Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5285ddc2b2 | ||
|
|
4c1dea77e1 | ||
|
|
99aca7d43d | ||
|
|
27cccd8964 | ||
|
|
03226f8a53 | ||
|
|
b66a993b35 | ||
|
|
01e59d96bd | ||
|
|
c35680eb13 | ||
|
|
bbe4a5643b | ||
|
|
f991f469cf | ||
|
|
7e8863fc7e |
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "Frozen pattern catalog: signature-indexed widget/platform/animation detectors. Matched against IR srcClass/tag/attrs (frozen capture evidence) by src/knowledge/patternIndex.ts. Deterministic: same catalog + same IR => same hints. Regenerate the lock after edits: node --import tsx src/knowledge/patternIndex.ts --write-lock",
|
||||
"patterns": [
|
||||
{ "id": "carousel_slick", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["slick-slider", "slick-track", "slick-slide", "slick-dots", "slick-list"] } },
|
||||
{ "id": "carousel_swiper", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["swiper", "swiper-wrapper", "swiper-slide", "swiper-container", "swiper-pagination"] } },
|
||||
{ "id": "carousel_splide", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["splide", "splide__track", "splide__slide", "splide__list"] } },
|
||||
{ "id": "carousel_owl", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["owl-carousel", "owl-stage", "owl-item"] } },
|
||||
{ "id": "carousel_flickity", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["flickity-enabled", "flickity-viewport", "flickity-slider"] } },
|
||||
{ "id": "carousel_glide", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["glide", "glide__track", "glide__slides", "glide__slide"] } },
|
||||
{ "id": "carousel_embla", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["embla", "embla__container", "embla__slide"] } },
|
||||
{ "id": "carousel_keen", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["keen-slider", "keen-slider__slide"] } },
|
||||
{ "id": "carousel_tiny", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["tns-outer", "tns-inner", "tns-slider", "tns-item"] } },
|
||||
{ "id": "carousel_bootstrap", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["carousel-inner", "carousel-item", "carousel-indicators"] } },
|
||||
{ "id": "marquee_rfm", "kind": "marquee", "flags": ["deferred_interactive"], "match": { "classPrefixes": ["rfm-"] } },
|
||||
{ "id": "marquee_generic", "kind": "marquee", "flags": ["deferred_interactive"], "match": { "classTokens": ["marquee", "ticker", "scroller"], "tags": ["marquee"] } },
|
||||
{ "id": "counter_odometer", "kind": "counter", "flags": ["counter", "deferred_interactive"], "match": { "classTokens": ["odometer", "odometer-inside", "odometer-digit"] } },
|
||||
{ "id": "counter_countup", "kind": "counter", "flags": ["counter", "deferred_interactive"], "match": { "classTokens": ["countup", "counter-up", "purecounter"] } },
|
||||
{ "id": "typed_text", "kind": "text_effect", "flags": ["deferred_interactive"], "match": { "classTokens": ["typed-cursor", "typewrite", "typewriter"] } },
|
||||
{ "id": "anim_aos", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classTokens": ["aos-init", "aos-animate"], "attrNames": ["data-aos"] } },
|
||||
{ "id": "anim_wow", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classTokens": ["wow"], "attrNames": ["data-wow-delay", "data-wow-duration"] } },
|
||||
{ "id": "anim_animate_css", "kind": "css_animation", "flags": ["motion_lib"], "match": { "classPrefixes": ["animate__"], "classTokens": ["animated"] } },
|
||||
{ "id": "anim_locomotive", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "attrNames": ["data-scroll", "data-scroll-container", "data-scroll-section"] } },
|
||||
{ "id": "anim_rellax", "kind": "parallax", "flags": ["motion_lib"], "match": { "classTokens": ["rellax"], "attrNames": ["data-rellax-speed"] } },
|
||||
{ "id": "anim_gsap_scrolltrigger", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classPrefixes": ["gsap-"], "attrNames": ["data-gsap"] } },
|
||||
{ "id": "anim_sal", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "attrNames": ["data-sal", "data-sal-delay"] } },
|
||||
{ "id": "lottie_widget", "kind": "lottie", "flags": ["deferred_interactive", "motion_lib"], "match": { "classTokens": ["lottie", "lottie-animation"], "tags": ["lottie-player", "dotlottie-player"], "attrNames": ["data-lottie", "data-animation-path"] } },
|
||||
{ "id": "particles_bg", "kind": "background_effect", "flags": ["deferred_interactive"], "match": { "classTokens": ["particles-js", "tsparticles"], "idPrefixes": ["particles-js"] } },
|
||||
{ "id": "video_player_lib", "kind": "video_player", "flags": ["deferred_interactive"], "match": { "classTokens": ["video-js", "plyr", "jwplayer", "vjs-tech"] } },
|
||||
{ "id": "lightbox_gallery", "kind": "lightbox", "flags": ["deferred_interactive", "gallery"], "match": { "classPrefixes": ["lg-", "pswp"], "classTokens": ["fancybox", "glightbox", "lightbox"] } },
|
||||
{ "id": "platform_elementor", "kind": "platform", "flags": ["platform_elementor"], "match": { "classPrefixes": ["elementor-"], "classTokens": ["elementor"] } },
|
||||
{ "id": "platform_wp_blocks", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["wp-block-", "wp-container-"] } },
|
||||
{ "id": "platform_wpbakery", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["vc_"], "classTokens": ["wpb_wrapper", "wpb_row"] } },
|
||||
{ "id": "platform_divi", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["et_pb_"] } },
|
||||
{ "id": "platform_webflow", "kind": "platform", "flags": ["platform_webflow"], "match": { "classTokens": ["w-nav", "w-slider", "w-dropdown", "w-container", "w-richtext"], "attrNames": ["data-wf-page", "data-wf-site"] } },
|
||||
{ "id": "platform_shopify", "kind": "platform", "flags": ["platform_shopify"], "match": { "classTokens": ["shopify-section"], "idPrefixes": ["shopify-section"] } },
|
||||
{ "id": "platform_squarespace", "kind": "platform", "flags": ["platform_squarespace"], "match": { "classPrefixes": ["sqs-"], "attrNames": ["data-block-type"] } },
|
||||
{ "id": "platform_framer", "kind": "platform", "flags": ["platform_framer"], "match": { "classPrefixes": ["framer-"], "attrNames": ["data-framer-name"] } },
|
||||
{ "id": "platform_woocommerce", "kind": "platform", "flags": ["platform_wordpress", "ecommerce"], "match": { "classTokens": ["woocommerce", "woocommerce-page"] } },
|
||||
{ "id": "nav_hamburger", "kind": "nav_toggle", "flags": ["nav_toggle"], "match": { "classTokens": ["hamburger", "navbar-toggler", "menu-toggle", "nav-toggle", "burger", "menu-btn"] } },
|
||||
{ "id": "nav_mega_menu", "kind": "nav_menu", "flags": ["nav_toggle"], "match": { "classTokens": ["mega-menu", "megamenu", "dropdown-menu", "sub-menu"] } },
|
||||
{ "id": "consent_onetrust", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["ot-sdk-", "onetrust-"], "idPrefixes": ["onetrust-"] } },
|
||||
{ "id": "consent_cookiebot", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "idPrefixes": ["CybotCookiebot"] } },
|
||||
{ "id": "consent_osano", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["osano-"] } },
|
||||
{ "id": "consent_cookieyes", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["cky-"] } },
|
||||
{ "id": "consent_complianz", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["cmplz-"] } },
|
||||
{ "id": "chat_intercom", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["intercom-"], "idPrefixes": ["intercom-"] } },
|
||||
{ "id": "chat_crisp", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["crisp-"], "idPrefixes": ["crisp-"] } },
|
||||
{ "id": "chat_drift", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["drift-"], "idPrefixes": ["drift-"] } },
|
||||
{ "id": "chat_hubspot", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["hs-chat-", "hubspot-"], "idPrefixes": ["hubspot-"] } },
|
||||
{ "id": "chat_tawk", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "idPrefixes": ["tawk-"] } },
|
||||
{ "id": "accordion_generic", "kind": "accordion", "flags": ["nav_toggle"], "match": { "classTokens": ["accordion", "accordion-item", "accordion-header", "collapsible"] } },
|
||||
{ "id": "tabs_generic", "kind": "tabs", "flags": ["nav_toggle"], "match": { "classTokens": ["tabs", "tab-content", "tab-pane", "tablist"] } }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3f46aca40bb69d5c114e957c28b3a4924c58902cecb35332d34f3065114d0e75
|
||||
@@ -36,8 +36,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1",
|
||||
"pixelmatch": "7.2.0",
|
||||
"playwright": "1.56.1",
|
||||
"pixelmatch": "5.3.0",
|
||||
"pngjs": "7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+238
-53
@@ -15,6 +15,8 @@ import { discoverBreakpoints } from "./breakpoints.js";
|
||||
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
|
||||
import { sha1_12, round } from "../util/canonical.js";
|
||||
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
|
||||
import { buildDeterministicEnvShim, captureEpochMs, DEFAULT_PRNG_SEED } from "../util/envShim.js";
|
||||
import { isBotWall, classifyNavFailure } from "../util/captureFailure.js";
|
||||
|
||||
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
|
||||
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
|
||||
@@ -65,6 +67,10 @@ export type SeoResource = {
|
||||
export type CaptureResult = {
|
||||
sourceUrl: string;
|
||||
capturedAt: string;
|
||||
// Deterministic-env shim parameters actually used for this run (Item 1). Recorded
|
||||
// so a recapture can reuse the exact {seed, epochMs} — the pinned wall clock is a
|
||||
// run-metadata value, not a hardcoded constant. Absent when the shim was disabled.
|
||||
deterministicEnv?: { seed: number; epochMs: number };
|
||||
viewports: number[];
|
||||
// Browser-as-oracle: the widths where the SOURCE layout actually restructures (display /
|
||||
// flex-direction / wrap / grid-track-count / position / visibility flips), found by sweeping the
|
||||
@@ -797,6 +803,66 @@ async function captureCanvasStills(page: import("playwright").Page): Promise<Can
|
||||
// instead of returning a truncated image on pathologically tall pages.
|
||||
const CDP_MAX_SHOT_DIMENSION = 16_384;
|
||||
|
||||
/**
|
||||
* Item 2: extended lazy-asset DISCOVERY. Returns the deduped, sorted set of asset
|
||||
* URLs referenced through channels the walker + promoteLazyMedia miss:
|
||||
* (1) <noscript> fallback markup (the walker skips <noscript> entirely) — parsed as
|
||||
* HTML so real img/source src + srcset are read, not regex-scraped;
|
||||
* (2) inline element style="…url(…)…" (the walker harvests url() from STYLESHEET
|
||||
* rules only, not from an element's own style attribute);
|
||||
* (3) data-background / data-background-image lazy-bg aliases (beyond promoteLazyMedia's
|
||||
* single data-bg);
|
||||
* (4) img[loading=lazy] currentSrc — the variant a below-fold image resolved to.
|
||||
* data: URLs and unresolvable refs are dropped. Discovery-only: the caller RECORDS these
|
||||
* for the fallback downloader; nothing here mutates the DOM (snapshots are already taken).
|
||||
* Exported for the fixture tests (page.evaluate'd directly).
|
||||
*/
|
||||
export function discoverLazyAssetsInPage(): string[] {
|
||||
const urls = new Set<string>();
|
||||
const push = (v: string | null | undefined): void => {
|
||||
if (!v) return;
|
||||
const s = v.trim();
|
||||
if (!s || s.startsWith("data:")) return;
|
||||
try { urls.add(new URL(s, document.baseURI).href); } catch { /* not a URL */ }
|
||||
};
|
||||
const firstOfSrcset = (ss: string): void => {
|
||||
for (const part of ss.split(",")) push(part.trim().split(/\s+/)[0]);
|
||||
};
|
||||
// (1) <noscript> fallback markup.
|
||||
for (const ns of Array.from(document.querySelectorAll("noscript"))) {
|
||||
const html = ns.textContent ?? "";
|
||||
if (!html.includes("<")) continue;
|
||||
let frag: Document;
|
||||
try { frag = new DOMParser().parseFromString(html, "text/html"); } catch { continue; }
|
||||
for (const el of Array.from(frag.querySelectorAll("img,source"))) {
|
||||
push(el.getAttribute("src"));
|
||||
const ss = el.getAttribute("srcset");
|
||||
if (ss) firstOfSrcset(ss);
|
||||
}
|
||||
}
|
||||
// (2) inline style url(...) on any element (background-image / mask / etc.).
|
||||
for (const el of Array.from(document.querySelectorAll("[style]"))) {
|
||||
const st = el.getAttribute("style") ?? "";
|
||||
if (!st.includes("url(")) continue;
|
||||
for (const m of st.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) push(m[2]);
|
||||
}
|
||||
// (3) lazy-background data-* aliases the promoter doesn't cover.
|
||||
const BG_ATTRS = ["data-background", "data-background-image"];
|
||||
for (const el of Array.from(document.querySelectorAll(BG_ATTRS.map((a) => `[${a}]`).join(",")))) {
|
||||
for (const a of BG_ATTRS) {
|
||||
const raw = el.getAttribute(a);
|
||||
if (!raw) continue;
|
||||
const m = /url\(\s*(['"]?)([^'")]+)\1\s*\)/i.exec(raw);
|
||||
push(m ? m[2] : raw);
|
||||
}
|
||||
}
|
||||
// (4) the variant a lazy image actually resolved to.
|
||||
for (const img of Array.from(document.querySelectorAll<HTMLImageElement>("img[loading=lazy]"))) {
|
||||
push(img.currentSrc || img.src);
|
||||
}
|
||||
return [...urls].sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page screenshot via CDP `Page.captureScreenshot` with `captureBeyondViewport:true`.
|
||||
* Unlike Playwright's `fullPage:true` (which scroll-stitches — scrolling the page to render
|
||||
@@ -1004,6 +1070,14 @@ export async function captureSite(opts: {
|
||||
interactions?: boolean; // Stage 4: opt-in interaction capture (hover/focus + patterns)
|
||||
motion?: boolean; // Stage 5: opt-in motion capture (WAAPI + rotating text)
|
||||
breakpoints?: boolean; // discover the source's real responsive band edges (default on; read-only sweep)
|
||||
deterministicEnv?: boolean; // Item 1: seed Math.random + pin the Date epoch BEFORE page JS runs
|
||||
// (default on), so shuffled carousels / random ids / relative-time text
|
||||
// ("posted N minutes ago") render the same across captures. Relative time
|
||||
// still advances (timers behave) and performance.now() stays real.
|
||||
captureEpochMs?: number | string; // pinned wall-clock origin for the shim (ms since epoch, or a
|
||||
// date string). Recorded in the result; a recapture passes the
|
||||
// previously-recorded value to reproduce time-dependent content.
|
||||
prngSeed?: number; // mulberry32 seed for the pinned Math.random (recorded in the result).
|
||||
screenshots?: boolean; // write per-viewport full-page PNGs (default on). ONLY the validator reads these
|
||||
// (generation never touches pixels), and full-page shots of tall pages are the
|
||||
// dominant capture cost — so a production clone that won't be perceptually graded
|
||||
@@ -1013,6 +1087,13 @@ export async function captureSite(opts: {
|
||||
}): Promise<CaptureResult> {
|
||||
const viewports = opts.viewports ?? [...REQUIRED_VIEWPORTS];
|
||||
const log = opts.log ?? (() => {});
|
||||
// Item 1: resolve the deterministic-env parameters once, at run start, and record
|
||||
// them in the result. The epoch is a run-metadata value (recorded, reproducible),
|
||||
// not a hardcoded constant; performance.now() is left real so motion capture is
|
||||
// unaffected. Defaults on; opts.deterministicEnv === false disables the shim.
|
||||
const deterministicEnvOn = opts.deterministicEnv !== false;
|
||||
const prngSeed = Number.isFinite(opts.prngSeed) ? Math.trunc(opts.prngSeed!) : DEFAULT_PRNG_SEED;
|
||||
const epochMs = captureEpochMs(opts.captureEpochMs);
|
||||
const captureDir = join(opts.outDir, "capture");
|
||||
const screenshotsDir = join(opts.outDir, "screenshots");
|
||||
const cssDir = join(captureDir, "css");
|
||||
@@ -1178,64 +1259,136 @@ export async function captureSite(opts: {
|
||||
// hero on each fresh load) and session-reuse degeneration (warbyparker returned
|
||||
// a 13-node shell when reloaded with a carried session). The IR's cross-viewport
|
||||
// alignment then operates on one logical DOM that CSS merely reflows.
|
||||
const context: BrowserContext = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: canonical, height: viewportHeight(canonical) },
|
||||
deviceScaleFactor: 1,
|
||||
userAgent: DESKTOP_UA,
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
// tsx/esbuild wraps functions with a __name() helper for stack traces; that
|
||||
// helper does not exist in the browser when we serialize page.evaluate
|
||||
// callbacks. Shim it (as a raw string so it isn't itself transformed).
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
const bodyPromises: Promise<void>[] = [];
|
||||
|
||||
page.on("response", (resp) => {
|
||||
try {
|
||||
const url = resp.url();
|
||||
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
||||
const ct = resp.headers()["content-type"] || null;
|
||||
const type = classifyAsset(url, ct);
|
||||
if (!type) return;
|
||||
const status = resp.status();
|
||||
recordAsset(url, type, ct, status, "network");
|
||||
const existing = assetMap.get(url);
|
||||
if (existing?.storedAs) return;
|
||||
if (status >= 400) return;
|
||||
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
|
||||
// ship a corrupt file under first-stored-wins and the full-download fallback
|
||||
// would then skip the asset. Record only; the fallback pass fetches the 200 body.
|
||||
if (status === 206) return;
|
||||
bodyPromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const buf = await resp.body();
|
||||
storeBytes(url, type, buf);
|
||||
if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") });
|
||||
if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
|
||||
} catch { /* body unavailable */ }
|
||||
})(),
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
// Response listener + init scripts are attached the same way to the initial page
|
||||
// and to any fresh recovery page (Item 3c), so factor the wiring into one helper.
|
||||
const attachResponseListener = (pg: import("playwright").Page): void => {
|
||||
pg.on("response", (resp) => {
|
||||
try {
|
||||
const url = resp.url();
|
||||
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
||||
const ct = resp.headers()["content-type"] || null;
|
||||
const type = classifyAsset(url, ct);
|
||||
if (!type) return;
|
||||
const status = resp.status();
|
||||
recordAsset(url, type, ct, status, "network");
|
||||
const existing = assetMap.get(url);
|
||||
if (existing?.storedAs) return;
|
||||
if (status >= 400) return;
|
||||
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
|
||||
// ship a corrupt file under first-stored-wins and the full-download fallback
|
||||
// would then skip the asset. Record only; the fallback pass fetches the 200 body.
|
||||
if (status === 206) return;
|
||||
bodyPromises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const buf = await resp.body();
|
||||
storeBytes(url, type, buf);
|
||||
if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") });
|
||||
if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
|
||||
} catch { /* body unavailable */ }
|
||||
})(),
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
};
|
||||
|
||||
// Single navigation at the canonical width; every viewport below is a resize.
|
||||
log({ event: "goto", url: opts.url });
|
||||
let navigated = false;
|
||||
let navErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 3 && !navigated; attempt++) {
|
||||
try {
|
||||
await page.goto(opts.url, { waitUntil: attempt === 0 ? "load" : "domcontentloaded", timeout: 45000 });
|
||||
navigated = true;
|
||||
} catch (e) {
|
||||
navErr = e;
|
||||
await page.waitForTimeout(1000);
|
||||
const newSession = async (): Promise<{ context: BrowserContext; page: import("playwright").Page }> => {
|
||||
const ctx: BrowserContext = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: canonical, height: viewportHeight(canonical) },
|
||||
deviceScaleFactor: 1,
|
||||
userAgent: DESKTOP_UA,
|
||||
javaScriptEnabled: true,
|
||||
});
|
||||
const pg = await ctx.newPage();
|
||||
attachResponseListener(pg);
|
||||
// tsx/esbuild wraps functions with a __name() helper for stack traces; that
|
||||
// helper does not exist in the browser when we serialize page.evaluate
|
||||
// callbacks. Shim it (as a raw string so it isn't itself transformed).
|
||||
await pg.addInitScript(ESBUILD_SHIM);
|
||||
// Item 1: deterministic-env shim — seed Math.random + pin the Date epoch BEFORE
|
||||
// any page script runs. Raw string so tsx/esbuild leaves it byte-exact; the
|
||||
// {seed, epochMs} are the recorded run-metadata values. performance.now() stays
|
||||
// real, so motion.ts marquee/rotator velocity sampling is unaffected.
|
||||
if (deterministicEnvOn) {
|
||||
await pg.addInitScript(buildDeterministicEnvShim({ seed: prngSeed, epochMs }));
|
||||
}
|
||||
return { context: ctx, page: pg };
|
||||
};
|
||||
|
||||
// Single context + single navigation; every viewport is captured by RESIZING the
|
||||
// same loaded page (see the block comment above). Item 3c: session recovery is
|
||||
// deliberately scoped to the INITIAL navigation only — carrying one session across
|
||||
// viewports is load-bearing (a reload can degenerate: warbyparker's 13-node shell),
|
||||
// so we never recover mid-loop; we only retry the first nav with a fresh context
|
||||
// when the page/browser died before any content was captured.
|
||||
let { context, page } = await newSession();
|
||||
|
||||
// Total nav budget (Item 3a): a hung origin used to stall for up to ~3×45s + retries
|
||||
// with no ceiling. Bound the WHOLE navigation phase so a dead host fails fast with a
|
||||
// structured error instead of tying up the pipeline. Attempt 0 waits for `load`;
|
||||
// later attempts fall back to `domcontentloaded` (a heavy page may never fire `load`).
|
||||
const NAV_BUDGET_MS = 90_000;
|
||||
const navigateLoaded = async (pg: import("playwright").Page): Promise<void> => {
|
||||
log({ event: "goto", url: opts.url });
|
||||
const navStart = Date.now();
|
||||
let navigated = false;
|
||||
let navErr: unknown = null;
|
||||
for (let attempt = 0; attempt < 3 && !navigated; attempt++) {
|
||||
const remaining = NAV_BUDGET_MS - (Date.now() - navStart);
|
||||
if (remaining < 5_000) break; // not enough budget left for a meaningful attempt
|
||||
try {
|
||||
await pg.goto(opts.url, {
|
||||
waitUntil: attempt === 0 ? "load" : "domcontentloaded",
|
||||
timeout: Math.min(attempt === 0 ? 45_000 : 20_000, remaining),
|
||||
});
|
||||
navigated = true;
|
||||
} catch (e) {
|
||||
navErr = e;
|
||||
await pg.waitForTimeout(1000);
|
||||
}
|
||||
}
|
||||
if (!navigated) {
|
||||
throw new Error(
|
||||
`navigation failed for ${opts.url} within ${Math.round((Date.now() - navStart) / 1000)}s: ${String((navErr as { message?: string })?.message ?? navErr).slice(0, 300)}`,
|
||||
);
|
||||
}
|
||||
await settle(pg);
|
||||
};
|
||||
|
||||
try {
|
||||
await navigateLoaded(page);
|
||||
} catch (navErr) {
|
||||
// Item 3c: one fresh-context retry, but ONLY for a session-death class of failure
|
||||
// (browser/page/context closed, crash, transient reset/timeout). A wall or a hard
|
||||
// terminal error (DNS/cert/refused) is not retried — a fresh context can't fix it.
|
||||
const cls = classifyNavFailure(navErr);
|
||||
if (cls !== "retryable") throw navErr;
|
||||
log({ event: "capture_recover", reason: "session_death_on_initial_nav" });
|
||||
try { if (!page.isClosed()) await page.close(); } catch { /* ignore */ }
|
||||
try { await context.close(); } catch { /* ignore */ }
|
||||
({ context, page } = await newSession());
|
||||
await navigateLoaded(page); // second failure propagates (no further retry)
|
||||
}
|
||||
|
||||
// Item 3b: bot/auth-wall fast-fail. A wall page would otherwise burn the full
|
||||
// multi-viewport capture and only get flagged by the pollution gate afterward.
|
||||
// Uses the SAME signatures + node-count threshold as the gate (util/captureFailure.ts)
|
||||
// so the abort and the grade agree; the pollution gate stays the shipped-capture
|
||||
// authority, this only saves the wasted work of capturing an obvious wall.
|
||||
const wallProbe = await page
|
||||
.evaluate(() => ({
|
||||
text: (document.body?.innerText ?? "").slice(0, 20_000),
|
||||
nodes: document.querySelectorAll("*").length,
|
||||
}))
|
||||
.catch(() => null);
|
||||
if (isBotWall(wallProbe)) {
|
||||
throw new Error(
|
||||
`auth/bot wall detected at ${opts.url} (${wallProbe!.nodes} nodes, wall text matched): capture aborted early`,
|
||||
);
|
||||
}
|
||||
if (!navigated) throw navErr;
|
||||
await settle(page);
|
||||
|
||||
// Stage 2: lazy-loader promotion. WP Rocket/lazysizes keep a 0-size placeholder in
|
||||
// `src` with the real URL in data attrs; autoScroll outruns their IntersectionObserver
|
||||
@@ -1641,6 +1794,37 @@ export async function captureSite(opts: {
|
||||
log({ event: "captured", viewport: vw, nodes: snapshot.doc.nodeCount, scrollHeight: snapshot.doc.scrollHeight });
|
||||
}
|
||||
|
||||
// Item 2: extended lazy-asset discovery sweep. The walker + promoteLazyMedia
|
||||
// already harvest img/source src/srcset and the common data-* lazy attrs
|
||||
// (data-src / data-lazy-src / data-original / data-ll-src / data-(lazy-)srcset)
|
||||
// and picture <source> variants — this sweep ONLY adds the references those miss:
|
||||
// • <noscript> contents — the walker skips <noscript> entirely, but the fallback
|
||||
// markup real browsers render with JS off often carries the true <img>/<source> URL;
|
||||
// • inline element style="…url(…)…" — the walker harvests url() from STYLESHEET
|
||||
// rules, not from an element's own style attribute (hero background-image inlined);
|
||||
// • data-background / data-background-image — lazy-bg aliases beyond promoteLazyMedia's
|
||||
// single data-bg;
|
||||
// • img[loading=lazy] currentSrc — the resolved variant a below-fold image settled on.
|
||||
// Discovery-ONLY: URLs are recorded through recordAsset so the fallback downloader
|
||||
// fetches them; nothing is promoted into the DOM (the snapshots are already taken, so
|
||||
// this can never shift a captured layout). Bounded so a pathological page can't explode
|
||||
// the fallback fetch phase.
|
||||
try {
|
||||
const sweptRefs: string[] = await page.evaluate(discoverLazyAssetsInPage);
|
||||
let swept = 0;
|
||||
for (const url of sweptRefs) {
|
||||
if (swept >= 120) break; // bound pathological pages (fallback fetch is 30s/asset)
|
||||
if (!/^https?:\/\//i.test(url) || assetMap.has(url)) continue;
|
||||
const t = classifyAsset(url, null);
|
||||
if (!t || !["image", "svg", "video", "font", "lottie"].includes(t)) continue;
|
||||
recordAsset(url, t, null, null, "lazy-sweep");
|
||||
swept++;
|
||||
}
|
||||
if (swept) log({ event: "lazy_sweep", recorded: swept, seen: sweptRefs.length });
|
||||
} catch (e) {
|
||||
log({ event: "lazy_sweep_error", error: String((e as { message?: string })?.message ?? e).slice(0, 160) });
|
||||
}
|
||||
|
||||
// Browser-as-oracle: discover the source's real responsive band edges by sweeping the viewport
|
||||
// and binary-searching each width where the discrete (media-query-toggled) layout signature
|
||||
// changes. Read-only and bounded; runs once here — overlays are dismissed and the DOM settled, so
|
||||
@@ -1754,6 +1938,7 @@ export async function captureSite(opts: {
|
||||
const result: CaptureResult = {
|
||||
sourceUrl: opts.url,
|
||||
capturedAt: new Date().toISOString(),
|
||||
...(deterministicEnvOn ? { deterministicEnv: { seed: prngSeed, epochMs } } : {}),
|
||||
viewports,
|
||||
breakpoints: discoveredBreakpoints,
|
||||
perViewport,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { FontGraph } from "../infer/fonts.js";
|
||||
import { SYSTEM_FALLBACK } from "../infer/fonts.js";
|
||||
import { detectComponents, type ComponentPlan, type ComponentCluster } from "../infer/components.js";
|
||||
import { buildClassMap } from "./classMap.js";
|
||||
import { generatePreviewHtml } from "./preview.js";
|
||||
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
|
||||
import { planSections, type SectionPlan } from "./sectionSplit.js";
|
||||
import type { RecipeReport } from "../infer/recipes.js";
|
||||
@@ -2659,7 +2660,7 @@ export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | und
|
||||
};
|
||||
}
|
||||
|
||||
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[] } {
|
||||
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[]; previewHtml: string } {
|
||||
const { ir, assetGraph, fontGraph, appDir, sourceUrl } = input;
|
||||
const assetMap = buildAssetMap(assetGraph);
|
||||
const framework = input.framework ?? "next";
|
||||
@@ -2789,6 +2790,16 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
||||
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
||||
}
|
||||
writeText(join(rootDir, "ditto.css"), cloneCss);
|
||||
// Fast, self-contained static preview (runtime-free single HTML file) the service can
|
||||
// render within seconds of `generate`, BEFORE the Next build + deploy completes. Emitted
|
||||
// at generated/app/preview.html (assets relative to public/); shares the same IR + css.ts
|
||||
// rule/banding collectors and the same propsList/resolveTag decision code as the app,
|
||||
// so it shows what the built app will show without a second maintained emission path.
|
||||
const previewPath = "preview.html";
|
||||
writeText(join(appDir, previewPath), generatePreviewHtml({
|
||||
ir, assetMap, fontGraph, tokensCss, sourceUrl,
|
||||
colorVar: input.colorVar, tokenResolver: input.tokenResolver,
|
||||
}));
|
||||
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
|
||||
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
|
||||
if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
|
||||
@@ -2830,7 +2841,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
||||
],
|
||||
});
|
||||
|
||||
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] };
|
||||
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [], previewHtml: previewPath };
|
||||
}
|
||||
|
||||
/** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at
|
||||
|
||||
@@ -1952,6 +1952,7 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
|
||||
if (node.tag !== "a" && node.tag !== "button") return false;
|
||||
const widths: number[] = [];
|
||||
let sampled = 0;
|
||||
let contentFit = true;
|
||||
for (const vp of viewports) {
|
||||
const cs = node.computedByVp[vp];
|
||||
const bb = node.bboxByVp[vp];
|
||||
@@ -1962,7 +1963,21 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
|
||||
if (pf(cs.borderTopLeftRadius) < 12 && pf(cs.borderTopRightRadius) < 12) return false;
|
||||
if (bb.width < 120 || bb.height < 28) return false;
|
||||
widths.push(bb.width);
|
||||
// Content-fit evidence: the sizing probe proved `width:auto` reproduces this box AND it paints
|
||||
// exactly at its own max-content width, with genuinely wrappable text inside (wMin < wMax).
|
||||
const sz = node.sizingByVp?.[vp];
|
||||
if (!(sz?.wAuto === true && sz.wMax != null && sz.wMin != null && sz.wMin < sz.wMax - 2 &&
|
||||
Math.abs(bb.width - sz.wMax) <= Math.max(1.5, 0.01 * sz.wMax))) contentFit = false;
|
||||
}
|
||||
// A constant width alone is weak evidence of an AUTHORED fixed width: a content-sized chip whose
|
||||
// label never changes paints the same width at every viewport it's shown at. When the probe proved
|
||||
// width:auto reproduces the box at its max-content at EVERY sample, the width is content-derived —
|
||||
// don't lock it. Baking that px invites the emitters' quantization (snapLen's 0.1px rounding,
|
||||
// snapBase's ≤0.25px spacing-scale snap) to land fractionally BELOW the intrinsic single-line
|
||||
// width, wrapping the label onto a second line; the probe verdict (sizingVerdict → width:auto)
|
||||
// reproduces the box intrinsically at every width instead. Authored intent (sourceFixedWidth /
|
||||
// sourceFixedSize, checked by the caller) still locks.
|
||||
if (sampled >= 1 && contentFit) return false;
|
||||
return sampled >= 2 && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) <= 2;
|
||||
}
|
||||
|
||||
@@ -3120,7 +3135,10 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
|
||||
* column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit
|
||||
* `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture.
|
||||
* Conservative by construction — every guard must hold at every painted viewport:
|
||||
* • text leaf (direct text, no element children), whitespace not already preserved (`pre*`);
|
||||
* • text leaf (direct text; element children only if REPLACED — an icon <svg>/<img> beside the
|
||||
* label, the icon+text chip shape. A replaced box can't wrap and its constant width is already
|
||||
* inside the probe's wMin/wMax, so the math below is unchanged; any other element child may
|
||||
* carry its own text and bails), whitespace not already preserved (`pre*`);
|
||||
* • single line: box height ≈ one line box (line-height + vertical padding/border), so a genuinely
|
||||
* wrapping paragraph (≥2 line boxes tall) is excluded;
|
||||
* • wrap-vulnerable: max-content (unwrapped) width ≥ available container width − 2 and ≤ it + 1 —
|
||||
@@ -3130,7 +3148,7 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
|
||||
* Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent ⇒ no emit. */
|
||||
function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
|
||||
if (!parentNode) return false;
|
||||
if (hasElementChild(node)) return false;
|
||||
if (node.children.some((c) => !isTextChild(c) && !REPLACED.has(c.tag))) return false;
|
||||
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
|
||||
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
|
||||
let painted = 0;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Tokens } from "../infer/tokens.js";
|
||||
import type { AssetGraph } from "../infer/assets.js";
|
||||
import type { FontGraph } from "../infer/fonts.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
import type { PatternHints } from "../knowledge/patternIndex.js";
|
||||
|
||||
export const COMPILER_VERSION = "0.1.0";
|
||||
export const SCHEMA_VERSION = 1;
|
||||
@@ -16,8 +17,13 @@ export function buildManifest(args: {
|
||||
fontGraph: FontGraph;
|
||||
capture: CaptureResult;
|
||||
componentCount: number;
|
||||
patternHints?: PatternHints;
|
||||
// Relative path (within generated/app) of the static preview artifact, when emitted.
|
||||
// Lets the service/client show the cloned page from the file map BEFORE the Next build
|
||||
// + deploy finishes, then swap to the deployed app. A fixed string — no timestamps.
|
||||
previewHtml?: string;
|
||||
}): Record<string, unknown> {
|
||||
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount } = args;
|
||||
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints, previewHtml } = args;
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
let downloaded = 0, skipped = 0;
|
||||
@@ -56,6 +62,26 @@ export function buildManifest(args: {
|
||||
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
|
||||
},
|
||||
components: { count: componentCount },
|
||||
// Static preview artifact (runtime-free, single HTML file) the client can render
|
||||
// immediately from the file map. Omitted when not emitted.
|
||||
...(previewHtml ? { preview_html: previewHtml } : {}),
|
||||
// Frozen-catalog pattern evidence (hint-only, additive): library/platform fingerprints
|
||||
// detected in the IR, with the node cids that carried each signature (bounded pre-order
|
||||
// sample). Deterministic — matches are id-sorted and cids are pre-order, so the same
|
||||
// catalog + IR yields byte-identical evidence. Generated docs read this to say e.g.
|
||||
// "Swiper carousel detected". Omitted when no hints were computed.
|
||||
...(patternHints
|
||||
? {
|
||||
patterns: {
|
||||
catalogVersion: patternHints.catalogVersion,
|
||||
catalogHash: patternHints.catalogHash,
|
||||
flags: patternHints.flags,
|
||||
platforms: patternHints.platforms,
|
||||
simpleStatic: patternHints.simpleStatic,
|
||||
matches: patternHints.matches.map((m) => ({ id: m.id, kind: m.kind, count: m.count, cids: m.cids })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Fidelity note: containers whose children were a DIFFERENT SET at some band viewport(s)
|
||||
// (content-identity drift — the source deterministically served other content there). The
|
||||
// clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead
|
||||
|
||||
@@ -13,6 +13,7 @@ import { buildCodeQualityReport, codeQualityReportToMarkdown, type CodeQualityRe
|
||||
import { interactionRejectedSet } from "./interactive.js";
|
||||
import { buildManifest } from "./manifest.js";
|
||||
import { buildSeoInventory, seoInventoryToMarkdown, type SeoInventory } from "./seo.js";
|
||||
import { resolvePatternHints, type PatternHints } from "../knowledge/patternIndex.js";
|
||||
import { writeJSON, writeText, readJSON, fileExists } from "../util/fsx.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
|
||||
@@ -24,6 +25,7 @@ export type GenerateAllResult = {
|
||||
fontGraph: FontGraph;
|
||||
recipeReport: RecipeReport;
|
||||
interactionRecipeReport: InteractionRecipeReport;
|
||||
patternHints: PatternHints;
|
||||
seoInventory: SeoInventory;
|
||||
codeQuality: CodeQualityReport;
|
||||
manifest: Record<string, unknown>;
|
||||
@@ -59,6 +61,10 @@ export function generateAll(opts: {
|
||||
|
||||
const sections = detectSections(ir);
|
||||
const tokens = extractTokens(ir);
|
||||
// Pattern hints: frozen-catalog signature scan over the IR (deterministic; the
|
||||
// pin is asserted here and throws on catalog/lock drift). Fed as ADDITIVE evidence
|
||||
// into recipe recognition — never overrides captured geometry.
|
||||
const patternHints = resolvePatternHints(ir);
|
||||
const assetGraph = buildAssetGraph(capture);
|
||||
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
|
||||
const seoInventory = buildSeoInventory(ir, assetGraph, capture);
|
||||
@@ -69,7 +75,7 @@ export function generateAll(opts: {
|
||||
const tokensCss = (palette.css ? palette.css + "\n" : "") + tokensToCss(tokens, true);
|
||||
const tokenResolver = buildTokenResolver(tokens);
|
||||
const primitives = recognizePrimitives(ir);
|
||||
const recipeReport = buildRecipeReport(ir, sections, primitives);
|
||||
const recipeReport = buildRecipeReport(ir, sections, primitives, patternHints);
|
||||
const interactionRecipeReport = buildInteractionRecipeReport(ir, sections, capture.interaction);
|
||||
// Patterns the interaction gate previously rejected (don't reproduce) → left static.
|
||||
const rejPath = join(sourceDir, "interaction-rejected.json");
|
||||
@@ -95,6 +101,7 @@ export function generateAll(opts: {
|
||||
writeJSON(join(outDir, "fonts.json"), fontGraph.entries);
|
||||
const inventory = inventoryOf(ir, primitives);
|
||||
writeJSON(join(outDir, "components.json"), inventory);
|
||||
writeJSON(join(outDir, "patterns.json"), patternHints);
|
||||
writeJSON(join(outDir, "recipes.json"), recipeReport);
|
||||
writeText(join(outDir, "recipes.md"), recipeReportToMarkdown(recipeReport));
|
||||
writeJSON(join(outDir, "interaction-recipes.json"), interactionRecipeReport);
|
||||
@@ -104,8 +111,8 @@ export function generateAll(opts: {
|
||||
const codeQuality = buildCodeQualityReport(appDir, recipeReport);
|
||||
writeJSON(join(outDir, "code-quality.json"), codeQuality);
|
||||
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
|
||||
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count });
|
||||
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints, previewHtml: gen.previewHtml });
|
||||
writeJSON(join(outDir, "manifest.json"), manifest);
|
||||
|
||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { isTextChild, type IR, type IRNode, type IRChild } from "../normalize/ir.js";
|
||||
import type { FontGraph } from "../infer/fonts.js";
|
||||
import { SYSTEM_FALLBACK } from "../infer/fonts.js";
|
||||
import { collectNodeRules, keyframesCss, computeBands, assembleCss, RESET_CSS } from "./css.js";
|
||||
import type { TokenResolver } from "../infer/tokens.js";
|
||||
import { propsList, resolveTag, resolveHtmlBg, htmlBgRule } from "./app.js";
|
||||
|
||||
/**
|
||||
* Fast, self-contained static preview artifact (`preview.html`).
|
||||
*
|
||||
* Product moment: the service shows the cloned page within seconds of `generate`
|
||||
* finishing — BEFORE the Next.js build + deploy completes — then swaps to the
|
||||
* deployed app. This artifact renders WITHOUT any build toolchain (no next / tailwind
|
||||
* / esbuild): it is pure string building over the same frozen IR + rules the JSX
|
||||
* emitter consumes.
|
||||
*
|
||||
* SHARED DECISION CODE (not a parallel emission path):
|
||||
* - CSS: `collectNodeRules` + `keyframesCss` + `computeBands` + `assembleCss` from
|
||||
* css.ts — the identical per-node rule collection + media-query banding the semantic
|
||||
* class-map emitter (classMap.ts) and the legacy per-node emitter (generateCss) use.
|
||||
* We emit `.c<id>` selectors, exactly like `generateCss`, so the preview's computed
|
||||
* styles are byte-for-byte the same rules the built app resolves to. Crucially this
|
||||
* holds even though the app's DEFAULT output is Tailwind (utilities in the JSX, no
|
||||
* `.c<id>` rules) — the preview reuses the css-mode rule collector directly.
|
||||
* - HTML: `propsList` + `resolveTag` from app.ts — the SAME tag resolution, attribute
|
||||
* filtering, asset-URL mapping (src/poster/srcset), video/lottie poster stills, and
|
||||
* class assignment the JSX renderer uses. We translate the JSX-flavoured attribute
|
||||
* pairs to raw HTML attributes; we do NOT re-derive which attrs/assets to keep.
|
||||
*
|
||||
* Runtime-free: no DittoWire / DittoLottie / DittoMotion scripts. Lottie mounts and
|
||||
* <video> render as their captured poster/first-frame stills (the still is already
|
||||
* baked into the node's `poster`/`src` attr by capture + the asset pipeline, so
|
||||
* `propsList` emits it for us). Animations are frozen at their captured start state.
|
||||
*
|
||||
* Determinism (sacred): derived purely from the IR + rules + asset map; no timestamps,
|
||||
* no randomness. Two runs over the same frozen capture emit byte-identical HTML.
|
||||
*
|
||||
* Asset paths: the asset map yields root-absolute `/assets/cloned/...` (public/ served
|
||||
* at the app root under Next). preview.html physically sits at generated/app/preview.html
|
||||
* with assets under generated/app/public/assets/cloned/..., so we rewrite the leading
|
||||
* `/assets/` to `public/assets/` — a relative path that resolves both when the file is
|
||||
* opened at the generated app-dir root and when it is fetched alongside the file map.
|
||||
*/
|
||||
|
||||
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
||||
|
||||
// Inverse of app.ts's ATTR_RENAME (HTML → React). The JSX `propsList` renames HTML
|
||||
// attribute names to their React spelling (for → htmlFor, class → className, …); the
|
||||
// preview emits raw HTML, so map them back. Anything not listed is already an HTML name.
|
||||
const REACT_TO_HTML: Record<string, string> = {
|
||||
htmlFor: "for", className: "class", srcSet: "srcset", colSpan: "colspan",
|
||||
rowSpan: "rowspan", dateTime: "datetime", itemProp: "itemprop", hrefLang: "hreflang",
|
||||
autoPlay: "autoplay", playsInline: "playsinline", readOnly: "readonly",
|
||||
maxLength: "maxlength", crossOrigin: "crossorigin", noValidate: "novalidate",
|
||||
tabIndex: "tabindex", contentEditable: "contenteditable", defaultChecked: "checked",
|
||||
};
|
||||
|
||||
/** Rewrite the asset map's root-absolute `/assets/…` refs to the preview's relative
|
||||
* `public/assets/…` layout (preview.html at generated/app/, assets at generated/app/public/).
|
||||
* Applied to both inline CSS (url(/assets/…)) and HTML attribute values. Pure string op. */
|
||||
function relativizeAssets(s: string): string {
|
||||
return s.replace(/\/assets\//g, "public/assets/");
|
||||
}
|
||||
|
||||
function escapeAttr(v: string): string {
|
||||
return v.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function escapeText(raw: string): string {
|
||||
return raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** Collapse a text run under white-space:normal the way CSS renders it (mirrors app.ts's
|
||||
* collapseWs): every whitespace run → a single space. Verbatim under pre/pre-wrap etc. */
|
||||
function collapseWs(raw: string): string {
|
||||
return raw.replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function preservesWhitespace(n: IRNode, canonical: number): boolean {
|
||||
const ws = (n.computedByVp[canonical] ?? Object.values(n.computedByVp)[0])?.whiteSpace;
|
||||
if (ws) return /^(pre|pre-wrap|pre-line|break-spaces)$/.test(ws);
|
||||
return n.tag === "pre" || n.tag === "textarea";
|
||||
}
|
||||
|
||||
// Element-only parents whose direct whitespace-only text children are non-significant
|
||||
// (mirrors app.ts's ELEMENT_ONLY_PARENTS): a stray "\n " between <li>s is source
|
||||
// indentation, not content.
|
||||
const ELEMENT_ONLY_PARENTS = new Set(["ul", "ol", "table", "thead", "tbody", "tfoot", "tr", "select", "colgroup", "optgroup", "menu", "dl"]);
|
||||
|
||||
/** Translate one JSX-flavoured [key, valueSrc] pair from propsList into a raw HTML
|
||||
* attribute string (leading space), or "" to drop it. valueSrc is ready-to-emit JS
|
||||
* source: a JSON string literal, `true`, or a `{ __html: … }` object (SVG inner — the
|
||||
* caller handles that separately, so we skip it here). */
|
||||
function htmlAttr(key: string, valueSrc: string): string {
|
||||
// The SVG inner-markup prop is emitted as literal inner HTML by the caller.
|
||||
if (key === "dangerouslySetInnerHTML") return "";
|
||||
const rawName = key.startsWith('"') ? key.slice(1, -1) : key;
|
||||
const name = REACT_TO_HTML[rawName] ?? rawName;
|
||||
if (valueSrc === "true") return ` ${name}`;
|
||||
if (valueSrc === "false") return "";
|
||||
let value: string;
|
||||
if (valueSrc.startsWith('"')) {
|
||||
try { value = JSON.parse(valueSrc) as string; } catch { return ""; }
|
||||
} else {
|
||||
// A `{ __html … }` object or other non-string expression — not a plain HTML attr.
|
||||
return "";
|
||||
}
|
||||
// Asset attrs (src/poster/srcset) and any inline url() carry the /assets/ prefix.
|
||||
if (name === "src" || name === "poster" || name === "srcset" || name === "style") value = relativizeAssets(value);
|
||||
return ` ${name}="${escapeAttr(value)}"`;
|
||||
}
|
||||
|
||||
/** The raw-HTML attribute string for a node — REUSES propsList (app.ts) so tag/attr/asset
|
||||
* decisions are identical to the JSX path; we only re-serialize to HTML. No ctx is passed,
|
||||
* so propsList assigns the legacy `c<id>` class (matching our `.c<id>` CSS) and emits
|
||||
* data-cid; component/section/svg-module machinery is inert (the composed DOM is the same). */
|
||||
function attrsForNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): string {
|
||||
return propsList(node, assetMap, sourceUrl).map(([k, v]) => htmlAttr(k, v)).join("");
|
||||
}
|
||||
|
||||
/** The inner markup of an inline <svg> node, taken verbatim from the propsList
|
||||
* `dangerouslySetInnerHTML` prop (already browser-serialized, capture-id stripped, start
|
||||
* state revealed) — shared with the JSX path, url()s relativized. */
|
||||
function svgInner(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): string {
|
||||
for (const [k, v] of propsList(node, assetMap, sourceUrl)) {
|
||||
if (k !== "dangerouslySetInnerHTML") continue;
|
||||
const m = /\{\s*__html:\s*(".*")\s*\}$/s.exec(v);
|
||||
if (!m) return "";
|
||||
try { return relativizeAssets(JSON.parse(m[1]!) as string); } catch { return ""; }
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: string, canonical: number, indent: number, insideInteractive: boolean, insideTable: boolean): string {
|
||||
const pad = " ".repeat(indent);
|
||||
const tag = resolveTag(node, insideInteractive, insideTable);
|
||||
const attrs = attrsForNode(node, assetMap, sourceUrl);
|
||||
const childInteractive = insideInteractive || node.tag === "a" || node.tag === "button";
|
||||
const childTable = insideTable || node.tag === "table";
|
||||
|
||||
// Inline SVG: emit the shared inner markup verbatim inside the <svg> box.
|
||||
if (node.rawHTML && tag === "svg") {
|
||||
const inner = svgInner(node, assetMap, sourceUrl);
|
||||
return inner ? `${pad}<svg${attrs}>${inner}</svg>` : `${pad}<svg${attrs}></svg>`;
|
||||
}
|
||||
if (VOID_TAGS.has(tag)) return `${pad}<${tag}${attrs} />`;
|
||||
|
||||
const children = renderChildren(node.children, tag, assetMap, sourceUrl, canonical, indent + 1, childInteractive, childTable, preservesWhitespace(node, canonical));
|
||||
if (children.length === 0) return `${pad}<${tag}${attrs}></${tag}>`;
|
||||
return `${pad}<${tag}${attrs}>\n${children.join("\n")}\n${pad}</${tag}>`;
|
||||
}
|
||||
|
||||
function renderChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, canonical: number, indent: number, insideInteractive: boolean, insideTable: boolean, preserveWs: boolean): string[] {
|
||||
const pad = " ".repeat(indent);
|
||||
const parts: string[] = [];
|
||||
// Coalesce adjacent text children into one node (the browser merges them anyway).
|
||||
let textBuf = "";
|
||||
const flush = () => {
|
||||
if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; }
|
||||
const out = preserveWs ? textBuf : collapseWs(textBuf);
|
||||
if (out.length) parts.push(`${pad}${escapeText(out)}`);
|
||||
textBuf = "";
|
||||
};
|
||||
for (const c of children) {
|
||||
if (isTextChild(c)) { textBuf += c.text; continue; }
|
||||
// <source>/<track> children: kept only if the JSX path would keep them. The JSX
|
||||
// emitter filters video <track> and non-materialized <source>; but propsList/resolveTag
|
||||
// don't, so filter here to match. A video/picture <source> without a local candidate
|
||||
// and any <video> <track> would otherwise reference a missing/remote file.
|
||||
if (parentTag === "video" && c.tag === "track") continue;
|
||||
flush();
|
||||
parts.push(renderNode(c, assetMap, sourceUrl, canonical, indent, insideInteractive, insideTable));
|
||||
}
|
||||
flush();
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** The inline stylesheet: reset + fonts + tokens + page base + per-node rules + keyframes.
|
||||
* The per-node rules and keyframes come from the SHARED css.ts collectors (identical to
|
||||
* what the built app resolves to); all url()/asset refs are relativized. */
|
||||
function previewCss(ir: IR, assetMap: Map<string, string>, tokensCss: string, fontGraph: FontGraph, colorVar?: (v: string) => string | null, tokenResolver?: TokenResolver): string {
|
||||
const rules = collectNodeRules(ir, assetMap, undefined, colorVar, tokenResolver);
|
||||
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
|
||||
const kf = keyframesCss(ir, assetMap);
|
||||
const nodeCss = assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, kf);
|
||||
|
||||
const pv = ir.doc.perViewport[ir.doc.canonicalViewport];
|
||||
const htmlBg = resolveHtmlBg(pv);
|
||||
const noHScroll = Object.entries(ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
|
||||
const clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : "";
|
||||
|
||||
const doc = `${RESET_CSS}
|
||||
/* fonts */
|
||||
${fontGraph.css}
|
||||
|
||||
/* tokens */
|
||||
${tokensCss}
|
||||
|
||||
/* page base */
|
||||
${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
|
||||
|
||||
/* nodes */
|
||||
${nodeCss}`;
|
||||
return relativizeAssets(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the standalone `preview.html` document as a string. Pure + deterministic.
|
||||
* `tokensCss` is the assembled color-palette + token CSS (same string the pipeline
|
||||
* feeds generateApp). `sourceUrl` drives asset-URL resolution (matching the JSX path).
|
||||
*/
|
||||
export function generatePreviewHtml(input: {
|
||||
ir: IR;
|
||||
assetMap: Map<string, string>;
|
||||
fontGraph: FontGraph;
|
||||
tokensCss: string;
|
||||
sourceUrl: string;
|
||||
colorVar?: (v: string) => string | null;
|
||||
tokenResolver?: TokenResolver;
|
||||
}): string {
|
||||
const { ir, assetMap, fontGraph, tokensCss, sourceUrl, colorVar, tokenResolver } = input;
|
||||
const canonical = ir.doc.canonicalViewport;
|
||||
const css = previewCss(ir, assetMap, tokensCss, fontGraph, colorVar, tokenResolver);
|
||||
const body = renderChildren(ir.root.children, ir.root.tag, assetMap, sourceUrl, canonical, 2, false, false, false).join("\n");
|
||||
const bodyAttrs = attrsForNode(ir.root, assetMap, sourceUrl);
|
||||
const lang = ir.doc.lang || "en";
|
||||
const title = ir.doc.title || "Preview";
|
||||
return `<!doctype html>
|
||||
<html lang="${escapeAttr(lang)}">
|
||||
<head>
|
||||
<meta charset="${escapeAttr(ir.doc.charset || "utf-8")}" />
|
||||
<meta name="viewport" content="${escapeAttr(ir.doc.metaViewport || "width=device-width, initial-scale=1")}" />
|
||||
<title>${escapeText(title)}</title>
|
||||
<style>
|
||||
${css}
|
||||
</style>
|
||||
</head>
|
||||
<body${bodyAttrs}>
|
||||
${body}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
@@ -32,6 +32,10 @@ export type { GateResult } from "./validate/gates.js";
|
||||
export { buildIR } from "./normalize/ir.js";
|
||||
export type { IR } from "./normalize/ir.js";
|
||||
|
||||
// ---- Pattern knowledge (frozen catalog; deterministic hints; pin asserted on load) ----
|
||||
export { resolvePatternHints, loadPatternIndex, assertPinnedCatalog, matchCatalogNode } from "./knowledge/patternIndex.js";
|
||||
export type { PatternHints, PatternMatch, PatternDef, PatternCatalog } from "./knowledge/patternIndex.js";
|
||||
|
||||
// ---- Capture surface + version ----
|
||||
export { captureSite, REQUIRED_VIEWPORTS } from "./capture/capture.js";
|
||||
export type { CaptureResult } from "./capture/capture.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isTextChild } from "../normalize/ir.js";
|
||||
import type { PrimitiveType } from "./primitives.js";
|
||||
import type { Section } from "./sections.js";
|
||||
import { round } from "../util/canonical.js";
|
||||
import { matchCatalogNode, type PatternDef, type PatternHints } from "../knowledge/patternIndex.js";
|
||||
|
||||
export type RecipeKind = "logo-cloud" | "feature-grid" | "card-grid" | "product-grid" | "gallery-showcase" | "cta-band";
|
||||
|
||||
@@ -60,6 +61,8 @@ export type RecipeReport = {
|
||||
highConfidence: number;
|
||||
byKind: Record<string, number>;
|
||||
templateReadyKinds: RecipeKind[];
|
||||
/** page-level frozen-catalog pattern ids (from resolvePatternHints), when provided */
|
||||
catalogPatterns?: string[];
|
||||
};
|
||||
candidates: RecipeCandidate[];
|
||||
};
|
||||
@@ -78,6 +81,10 @@ type RecipeContext = {
|
||||
parentById: ParentMap;
|
||||
sectionByNodeId: SectionMap;
|
||||
primitives: Map<string, PrimitiveType>;
|
||||
/** per-node frozen-catalog matches, memoized (pattern catalog as a 4th evidence source) */
|
||||
catalogByCid: Map<string, PatternDef[]>;
|
||||
/** page-level catalog flags from resolvePatternHints (e.g. platform_shopify, ecommerce) */
|
||||
pageFlags: Set<string>;
|
||||
};
|
||||
|
||||
type ItemStats = {
|
||||
@@ -233,7 +240,7 @@ function nodeStats(ctx: RecipeContext, n: IRNode): ItemStats {
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeContext {
|
||||
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeContext {
|
||||
const nodes: IRNode[] = [];
|
||||
const byId: NodeMap = new Map();
|
||||
const parentById: ParentMap = new Map();
|
||||
@@ -254,9 +261,32 @@ function buildContext(ir: IR, sections: Section[], primitives: Map<string, Primi
|
||||
parentById,
|
||||
sectionByNodeId: new Map(sections.map((s) => [s.nodeId, s])),
|
||||
primitives,
|
||||
catalogByCid: new Map(),
|
||||
pageFlags: new Set(patternHints?.flags ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function catalogDefs(ctx: RecipeContext, node: IRNode): PatternDef[] {
|
||||
let defs = ctx.catalogByCid.get(node.id);
|
||||
if (!defs) {
|
||||
defs = matchCatalogNode(node);
|
||||
ctx.catalogByCid.set(node.id, defs);
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** First catalog pattern in `root`'s subtree matching `pred` (pre-order; memoized
|
||||
* per node). The frozen catalog is deterministic, so recipe evidence stays
|
||||
* byte-stable across regenerations. */
|
||||
function subtreeCatalogMatch(ctx: RecipeContext, root: IRNode, pred: (d: PatternDef) => boolean): PatternDef | null {
|
||||
for (const d of catalogDefs(ctx, root)) if (pred(d)) return d;
|
||||
for (const c of elementChildren(root)) {
|
||||
const hit = subtreeCatalogMatch(ctx, c, pred);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestSection(ctx: RecipeContext, n: IRNode): Section | undefined {
|
||||
let cur: IRNode | undefined = n;
|
||||
while (cur) {
|
||||
@@ -545,18 +575,23 @@ function detectLogoClouds(ctx: RecipeContext): RecipeDraft[] {
|
||||
const rootText = textContent(root, 1200);
|
||||
const layout = isLayoutish(ctx, parent, logos.map((s) => s.node));
|
||||
const avgHeight = logos.reduce((sum, s) => sum + (s.bbox?.height ?? 0), 0) / logos.length;
|
||||
// Logo strips often ride marquee libraries (react-fast-marquee, ticker) —
|
||||
// a catalog marquee hit is strong logo-cloud evidence.
|
||||
const catalogMarquee = subtreeCatalogMatch(ctx, parent, (d) => d.kind === "marquee");
|
||||
const signals = [
|
||||
`${logos.length} repeated media-light children`,
|
||||
layout ? "item parent behaves like grid/flex/wrapped row" : "item parent has repeated logo geometry",
|
||||
TRUSTED_COPY.test(rootText) ? "nearby copy matches trusted-by/brand language" : "",
|
||||
avgHeight <= 96 ? "logo item boxes stay small" : "",
|
||||
catalogMarquee ? `pattern catalog identifies a ${catalogMarquee.id} strip` : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.58
|
||||
+ Math.min(0.18, logos.length * 0.025)
|
||||
+ (logos.length / kids.length) * 0.12
|
||||
+ (layout ? 0.08 : 0)
|
||||
+ (TRUSTED_COPY.test(rootText) ? 0.10 : 0)
|
||||
+ (avgHeight <= 96 ? 0.04 : 0);
|
||||
+ (avgHeight <= 96 ? 0.04 : 0)
|
||||
+ (catalogMarquee ? 0.04 : 0);
|
||||
out.push(baseDraft(ctx, "logo-cloud", parent, logos, confidence, signals));
|
||||
}
|
||||
return out;
|
||||
@@ -568,6 +603,10 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
for (const parent of ctx.nodes) {
|
||||
if (parent.id === ctx.ir.root.id) continue;
|
||||
if (subtreeSourceClassMatches(parent, DEFERRED_INTERACTIVE_COLLECTION)) continue;
|
||||
// Catalog-known carousel/marquee libraries the regex misses (embla, flickity,
|
||||
// keen-slider, glide, tns, …) are the same deferred-interactive class: their
|
||||
// children are slides, not a static grid.
|
||||
if (subtreeCatalogMatch(ctx, parent, (d) => d.kind === "carousel" || d.kind === "marquee")) continue;
|
||||
const kids = visibleElementChildren(parent, ctx.cw);
|
||||
if (kids.length < 2 || kids.length > 36) continue;
|
||||
const stats = kids.map((k) => nodeStats(ctx, k));
|
||||
@@ -581,7 +620,10 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const rootTooBroad = rootInfo.root.id === ctx.ir.root.id || (!!pageH && !!rootBox && rootBox.height > pageH * 0.55);
|
||||
const localText = textContent(parent, 1200);
|
||||
const parentText = rootTooBroad ? localText : textContent(rootInfo.root, 1600);
|
||||
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE));
|
||||
// Lightbox libraries (fancybox, photoswipe, lightGallery, …) mark a thumbnail
|
||||
// grid as a gallery even when no gallery-ish class names are present.
|
||||
const catalogLightbox = subtreeCatalogMatch(ctx, parent, (d) => d.kind === "lightbox");
|
||||
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE)) || !!catalogLightbox;
|
||||
const testimonialContext = subtreeAttrOrSourceMatches(parent, TESTIMONIAL_SOURCE) || TESTIMONIAL_COPY.test(localText);
|
||||
const featureItems = stats.filter(isLikelyFeatureItem);
|
||||
const cardItems = stats.filter(isLikelyCardItem);
|
||||
@@ -598,6 +640,7 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const signals = [
|
||||
`${cardLikeItems.length} repeated ${testimonialContext ? "testimonial/gallery" : "media/gallery"} items`,
|
||||
galleryContext ? "source attributes/classes identify a gallery or carousel track" : "",
|
||||
catalogLightbox ? `pattern catalog identifies a ${catalogLightbox.id} lightbox gallery` : "",
|
||||
testimonialContext ? "source text/classes identify a testimonial or horizontal story strip" : "",
|
||||
oneRowRegimes >= 2 ? "items remain in a horizontal gallery row across sampled widths" : "",
|
||||
mediaRatio >= 0.6 ? "most gallery items include media" : "",
|
||||
@@ -619,9 +662,13 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const productItems = stats.filter(isLikelyProductItem);
|
||||
const forbiddenProductContext = subtreeAttrOrSourceMatches(parent, /\b(?:footer|directory|nav|menu)\b/i)
|
||||
|| (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, /\b(?:footer|directory|nav|menu)\b/i));
|
||||
// Page-level catalog prior: a Shopify/WooCommerce page makes repeated card
|
||||
// grids product grids even when per-node class names carry no commerce words.
|
||||
const catalogCommerce = ctx.pageFlags.has("ecommerce") || ctx.pageFlags.has("platform_shopify");
|
||||
const productContext = !forbiddenProductContext &&
|
||||
(PRODUCT_COPY.test(localText) || subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE)
|
||||
|| (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))));
|
||||
|| (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE)))
|
||||
|| catalogCommerce);
|
||||
const productStats = productItems.length >= 2 ? productItems : (productContext ? cardLikeItems : []);
|
||||
if (productStats.length >= 2 && productStats.length / kids.length >= 0.45 && productContext) {
|
||||
const key = itemSetKey("product-grid", productStats);
|
||||
@@ -635,6 +682,7 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
productCopyRatio >= 0.5 ? "most items contain commerce/product copy" : "",
|
||||
ctaRatio >= 0.5 ? "most products include link/button affordances" : "",
|
||||
(subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? "source context is product/promo/commerce-like" : "",
|
||||
catalogCommerce ? "pattern catalog identifies an e-commerce platform page" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.58
|
||||
+ Math.min(0.14, productStats.length * 0.025)
|
||||
@@ -642,7 +690,8 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
+ (layout ? 0.10 : 0)
|
||||
+ (productCopyRatio >= 0.5 ? 0.07 : 0)
|
||||
+ (ctaRatio >= 0.5 ? 0.05 : 0)
|
||||
+ ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0);
|
||||
+ ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0)
|
||||
+ (catalogCommerce ? 0.05 : 0);
|
||||
out.push(baseDraft(ctx, "product-grid", parent, productStats, confidence, signals));
|
||||
}
|
||||
continue;
|
||||
@@ -832,8 +881,8 @@ function candidateSort(ctx: RecipeContext, a: RecipeDraft, b: RecipeDraft): numb
|
||||
|| b.confidence - a.confidence;
|
||||
}
|
||||
|
||||
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeReport {
|
||||
const ctx = buildContext(ir, sections, primitives);
|
||||
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeReport {
|
||||
const ctx = buildContext(ir, sections, primitives, patternHints);
|
||||
const drafts = suppressDuplicates(ctx, [
|
||||
...detectLogoClouds(ctx),
|
||||
...detectGrids(ctx),
|
||||
@@ -853,6 +902,7 @@ export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<s
|
||||
highConfidence: candidates.filter((c) => c.confidence >= 0.82).length,
|
||||
byKind,
|
||||
templateReadyKinds: TEMPLATE_READY,
|
||||
...(patternHints ? { catalogPatterns: patternHints.matches.map((m) => m.id) } : {}),
|
||||
},
|
||||
candidates,
|
||||
};
|
||||
@@ -880,6 +930,7 @@ export function recipeReportToMarkdown(report: RecipeReport): string {
|
||||
lines.push("");
|
||||
lines.push(`- Candidates: ${report.summary.totalCandidates}`);
|
||||
lines.push(`- High confidence: ${report.summary.highConfidence}`);
|
||||
if (report.summary.catalogPatterns?.length) lines.push(`- Catalog patterns on page: ${report.summary.catalogPatterns.join(", ")}`);
|
||||
lines.push(`- By kind: ${Object.entries(report.summary.byKind).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`);
|
||||
lines.push("");
|
||||
lines.push("## Candidates");
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Pattern index: O(1) signature lookup over the frozen pattern catalog
|
||||
* (data/pattern-catalog.json). Matches known widget/platform/animation
|
||||
* signatures against the IR's diagnostic evidence (srcClass, tags, attrs)
|
||||
* and produces deterministic PatternHints consumed as a generation artifact
|
||||
* (patterns.json) and as an ADDITIVE evidence source for recipe recognition.
|
||||
*
|
||||
* Determinism contract: the catalog is FROZEN data pinned by sha256
|
||||
* (data/pattern-catalog.lock). Same catalog + same IR => byte-identical hints.
|
||||
* There is no learning layer here — hints are catalog-only by construction.
|
||||
* The pin is UNCONDITIONAL: a catalog/lock mismatch (or a missing lock) always
|
||||
* throws, so pinned evidence can never drift silently.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IR, IRNode, IRChild } from "../normalize/ir.js";
|
||||
|
||||
export type PatternMatchSpec = {
|
||||
/** exact class-token equality (lowercase) */
|
||||
classTokens?: string[];
|
||||
/** class-token prefix, e.g. "elementor-", "wp-block-" */
|
||||
classPrefixes?: string[];
|
||||
/** element tag names, e.g. custom elements like "lottie-player" */
|
||||
tags?: string[];
|
||||
/** attribute presence, e.g. "data-aos" */
|
||||
attrNames?: string[];
|
||||
/** id-attribute prefix, e.g. "shopify-section" */
|
||||
idPrefixes?: string[];
|
||||
};
|
||||
|
||||
export type PatternDef = {
|
||||
id: string;
|
||||
kind: string;
|
||||
flags: string[];
|
||||
match: PatternMatchSpec;
|
||||
};
|
||||
|
||||
export type PatternCatalog = {
|
||||
version: number;
|
||||
description?: string;
|
||||
patterns: PatternDef[];
|
||||
};
|
||||
|
||||
export type PatternMatch = {
|
||||
id: string;
|
||||
kind: string;
|
||||
flags: string[];
|
||||
/** how many IR nodes matched at least one signature of this pattern */
|
||||
count: number;
|
||||
/** first few matching node ids, pre-order (bounded sample for diagnostics) */
|
||||
cids: string[];
|
||||
};
|
||||
|
||||
export type PatternHints = {
|
||||
catalogVersion: number;
|
||||
catalogHash: string;
|
||||
/** matched patterns, sorted by id for determinism */
|
||||
matches: PatternMatch[];
|
||||
/** union of flags across matches, sorted */
|
||||
flags: string[];
|
||||
/** platform_* flags with the prefix stripped, sorted (e.g. ["elementor","wordpress"]) */
|
||||
platforms: string[];
|
||||
/** no deferred-interactive / motion-lib / counter / nav-toggle signatures and a small tree —
|
||||
* callers may skip optional capture/inference stages for such pages */
|
||||
simpleStatic: boolean;
|
||||
};
|
||||
|
||||
const CATALOG_PATH = fileURLToPath(new URL("../../data/pattern-catalog.json", import.meta.url));
|
||||
const LOCK_PATH = fileURLToPath(new URL("../../data/pattern-catalog.lock", import.meta.url));
|
||||
const SAMPLE_CIDS = 5;
|
||||
/** simpleStatic requires the whole tree under this node count (matches "small landing page"). */
|
||||
const SIMPLE_STATIC_MAX_NODES = 1500;
|
||||
const SIMPLE_STATIC_BLOCKERS = ["deferred_interactive", "motion_lib", "counter", "nav_toggle"];
|
||||
|
||||
type CompiledIndex = {
|
||||
catalog: PatternCatalog;
|
||||
hash: string;
|
||||
byClassToken: Map<string, PatternDef[]>;
|
||||
classPrefixes: Array<[string, PatternDef]>;
|
||||
byTag: Map<string, PatternDef[]>;
|
||||
byAttrName: Map<string, PatternDef[]>;
|
||||
idPrefixes: Array<[string, PatternDef]>;
|
||||
};
|
||||
|
||||
let cached: CompiledIndex | null = null;
|
||||
|
||||
function addTo<K>(map: Map<K, PatternDef[]>, key: K, def: PatternDef): void {
|
||||
const list = map.get(key);
|
||||
if (list) list.push(def);
|
||||
else map.set(key, [def]);
|
||||
}
|
||||
|
||||
export function loadPatternIndex(): CompiledIndex {
|
||||
if (cached) return cached;
|
||||
const raw = readFileSync(CATALOG_PATH, "utf8");
|
||||
const hash = createHash("sha256").update(raw).digest("hex");
|
||||
const catalog = JSON.parse(raw) as PatternCatalog;
|
||||
if (!Array.isArray(catalog.patterns)) throw new Error("pattern catalog: missing patterns[]");
|
||||
const idx: CompiledIndex = {
|
||||
catalog,
|
||||
hash,
|
||||
byClassToken: new Map(),
|
||||
classPrefixes: [],
|
||||
byTag: new Map(),
|
||||
byAttrName: new Map(),
|
||||
idPrefixes: [],
|
||||
};
|
||||
for (const p of catalog.patterns) {
|
||||
for (const t of p.match.classTokens ?? []) addTo(idx.byClassToken, t.toLowerCase(), p);
|
||||
for (const pre of p.match.classPrefixes ?? []) idx.classPrefixes.push([pre.toLowerCase(), p]);
|
||||
for (const t of p.match.tags ?? []) addTo(idx.byTag, t.toLowerCase(), p);
|
||||
for (const a of p.match.attrNames ?? []) addTo(idx.byAttrName, a.toLowerCase(), p);
|
||||
for (const pre of p.match.idPrefixes ?? []) idx.idPrefixes.push([pre, p]);
|
||||
}
|
||||
cached = idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Enforce the frozen-catalog pin. UNCONDITIONAL: a missing lock or a hash
|
||||
* mismatch always throws — pinned evidence must never drift silently, so the
|
||||
* generator refuses to run against an unpinned/edited catalog. Regenerate the
|
||||
* lock deliberately after a catalog edit with `--write-lock`. */
|
||||
export function assertPinnedCatalog(): void {
|
||||
const idx = loadPatternIndex();
|
||||
if (!existsSync(LOCK_PATH)) {
|
||||
throw new Error("pattern catalog lock missing (data/pattern-catalog.lock)");
|
||||
}
|
||||
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
|
||||
if (pinned !== idx.hash) {
|
||||
throw new Error(
|
||||
`pattern catalog hash mismatch: pinned ${pinned.slice(0, 12)}… actual ${idx.hash.slice(0, 12)}…`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isElement(c: IRChild): c is IRNode {
|
||||
return (c as IRNode).id !== undefined;
|
||||
}
|
||||
|
||||
/** Match ONE node's own evidence (srcClass tokens, tag, attr names, id prefix)
|
||||
* against the catalog. Shared by the page-level hint scan and per-candidate
|
||||
* recipe scoring. Returns matched defs, deduped, in catalog order. */
|
||||
export function matchCatalogNode(node: Pick<IRNode, "tag" | "attrs" | "srcClass">): PatternDef[] {
|
||||
const idx = loadPatternIndex();
|
||||
const hits = new Set<PatternDef>();
|
||||
if (node.srcClass) {
|
||||
for (const tok of node.srcClass.toLowerCase().split(/\s+/)) {
|
||||
if (!tok) continue;
|
||||
for (const def of idx.byClassToken.get(tok) ?? []) hits.add(def);
|
||||
for (const [pre, def] of idx.classPrefixes) if (tok.startsWith(pre)) hits.add(def);
|
||||
}
|
||||
}
|
||||
for (const def of idx.byTag.get(node.tag.toLowerCase()) ?? []) hits.add(def);
|
||||
for (const attr of Object.keys(node.attrs)) {
|
||||
for (const def of idx.byAttrName.get(attr.toLowerCase()) ?? []) hits.add(def);
|
||||
}
|
||||
const id = node.attrs.id;
|
||||
if (id) for (const [pre, def] of idx.idPrefixes) if (id.startsWith(pre)) hits.add(def);
|
||||
return idx.catalog.patterns.filter((p) => hits.has(p));
|
||||
}
|
||||
|
||||
/** Single deterministic pre-order walk of the IR: tokenize srcClass, check tag /
|
||||
* attr / id signatures, accumulate per-pattern counts + a bounded cid sample.
|
||||
* Asserts the frozen-catalog pin first (throws on mismatch). */
|
||||
export function resolvePatternHints(ir: IR): PatternHints {
|
||||
const idx = loadPatternIndex();
|
||||
assertPinnedCatalog();
|
||||
|
||||
const counts = new Map<string, { def: PatternDef; count: number; cids: string[] }>();
|
||||
const hit = (def: PatternDef, cid: string, seen: Set<string>) => {
|
||||
if (seen.has(def.id)) return; // one hit per node per pattern
|
||||
seen.add(def.id);
|
||||
let e = counts.get(def.id);
|
||||
if (!e) { e = { def, count: 0, cids: [] }; counts.set(def.id, e); }
|
||||
e.count++;
|
||||
if (e.cids.length < SAMPLE_CIDS) e.cids.push(cid);
|
||||
};
|
||||
|
||||
const visit = (node: IRNode) => {
|
||||
const seen = new Set<string>();
|
||||
for (const def of matchCatalogNode(node)) hit(def, node.id, seen);
|
||||
for (const c of node.children) if (isElement(c)) visit(c);
|
||||
};
|
||||
visit(ir.root);
|
||||
|
||||
const matches: PatternMatch[] = [...counts.values()]
|
||||
.map((e) => ({ id: e.def.id, kind: e.def.kind, flags: e.def.flags, count: e.count, cids: e.cids }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const flags = [...new Set(matches.flatMap((m) => m.flags))].sort();
|
||||
const platforms = flags
|
||||
.filter((f) => f.startsWith("platform_"))
|
||||
.map((f) => f.slice("platform_".length))
|
||||
.sort();
|
||||
const simpleStatic =
|
||||
ir.doc.nodeCount < SIMPLE_STATIC_MAX_NODES && !flags.some((f) => SIMPLE_STATIC_BLOCKERS.includes(f));
|
||||
|
||||
return { catalogVersion: idx.catalog.version, catalogHash: idx.hash, matches, flags, platforms, simpleStatic };
|
||||
}
|
||||
|
||||
// `--write-lock` maintenance entry: refresh the pin after a deliberate catalog edit.
|
||||
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href && process.argv.includes("--write-lock")) {
|
||||
const idx = loadPatternIndex();
|
||||
writeFileSync(LOCK_PATH, idx.hash + "\n");
|
||||
console.log("pinned pattern catalog:", idx.hash);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
||||
import { rmSync } from "node:fs";
|
||||
import { writeText, readJSON, fileExists } from "../util/fsx.js";
|
||||
import { generateCss, RESET_CSS } from "../generate/css.js";
|
||||
import { generatePreviewHtml } from "../generate/preview.js";
|
||||
import { generateInteractionCss } from "../generate/interactionCss.js";
|
||||
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js";
|
||||
import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js";
|
||||
@@ -454,6 +455,22 @@ export function generateSiteApp(opts: {
|
||||
const globals = globalsCss(entry, unionFontCss(routes), palette.css);
|
||||
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
|
||||
}
|
||||
// Static preview of the ENTRY page only (the product moment — the first page shown
|
||||
// within seconds of generate, before the Next build + deploy completes). Runtime-free
|
||||
// single HTML file over the entry IR, sharing the same css.ts rule collectors +
|
||||
// propsList/resolveTag decisions as the app. Assets relative to public/.
|
||||
const previewTokensCss = tw
|
||||
? palette.css + "\n" + tokensToCss(entry.tokens, true) + (interner.defs.size ? "\n" + colorDefsCssOf(interner) : "")
|
||||
: palette.css + "\n" + tokensToCss(entry.tokens, true);
|
||||
writeText(join(appDir, "preview.html"), generatePreviewHtml({
|
||||
ir: entry.ir,
|
||||
assetMap: buildAssetMap(entry.assetGraph),
|
||||
fontGraph: { entries: entry.fontGraph.entries, css: unionFontCss(routes) },
|
||||
tokensCss: previewTokensCss,
|
||||
sourceUrl: entry.ir.doc.sourceUrl,
|
||||
colorVar: palette.varForColor,
|
||||
}));
|
||||
|
||||
emitGeneratedDocs(appDir, {
|
||||
sourceUrl: entry.ir.doc.sourceUrl,
|
||||
routes: seoRoutes,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** Capture-side fast-fail helpers (Item 3).
|
||||
*
|
||||
* Two concerns share this module because both are pure, testable predicates the
|
||||
* capture flow keys off:
|
||||
*
|
||||
* 1. Wall-text detection — the SAME signature set the validator's pollution gate
|
||||
* uses (validate/gates.ts imports WALL_RE from here). Extracted so the two
|
||||
* judgments — capture-side abort and validator-side grade — can never drift.
|
||||
* The pollution gate remains the AUTHORITY on whether a shipped capture is
|
||||
* polluted; this module only lets capture bail early on an obvious wall
|
||||
* instead of burning the full multi-viewport pass on garbage.
|
||||
*
|
||||
* 2. Nav-failure classification — decides whether a navigation/session error is
|
||||
* worth ONE fresh-context retry (transient: the browser/page died, a socket
|
||||
* reset, a nav timeout) versus terminal (a wall, a hard DNS/cert failure that
|
||||
* a retry cannot fix). Pure string classification so it unit-tests without a
|
||||
* browser.
|
||||
*/
|
||||
|
||||
/** Bot/egress/auth-wall text signatures. One regex, shared with the pollution gate
|
||||
* (validate/gates.ts) so capture-abort and validator-grade use identical fingerprints. */
|
||||
export const WALL_RE =
|
||||
/blocked by egress|access denied|access to this page has been denied|are you a (human|robot)|verify you are human|enable javascript to|please enable javascript|checking your browser|just a moment|attention required|request blocked|why have i been blocked|captcha|cf-browser-verification|ddos protection by/i;
|
||||
|
||||
export function isWallText(text: string): boolean {
|
||||
return WALL_RE.test(text);
|
||||
}
|
||||
|
||||
/** Node-count ceiling under which wall text is treated as a genuine interstitial
|
||||
* rather than an incidental mention on a real page. Matches the pollution gate's
|
||||
* `wall && nodeCount < 220` threshold so the two agree on the same boundary. */
|
||||
export const WALL_MAX_NODES = 220;
|
||||
|
||||
export type WallProbe = { text: string; nodes: number };
|
||||
|
||||
/** Capture-side wall verdict: the page is a bot/auth wall worth aborting on iff it
|
||||
* is BOTH small (few nodes) AND carries wall text — identical to the gate's rule.
|
||||
* A large page that merely mentions "captcha" in body copy is NOT a wall. */
|
||||
export function isBotWall(probe: WallProbe | null | undefined): boolean {
|
||||
if (!probe) return false;
|
||||
return probe.nodes < WALL_MAX_NODES && isWallText(probe.text);
|
||||
}
|
||||
|
||||
export type NavFailureClass = "retryable" | "wall" | "terminal";
|
||||
|
||||
/** Classify a navigation/session failure by its error text.
|
||||
*
|
||||
* - "wall" → a bot/auth interstitial; a retry with a fresh context won't help
|
||||
* and the caller should surface it as a pollution-style abort.
|
||||
* - "retryable" → the browser/page/context died, a transient socket/timeout/reset;
|
||||
* ONE fresh-context retry is worthwhile.
|
||||
* - "terminal" → a hard failure a retry cannot change (DNS, cert, refused, unknown).
|
||||
*
|
||||
* Ordering matters: a wall signature wins over a transient one (a wall served over
|
||||
* a reset connection is still a wall). */
|
||||
export function classifyNavFailure(error: unknown): NavFailureClass {
|
||||
const msg = String((error as { message?: string })?.message ?? error ?? "");
|
||||
if (WALL_RE.test(msg)) return "wall";
|
||||
// Session death / crash / transient network — a fresh context can recover these.
|
||||
if (
|
||||
/Target (page, context or browser|closed)|context or browser has been closed|page(?:,)? .*has been closed|browser has been closed|page closed|has crashed|Navigation .*interrupted|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|TIMED_OUT|ABORTED|EMPTY_RESPONSE|NETWORK_CHANGED|SOCKET_NOT_CONNECTED)|Timeout .*exceeded|Navigation timeout/i.test(
|
||||
msg,
|
||||
)
|
||||
) {
|
||||
return "retryable";
|
||||
}
|
||||
return "terminal";
|
||||
}
|
||||
|
||||
/** Whether a classified failure earns the single fresh-context retry. */
|
||||
export function isRetryableNavFailure(error: unknown): boolean {
|
||||
return classifyNavFailure(error) === "retryable";
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** Deterministic capture-environment shim (Stage 0, runs before ANY page script).
|
||||
*
|
||||
* Two entropy sources make a page render differently on each load — Math.random
|
||||
* (shuffled carousels, random ids, A/B jitter) and the wall clock ("posted N
|
||||
* minutes ago", schedule/countdown logic). Seeding the first and pinning the
|
||||
* second makes a capture reproducible: the same site captured under the same
|
||||
* {seed, epoch} paints the same DOM.
|
||||
*
|
||||
* CAUTIONS (all honored below):
|
||||
* - performance.now() is left REAL. Motion capture (marquee/rotator sampling in
|
||||
* capture/motion.ts) measures real velocities and observation windows off it;
|
||||
* pinning it would corrupt those measurements. Only the Date wall clock moves.
|
||||
* - The Date epoch is a PARAMETER, recorded in capture metadata — a recapture
|
||||
* uses a fresh-but-recorded value. Generation determinism only requires a
|
||||
* frozen capture to produce byte-stable output, which is unaffected by which
|
||||
* epoch was chosen (the epoch is frozen into the capture, not re-derived).
|
||||
* - The clock still ADVANCES from the pinned epoch (via the real-clock delta),
|
||||
* so elapsed-time logic (setTimeout-driven reveals, animation timing that
|
||||
* reads Date, lazy-load debounces) behaves — only the absolute origin is
|
||||
* pinned, not the passage of time.
|
||||
* - Date is patched consistently across constructor / Date.now() / getTime() /
|
||||
* valueOf() / Symbol.toPrimitive by shifting the underlying instant for BOTH
|
||||
* the no-arg constructor and Date.now(); every instance method then reads the
|
||||
* shifted instant through the native prototype, so cookie-banner / schedule
|
||||
* gates that call getTime()/valueOf() see the same pinned clock as now().
|
||||
*/
|
||||
|
||||
/** Default fixed epoch: 2026-01-01T00:00:00.000Z. Callers should pass an explicit,
|
||||
* recorded epoch (see captureEpochMs); this is only the fallback. */
|
||||
export const DEFAULT_CAPTURE_EPOCH_MS = 1767225600000;
|
||||
|
||||
/** Default PRNG seed (mulberry32 initial state). A non-zero constant so the first
|
||||
* draw is well-mixed; recorded alongside the epoch for reproducibility. */
|
||||
export const DEFAULT_PRNG_SEED = 0x9e3779b9;
|
||||
|
||||
/** Resolve the epoch a capture run should pin to. Env override lets a recapture
|
||||
* reuse a recorded value; otherwise a fresh timestamp is taken at run start and
|
||||
* recorded in metadata. Determinism of GENERATION does not depend on this value —
|
||||
* only on the capture being frozen once chosen. */
|
||||
export function captureEpochMs(override?: number | string): number {
|
||||
if (typeof override === "number" && Number.isFinite(override)) return override;
|
||||
if (typeof override === "string" && override.trim()) {
|
||||
const parsed = Number(override);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
const asDate = Date.parse(override);
|
||||
if (Number.isFinite(asDate)) return asDate;
|
||||
}
|
||||
return DEFAULT_CAPTURE_EPOCH_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the init-script SOURCE STRING injected via page.addInitScript before any
|
||||
* page script runs. Returned as a raw string (not a function) so tsx/esbuild never
|
||||
* transforms it — the browser receives exactly these bytes.
|
||||
*
|
||||
* Interpolation is numeric-only (Number.isFinite-guarded), so no string escaping /
|
||||
* injection surface exists.
|
||||
*/
|
||||
export function buildDeterministicEnvShim(opts?: { seed?: number; epochMs?: number }): string {
|
||||
const seed = Number.isFinite(opts?.seed) ? Math.trunc(opts!.seed!) : DEFAULT_PRNG_SEED;
|
||||
const epoch = Number.isFinite(opts?.epochMs) ? Math.trunc(opts!.epochMs!) : DEFAULT_CAPTURE_EPOCH_MS;
|
||||
// NOTE: `delta` is computed once at shim-eval time (the earliest possible moment,
|
||||
// before page scripts) so the pinned origin is stable; the real clock then adds
|
||||
// elapsed time on top, keeping relative time truthful.
|
||||
return `(() => {
|
||||
"use strict";
|
||||
// ---- Seeded PRNG (mulberry32): deterministic, well-distributed, cheap ----
|
||||
let __s = ${seed} | 0;
|
||||
const __rand = function random() {
|
||||
__s = (__s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(__s ^ (__s >>> 15), 1 | __s);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
try { Object.defineProperty(Math, "random", { value: __rand, writable: true, configurable: true }); }
|
||||
catch (e) { try { Math.random = __rand; } catch (e2) {} }
|
||||
|
||||
// ---- Pinned-but-advancing wall clock. performance.now() stays REAL. ----
|
||||
const RealDate = Date;
|
||||
const realNow = () => RealDate.now();
|
||||
// Shift so the FIRST observed instant is the pinned epoch; real elapsed time is
|
||||
// then added on top (delta is negative if the machine clock is ahead of epoch).
|
||||
const delta = ${epoch} - realNow();
|
||||
const shiftedNow = () => realNow() + delta;
|
||||
function DittoDate(...args) {
|
||||
// \`new Date()\` with no args => pinned clock; every other form is delegated to
|
||||
// the native constructor unchanged (parsing a string, y/m/d, a millis value).
|
||||
const inst = args.length === 0 ? new RealDate(shiftedNow()) : new RealDate(...args);
|
||||
// Re-tag the prototype so instanceof + inherited getTime/valueOf/toISOString work.
|
||||
Object.setPrototypeOf(inst, DittoDate.prototype);
|
||||
return inst;
|
||||
}
|
||||
// Inherit the full native Date prototype (getTime, valueOf, getFullYear,
|
||||
// toISOString, Symbol.toPrimitive, ...) so every read is consistent with now().
|
||||
DittoDate.prototype = Object.create(RealDate.prototype);
|
||||
DittoDate.prototype.constructor = DittoDate;
|
||||
DittoDate.now = shiftedNow;
|
||||
DittoDate.parse = RealDate.parse;
|
||||
DittoDate.UTC = RealDate.UTC;
|
||||
try { Object.defineProperty(globalThis, "Date", { value: DittoDate, writable: true, configurable: true }); }
|
||||
catch (e) { try { globalThis.Date = DittoDate; } catch (e2) {} }
|
||||
})();`;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { AssetGraph } from "../infer/assets.js";
|
||||
import type { FontGraph } from "../infer/fonts.js";
|
||||
import type { Section } from "../infer/sections.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
import { WALL_RE } from "../util/captureFailure.js";
|
||||
|
||||
export type GateResult = {
|
||||
gate: string;
|
||||
@@ -117,7 +118,8 @@ export function countVisibleInCaptureHiddenInClone(
|
||||
// page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was
|
||||
// never dismissed (the clone then reproduces the modal, so the perceptual gate is
|
||||
// fooled too). This gate flags those captures so a "perfect" score can't hide them.
|
||||
const WALL_RE = /blocked by egress|access denied|access to this page has been denied|are you a (human|robot)|verify you are human|enable javascript to|please enable javascript|checking your browser|just a moment|attention required|request blocked|why have i been blocked|captcha|cf-browser-verification|ddos protection by/i;
|
||||
// WALL_RE lives in util/captureFailure.ts — shared with the capture-side fast-fail so
|
||||
// the abort and the grade can never drift on what counts as a wall.
|
||||
|
||||
export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[]): GateResult {
|
||||
const issues: string[] = [];
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
WALL_RE,
|
||||
isWallText,
|
||||
isBotWall,
|
||||
WALL_MAX_NODES,
|
||||
classifyNavFailure,
|
||||
isRetryableNavFailure,
|
||||
} from "../src/util/captureFailure.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 3: capture-side fast-fail predicates (pure — no browser).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("captureFailure: wall-text detection", () => {
|
||||
it("matches common bot/auth-wall fingerprints", () => {
|
||||
for (const t of [
|
||||
"Just a moment...",
|
||||
"Checking your browser before accessing",
|
||||
"Please enable JavaScript to continue",
|
||||
"Access to this page has been denied",
|
||||
"Are you a human?",
|
||||
"Verify you are human",
|
||||
"Attention Required! | Cloudflare",
|
||||
"Please complete the CAPTCHA",
|
||||
"DDoS protection by Cloudflare",
|
||||
]) {
|
||||
assert.ok(isWallText(t), `expected wall text to match: ${t}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not match ordinary marketing/body copy", () => {
|
||||
for (const t of [
|
||||
"Welcome to our store — shop the new collection",
|
||||
"Our team of humans is here to help you 24/7",
|
||||
"Enable notifications to get the latest deals",
|
||||
"About us · Careers · Contact",
|
||||
]) {
|
||||
assert.equal(isWallText(t), false, `expected NOT to match: ${t}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("WALL_RE is the same instance shared with the pollution gate (single source of truth)", () => {
|
||||
// gates.ts imports WALL_RE from this module; assert the export is a real RegExp
|
||||
// so a refactor that turns it into a function would fail here.
|
||||
assert.ok(WALL_RE instanceof RegExp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureFailure: isBotWall (matches the pollution gate's small+wall rule)", () => {
|
||||
it("flags a small page with wall text", () => {
|
||||
assert.ok(isBotWall({ text: "Just a moment...", nodes: 8 }));
|
||||
assert.ok(isBotWall({ text: "checking your browser", nodes: WALL_MAX_NODES - 1 }));
|
||||
});
|
||||
|
||||
it("does NOT flag a large page that merely mentions a wall phrase", () => {
|
||||
// A real page can say "captcha" in its help docs — node count is the discriminator.
|
||||
assert.equal(isBotWall({ text: "how our captcha works", nodes: WALL_MAX_NODES }), false);
|
||||
assert.equal(isBotWall({ text: "how our captcha works", nodes: 5000 }), false);
|
||||
});
|
||||
|
||||
it("does NOT flag a small page with no wall text", () => {
|
||||
assert.equal(isBotWall({ text: "Home · About · Contact", nodes: 10 }), false);
|
||||
});
|
||||
|
||||
it("is null-safe", () => {
|
||||
assert.equal(isBotWall(null), false);
|
||||
assert.equal(isBotWall(undefined), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureFailure: classifyNavFailure", () => {
|
||||
it("classifies session-death / crash / transient network as retryable", () => {
|
||||
for (const msg of [
|
||||
"Target page, context or browser has been closed",
|
||||
"Target closed",
|
||||
"browser has been closed",
|
||||
"page has crashed",
|
||||
"net::ERR_CONNECTION_RESET at https://example.com",
|
||||
"net::ERR_TIMED_OUT",
|
||||
"Timeout 45000ms exceeded",
|
||||
"Navigation timeout of 30000 ms exceeded",
|
||||
]) {
|
||||
assert.equal(classifyNavFailure(new Error(msg)), "retryable", msg);
|
||||
assert.equal(isRetryableNavFailure(new Error(msg)), true, msg);
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies wall fingerprints as wall (not retryable — a fresh context can't help)", () => {
|
||||
const e = new Error("navigation failed: just a moment... checking your browser");
|
||||
assert.equal(classifyNavFailure(e), "wall");
|
||||
assert.equal(isRetryableNavFailure(e), false);
|
||||
});
|
||||
|
||||
it("classifies hard failures as terminal (not retryable)", () => {
|
||||
for (const msg of [
|
||||
"net::ERR_NAME_NOT_RESOLVED",
|
||||
"net::ERR_CERT_AUTHORITY_INVALID",
|
||||
"net::ERR_CONNECTION_REFUSED",
|
||||
"something totally unexpected",
|
||||
]) {
|
||||
assert.equal(classifyNavFailure(new Error(msg)), "terminal", msg);
|
||||
assert.equal(isRetryableNavFailure(new Error(msg)), false, msg);
|
||||
}
|
||||
});
|
||||
|
||||
it("wall wins over a transient signature (a wall served over a reset is still a wall)", () => {
|
||||
const e = new Error("net::ERR_CONNECTION_RESET — access denied");
|
||||
assert.equal(classifyNavFailure(e), "wall");
|
||||
});
|
||||
|
||||
it("accepts a bare string or non-Error value", () => {
|
||||
assert.equal(classifyNavFailure("Target closed"), "retryable");
|
||||
assert.equal(classifyNavFailure(undefined), "terminal");
|
||||
});
|
||||
});
|
||||
@@ -566,6 +566,78 @@ describe("generateCss wrap-vulnerable single-line text", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// BUG C, icon+text chip variant — the label sits beside a replaced icon (<svg>), so the vulnerable
|
||||
// node is NOT a pure text leaf and the leaf-only guard skipped it. The chip's button ancestor bakes
|
||||
// a quantized px width that can land fractionally below the intrinsic single-line width, wrapping
|
||||
// the label ("Recreate a / screenshot"). The inner [icon, text] row must still earn nowrap: the icon
|
||||
// can't wrap, its constant width is already inside the probe's wMin/wMax, and only the text is at risk.
|
||||
describe("generateCss wrap-vulnerable icon+text chip", () => {
|
||||
// Captured shape: pill button 193.44×32 (px 12 + 1px border per side) → inner row 167.44×20
|
||||
// (icon 15 + gap 8 + text), line-height 20, wMin 94.88 < wMax 167.44 == avail == bbox.width.
|
||||
function chipRow(iconTag: string) {
|
||||
const szAt = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true, wMin: 94.88, wMax: 167.44 });
|
||||
const iconPer = (): XPerVp => ({ cs: { display: "inline-block" }, bbox: { x: 13, y: 8.5, width: 15, height: 15 } });
|
||||
const icon = xNode("n2", iconTag, { 375: iconPer(), 768: iconPer(), 1280: iconPer() });
|
||||
const rowPer = (): XPerVp => ({ cs: { display: "flex", lineHeight: "20px" }, bbox: { x: 13, y: 6, width: 167.44, height: 20 }, sizing: szAt() });
|
||||
const row = xNode("n1", "div", { 375: rowPer(), 768: rowPer(), 1280: rowPer() }, [icon, { text: "Recreate a screenshot" }]);
|
||||
const btnPer = (): XPerVp => ({
|
||||
cs: { display: "flex", cursor: "pointer", paddingLeft: "12px", paddingRight: "12px", borderLeftWidth: "1px", borderRightWidth: "1px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
|
||||
bbox: { x: 0, y: 0, width: 193.44, height: 32 },
|
||||
});
|
||||
const btn = xNode("n0", "button", { 375: btnPer(), 768: btnPer(), 1280: btnPer() }, [row]);
|
||||
return btn;
|
||||
}
|
||||
|
||||
it("emits white-space:nowrap for a single-line [icon, text] row flush against its pill's content edge", () => {
|
||||
const css = generateCss(xIr(chipRow("svg")), new Map());
|
||||
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `icon+text chip row should get nowrap, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("does NOT emit nowrap when the sibling is a non-replaced element (it may carry its own text)", () => {
|
||||
const css = generateCss(xIr(chipRow("span")), new Map());
|
||||
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `non-replaced sibling must keep the leaf-only bail, got: ${baseRule(css, "n1")}`);
|
||||
});
|
||||
});
|
||||
|
||||
// A pill <button> whose captured width is CONSTANT across viewports (the label never changes) looks
|
||||
// authored-fixed to the button-width heuristic — but the sizing probe proved `width:auto` reproduces
|
||||
// the box AND it paints exactly at its own max-content width at every sample: the width is content,
|
||||
// not authored. Baking it invites the emitters' quantization (0.1px rounding / spacing-scale snap) to
|
||||
// land fractionally below the intrinsic single-line width and wrap the label; the width must be
|
||||
// dropped so the clone re-derives it intrinsically at every band.
|
||||
describe("generateCss content-fit button width (probe-proven auto beats the constant-width lock)", () => {
|
||||
function pillBody(sizing?: () => RawSizing) {
|
||||
const per = (): XPerVp => ({
|
||||
cs: { display: "flex", cursor: "pointer", width: "144.188px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
|
||||
bbox: { x: 0, y: 0, width: 144.19, height: 32 },
|
||||
...(sizing ? { sizing: sizing() } : {}),
|
||||
});
|
||||
const btn = xNode("n1", "button", { 375: per(), 768: per(), 1280: per() }, [{ text: "Clone LinkedIn" }]);
|
||||
return xNode("n0", "body", {
|
||||
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 32 } },
|
||||
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 32 } },
|
||||
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 32 } },
|
||||
}, [btn]);
|
||||
}
|
||||
|
||||
it("drops the width when the probe proves auto AND the button paints at its max-content everywhere", () => {
|
||||
const sz = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
|
||||
const css = generateCss(xIr(pillBody(sz)), new Map());
|
||||
assert.ok(!allRulesX(css, "n1").includes("width:144"), `content-fit pill width must be dropped, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("still bakes the width without probe evidence (no sizing data)", () => {
|
||||
const css = generateCss(xIr(pillBody()), new Map());
|
||||
assert.ok(allRulesX(css, "n1").includes("width:144"), `unproven button width must stay locked, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
|
||||
it("still bakes the width when the probe proves it load-bearing (wAuto:false)", () => {
|
||||
const sz = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
|
||||
const css = generateCss(xIr(pillBody(sz)), new Map());
|
||||
assert.ok(allRulesX(css, "n1").includes("width:144"), `load-bearing button width must stay locked, got: ${allRulesX(css, "n1")}`);
|
||||
});
|
||||
});
|
||||
|
||||
// Cross-band transform identity — a node with a NON-identity transform at one band must emit the
|
||||
// explicit identity `transform:none` at the bands where the source is untransformed, so the
|
||||
// transform can't cascade across bands and freeze at a width the source left untransformed.
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import {
|
||||
buildDeterministicEnvShim,
|
||||
captureEpochMs,
|
||||
DEFAULT_CAPTURE_EPOCH_MS,
|
||||
DEFAULT_PRNG_SEED,
|
||||
} from "../src/util/envShim.js";
|
||||
|
||||
const ESBUILD_SHIM =
|
||||
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
|
||||
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 1: deterministic-env shim — pure epoch resolution + in-browser behavior.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("envShim: captureEpochMs resolution", () => {
|
||||
it("defaults to the fixed epoch when no override is given", () => {
|
||||
assert.equal(captureEpochMs(), DEFAULT_CAPTURE_EPOCH_MS);
|
||||
assert.equal(captureEpochMs(undefined), DEFAULT_CAPTURE_EPOCH_MS);
|
||||
});
|
||||
|
||||
it("passes a finite numeric override through unchanged", () => {
|
||||
assert.equal(captureEpochMs(1234567890), 1234567890);
|
||||
});
|
||||
|
||||
it("parses a numeric string and an ISO date string", () => {
|
||||
assert.equal(captureEpochMs("1234567890"), 1234567890);
|
||||
assert.equal(captureEpochMs("2020-01-01T00:00:00Z"), Date.parse("2020-01-01T00:00:00Z"));
|
||||
});
|
||||
|
||||
it("falls back to the default for garbage input", () => {
|
||||
assert.equal(captureEpochMs("not-a-date"), DEFAULT_CAPTURE_EPOCH_MS);
|
||||
assert.equal(captureEpochMs(NaN), DEFAULT_CAPTURE_EPOCH_MS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("envShim: buildDeterministicEnvShim source safety", () => {
|
||||
it("interpolates only numeric literals (no injection surface)", () => {
|
||||
const src = buildDeterministicEnvShim({ seed: 42, epochMs: 1000 });
|
||||
assert.match(src, /let __s = 42 \| 0;/);
|
||||
assert.match(src, /const delta = 1000 - realNow\(\);/);
|
||||
// performance.now must remain untouched — the shim only patches Math.random + Date,
|
||||
// never assigns to performance.now (motion.ts velocity sampling depends on the real one).
|
||||
assert.equal(/performance\s*\.\s*now\s*=/.test(src), false);
|
||||
assert.equal(/defineProperty\s*\(\s*performance/.test(src), false);
|
||||
});
|
||||
|
||||
it("truncates non-integer seed/epoch and uses defaults for non-finite", () => {
|
||||
const src = buildDeterministicEnvShim({ seed: 3.9, epochMs: 5.9 });
|
||||
assert.match(src, /let __s = 3 \| 0;/);
|
||||
assert.match(src, /const delta = 5 - realNow\(\);/);
|
||||
const dflt = buildDeterministicEnvShim({ seed: NaN, epochMs: Infinity });
|
||||
assert.match(dflt, new RegExp(`let __s = ${DEFAULT_PRNG_SEED} \\| 0;`));
|
||||
assert.match(dflt, new RegExp(`const delta = ${DEFAULT_CAPTURE_EPOCH_MS} - realNow\\(\\);`));
|
||||
});
|
||||
});
|
||||
|
||||
const VW = 1280, VH = 800;
|
||||
|
||||
describe("envShim: in-browser behavior", () => {
|
||||
let browser: Browser;
|
||||
before(async () => { browser = await chromium.launch(); });
|
||||
after(async () => { await browser.close(); });
|
||||
|
||||
// Fresh page with the shim installed BEFORE any page script (via addInitScript),
|
||||
// exactly as capture.ts wires it. We navigate to a data: URL rather than setContent
|
||||
// so the document is CREATED via navigation — addInitScript fires on document
|
||||
// creation, which setContent (a content swap on the existing about:blank) skips.
|
||||
// The inline <script> then runs after the init scripts and samples the pinned env.
|
||||
const loadWithShim = async (opts: { seed?: number; epochMs?: number }, bodyScript: string): Promise<Page> => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewportSize({ width: VW, height: VH });
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.addInitScript(buildDeterministicEnvShim(opts));
|
||||
const html = `<!doctype html><html><body><script>${bodyScript}</script></body></html>`;
|
||||
await page.goto("data:text/html," + encodeURIComponent(html));
|
||||
return page;
|
||||
};
|
||||
|
||||
it("seeds Math.random reproducibly across two independent loads (same seed → same sequence)", async () => {
|
||||
const script = "window.__draws = Array.from({length: 10}, () => Math.random());";
|
||||
const p1 = await loadWithShim({ seed: 12345, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
|
||||
const p2 = await loadWithShim({ seed: 12345, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
|
||||
const [a, b] = await Promise.all([
|
||||
p1.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
|
||||
p2.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
|
||||
]);
|
||||
assert.deepEqual(a, b, "two loads with the same seed produce identical Math.random sequences");
|
||||
assert.ok(a.every((n) => n >= 0 && n < 1), "draws are in [0,1)");
|
||||
// Not a constant stream — a broken PRNG returning the same value would also be
|
||||
// "stable"; assert genuine variation so the test can't pass trivially.
|
||||
assert.ok(new Set(a).size > 1, "the sequence varies");
|
||||
await p1.close(); await p2.close();
|
||||
});
|
||||
|
||||
it("a different seed yields a different sequence", async () => {
|
||||
const script = "window.__draws = Array.from({length: 10}, () => Math.random());";
|
||||
const p1 = await loadWithShim({ seed: 1, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
|
||||
const p2 = await loadWithShim({ seed: 2, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
|
||||
const [a, b] = await Promise.all([
|
||||
p1.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
|
||||
p2.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
|
||||
]);
|
||||
assert.notDeepEqual(a, b);
|
||||
await p1.close(); await p2.close();
|
||||
});
|
||||
|
||||
it("pins the Date epoch consistently across constructor / now() / getTime() / valueOf() / toISOString()", async () => {
|
||||
const epoch = Date.parse("2021-06-15T12:00:00Z");
|
||||
const page = await loadWithShim(
|
||||
{ seed: 1, epochMs: epoch },
|
||||
`window.__t = {
|
||||
now: Date.now(),
|
||||
ctorGetTime: new Date().getTime(),
|
||||
ctorValueOf: new Date().valueOf(),
|
||||
iso: new Date().toISOString(),
|
||||
};`,
|
||||
);
|
||||
const t = await page.evaluate(() => (window as unknown as { __t: { now: number; ctorGetTime: number; ctorValueOf: number; iso: string } }).__t);
|
||||
// All reads land at the pinned epoch (within a few ms of real elapsed time between them).
|
||||
assert.ok(Math.abs(t.now - epoch) < 5000, `Date.now near epoch: ${t.now} vs ${epoch}`);
|
||||
assert.ok(Math.abs(t.ctorGetTime - epoch) < 5000, "new Date().getTime() near epoch");
|
||||
assert.ok(Math.abs(t.ctorValueOf - epoch) < 5000, "new Date().valueOf() near epoch");
|
||||
assert.match(t.iso, /^2021-06-15T12:00:0/, `toISOString pinned: ${t.iso}`);
|
||||
// now() and getTime() must agree (both read the same shifted instant).
|
||||
assert.ok(Math.abs(t.now - t.ctorGetTime) < 5000, "now() and getTime() agree");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("keeps the clock ADVANCING from the pinned epoch (timers still behave)", async () => {
|
||||
const epoch = DEFAULT_CAPTURE_EPOCH_MS;
|
||||
const page = await loadWithShim(
|
||||
{ seed: 1, epochMs: epoch },
|
||||
`window.__adv = new Promise((res) => {
|
||||
const t0 = Date.now();
|
||||
setTimeout(() => res({ t0, t1: Date.now() }), 60);
|
||||
});`,
|
||||
);
|
||||
const { t0, t1 } = await page.evaluate(() => (window as unknown as { __adv: Promise<{ t0: number; t1: number }> }).__adv);
|
||||
assert.ok(t0 >= epoch - 5000 && t0 <= epoch + 5000, "start is pinned near epoch");
|
||||
assert.ok(t1 > t0, "time advances across a real setTimeout delay");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("leaves performance.now() REAL (motion sampling depends on it) and advancing", async () => {
|
||||
const page = await loadWithShim(
|
||||
{ seed: 1, epochMs: DEFAULT_CAPTURE_EPOCH_MS },
|
||||
`window.__perf = new Promise((res) => {
|
||||
const p0 = performance.now();
|
||||
setTimeout(() => res({ p0, p1: performance.now() }), 40);
|
||||
});`,
|
||||
);
|
||||
const { p0, p1 } = await page.evaluate(() => (window as unknown as { __perf: Promise<{ p0: number; p1: number }> }).__perf);
|
||||
// performance.now() is a real monotonic clock from navigation start — small, not the epoch.
|
||||
assert.ok(p0 >= 0 && p0 < 60_000, `performance.now() is real (not pinned to epoch): ${p0}`);
|
||||
assert.ok(p1 > p0, "performance.now() advances");
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { discoverLazyAssetsInPage } from "../src/capture/capture.js";
|
||||
|
||||
const ESBUILD_SHIM =
|
||||
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
|
||||
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
|
||||
|
||||
const VW = 1280, VH = 800;
|
||||
const BASE = "https://example.com/page/";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Item 2: extended lazy-asset discovery sweep (in-page discovery function).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("lazySweep: discoverLazyAssetsInPage", () => {
|
||||
let browser: Browser;
|
||||
before(async () => { browser = await chromium.launch(); });
|
||||
after(async () => { await browser.close(); });
|
||||
|
||||
const discover = async (bodyHtml: string): Promise<string[]> => {
|
||||
const page: Page = await browser.newPage();
|
||||
await page.setViewportSize({ width: VW, height: VH });
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
// A <base> gives relative refs a stable absolute form to assert against.
|
||||
await page.setContent(`<!doctype html><html><head><base href="${BASE}"></head><body>${bodyHtml}</body></html>`);
|
||||
await page.evaluate(ESBUILD_SHIM);
|
||||
const refs = await page.evaluate(discoverLazyAssetsInPage);
|
||||
await page.close();
|
||||
return refs;
|
||||
};
|
||||
|
||||
it("discovers img/source src + srcset inside <noscript> fallback markup", async () => {
|
||||
const refs = await discover(`
|
||||
<noscript>
|
||||
<img src="hero.jpg" srcset="hero-2x.jpg 2x, hero-3x.jpg 3x">
|
||||
<picture><source srcset="alt.webp 1x, alt-2x.webp 2x"></picture>
|
||||
</noscript>
|
||||
`);
|
||||
assert.ok(refs.includes(BASE + "hero.jpg"), "noscript img src");
|
||||
assert.ok(refs.includes(BASE + "hero-2x.jpg"), "first srcset variant from noscript img");
|
||||
assert.ok(refs.includes(BASE + "alt.webp"), "first srcset variant from noscript source");
|
||||
});
|
||||
|
||||
it("discovers url(...) values from an element's inline style attribute", async () => {
|
||||
const refs = await discover(`
|
||||
<div style="background-image: url('bg.png'); color: red"></div>
|
||||
<span style='background: url("sprite.svg") no-repeat'></span>
|
||||
`);
|
||||
assert.ok(refs.includes(BASE + "bg.png"), "inline background-image url()");
|
||||
assert.ok(refs.includes(BASE + "sprite.svg"), "inline background shorthand url()");
|
||||
});
|
||||
|
||||
it("discovers data-background / data-background-image (raw URL and url(...) forms)", async () => {
|
||||
const refs = await discover(`
|
||||
<div data-background="lazybg.jpg"></div>
|
||||
<div data-background-image="url('lazybg2.png')"></div>
|
||||
`);
|
||||
assert.ok(refs.includes(BASE + "lazybg.jpg"), "raw data-background URL");
|
||||
assert.ok(refs.includes(BASE + "lazybg2.png"), "url()-wrapped data-background-image");
|
||||
});
|
||||
|
||||
it("discovers the resolved src of an img[loading=lazy]", async () => {
|
||||
const refs = await discover(`<img loading="lazy" src="belowfold.jpg">`);
|
||||
assert.ok(refs.includes(BASE + "belowfold.jpg"));
|
||||
});
|
||||
|
||||
it("drops data: URIs and dedupes + sorts", async () => {
|
||||
const refs = await discover(`
|
||||
<div style="background: url('dup.png')"></div>
|
||||
<div data-background="dup.png"></div>
|
||||
<img loading="lazy" src="data:image/gif;base64,R0lGOD">
|
||||
`);
|
||||
assert.equal(refs.filter((u) => u.endsWith("dup.png")).length, 1, "deduped across channels");
|
||||
assert.equal(refs.some((u) => u.startsWith("data:")), false, "data: URIs dropped");
|
||||
assert.deepEqual(refs, [...refs].sort(), "sorted");
|
||||
});
|
||||
|
||||
it("returns nothing for a page with no lazy references", async () => {
|
||||
const refs = await discover(`<div><p>hello</p><img src="eager.jpg"></div>`);
|
||||
// The eager <img src> is the walker's job, not this sweep — the sweep only harvests
|
||||
// the channels the walker misses.
|
||||
assert.deepEqual(refs, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
loadPatternIndex,
|
||||
assertPinnedCatalog,
|
||||
resolvePatternHints,
|
||||
matchCatalogNode,
|
||||
} from "../src/knowledge/patternIndex.js";
|
||||
import { planForFrameUrl } from "../src/capture/graft.js";
|
||||
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
|
||||
|
||||
const CW = 1280;
|
||||
const LOCK_PATH = fileURLToPath(new URL("../data/pattern-catalog.lock", import.meta.url));
|
||||
|
||||
/** Element node at the canonical viewport (ids assigned in pre-order by `page`). */
|
||||
function el(
|
||||
tag: string,
|
||||
opts: { srcClass?: string; attrs?: Record<string, string>; children?: IRChild[] } = {},
|
||||
): IRNode {
|
||||
return {
|
||||
id: "",
|
||||
tag,
|
||||
attrs: opts.attrs ?? {},
|
||||
srcClass: opts.srcClass,
|
||||
visibleByVp: { [CW]: true },
|
||||
bboxByVp: { [CW]: { x: 0, y: 0, width: CW, height: 100 } },
|
||||
computedByVp: { [CW]: { display: "block" } },
|
||||
children: opts.children ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
|
||||
function page(children: IRNode[]): IR {
|
||||
const root = el("body", { children });
|
||||
let i = 0;
|
||||
const assign = (n: IRNode): void => {
|
||||
n.id = `n${i++}`;
|
||||
for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode);
|
||||
};
|
||||
assign(root);
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
|
||||
perViewport: { [CW]: { scrollHeight: 100, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
|
||||
nodeCount: i, keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pattern catalog: load + pin", () => {
|
||||
it("loads the frozen catalog and pins by sha256 (lock verifies)", () => {
|
||||
const idx = loadPatternIndex();
|
||||
assert.equal(idx.catalog.version, 1);
|
||||
assert.ok(idx.catalog.patterns.length > 0, "catalog has patterns");
|
||||
// The on-disk lock must equal the catalog's live hash — no throw.
|
||||
assert.doesNotThrow(() => assertPinnedCatalog());
|
||||
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
|
||||
assert.equal(pinned, idx.hash, "lock matches catalog hash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern catalog: integrity check throws unconditionally", () => {
|
||||
const original = readFileSync(LOCK_PATH, "utf8");
|
||||
afterEach(() => {
|
||||
// Always restore the real lock so later tests / runs see the pinned hash.
|
||||
writeFileSync(LOCK_PATH, original);
|
||||
});
|
||||
|
||||
it("throws on a lock/catalog hash mismatch (no env-var escape hatch)", () => {
|
||||
writeFileSync(LOCK_PATH, "deadbeef".repeat(8) + "\n");
|
||||
assert.throws(() => assertPinnedCatalog(), /hash mismatch/);
|
||||
// Restoring must make it pass again.
|
||||
writeFileSync(LOCK_PATH, original);
|
||||
assert.doesNotThrow(() => assertPinnedCatalog());
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern catalog: fingerprint fixtures hit", () => {
|
||||
it("matchCatalogNode identifies a generic carousel fingerprint by class token", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "swiper swiper-wrapper" });
|
||||
assert.deepEqual(hits.map((h) => h.id), ["carousel_swiper"]);
|
||||
assert.equal(hits[0].kind, "carousel");
|
||||
});
|
||||
|
||||
it("matchCatalogNode identifies a platform fingerprint by class prefix", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "elementor-widget-container" });
|
||||
assert.ok(hits.some((h) => h.id === "platform_elementor"), "elementor prefix hit");
|
||||
});
|
||||
|
||||
it("matchCatalogNode identifies a scroll-animation fingerprint by attribute presence", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: { "data-aos": "fade-up" }, srcClass: undefined });
|
||||
assert.ok(hits.some((h) => h.id === "anim_aos"), "data-aos attr hit");
|
||||
});
|
||||
|
||||
it("resolvePatternHints walks the IR deterministically and records id + cid evidence", () => {
|
||||
const ir = page([
|
||||
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
|
||||
el("div", { srcClass: "swiper-slide" }),
|
||||
]);
|
||||
const hints = resolvePatternHints(ir);
|
||||
const swiper = hints.matches.find((m) => m.id === "carousel_swiper");
|
||||
assert.ok(swiper, "swiper detected");
|
||||
// Three nodes carried a swiper token → count 3, cids are pre-order node ids.
|
||||
assert.equal(swiper!.count, 3);
|
||||
assert.deepEqual(swiper!.cids, ["n1", "n2", "n3"]);
|
||||
// matches are sorted by id → deterministic evidence ordering.
|
||||
const ids = hints.matches.map((m) => m.id);
|
||||
assert.deepEqual(ids, [...ids].sort((a, b) => a.localeCompare(b)));
|
||||
// Same catalog + same IR ⇒ byte-identical hints.
|
||||
assert.deepEqual(resolvePatternHints(page([
|
||||
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
|
||||
el("div", { srcClass: "swiper-slide" }),
|
||||
])), hints);
|
||||
});
|
||||
|
||||
it("resolvePatternHints exposes platform flags (stripped) and simpleStatic", () => {
|
||||
const ir = page([el("section", { srcClass: "shopify-section" })]);
|
||||
const hints = resolvePatternHints(ir);
|
||||
assert.ok(hints.platforms.includes("shopify"), "shopify platform surfaced");
|
||||
assert.ok(hints.flags.includes("platform_shopify"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("popup-vendor skip list: FRAME_SKIP semantics preserved (single source of truth)", () => {
|
||||
// The catalog's consent/chat entries are DOM class/id fingerprints; the capture
|
||||
// pipeline's popup-vendor skip list (graft.ts FRAME_SKIP_RE) matches iframe host
|
||||
// URLs. They are orthogonal axes — the catalog carries no URL hosts, so there is
|
||||
// nothing to consolidate; capture's list stays the single source of truth for
|
||||
// frame-graft decisions. Assert its semantics are unchanged.
|
||||
it("skips promo/consent creative iframe hosts", () => {
|
||||
assert.equal(planForFrameUrl("https://widget.privy.com/assets/popup.html"), "skip");
|
||||
assert.equal(planForFrameUrl("https://app.recart.com/creative"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html"), "skip");
|
||||
});
|
||||
|
||||
it("still grafts inline form embeds and same-origin blank frames", () => {
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/form"), "graft");
|
||||
assert.equal(planForFrameUrl("about:blank"), "graft");
|
||||
assert.equal(planForFrameUrl(""), "graft");
|
||||
});
|
||||
|
||||
it("still renders media players as stills", () => {
|
||||
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc"), "still");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import type { FontGraph } from "../src/infer/fonts.js";
|
||||
import { generatePreviewHtml } from "../src/generate/preview.js";
|
||||
import { generateCss, collectNodeRules, assembleCss, computeBands } from "../src/generate/css.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], attrs: Record<string, string> = {}): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { ...cs };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/preview",
|
||||
title: "Preview Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: CANONICAL,
|
||||
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||
nodeCount: 4,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
// A @font-face block whose src points at a root-absolute cloned asset path — exactly the
|
||||
// shape buildFontGraph emits. The preview must relativize the /assets/ prefix.
|
||||
const FONT_GRAPH: FontGraph = {
|
||||
entries: [],
|
||||
css: `@font-face {\n font-family: "Demo";\n src: url("/assets/cloned/fonts/abc123.woff2") format("woff2");\n}`,
|
||||
};
|
||||
|
||||
/** Build a small page IR: body → heading (with own styles) + <img> (asset) + text. */
|
||||
function fixtureIr(): IR {
|
||||
const heading = node("n1", "h1", computed({ color: "rgb(10, 20, 30)", fontSize: "40px" }), [{ text: "Hello preview" }]);
|
||||
const img = node("n2", "img", computed(), [], { src: "https://example.test/logo.png", alt: "logo" });
|
||||
const root = node("n0", "body", computed(), [heading, img]);
|
||||
return irWith(root);
|
||||
}
|
||||
|
||||
/** Asset map keyed as buildAssetMap keys it (source URL + pathname) → root-absolute local path. */
|
||||
function assetMap(): Map<string, string> {
|
||||
return new Map([
|
||||
["https://example.test/logo.png", "/assets/cloned/images/logo.png"],
|
||||
["/logo.png", "/assets/cloned/images/logo.png"],
|
||||
]);
|
||||
}
|
||||
|
||||
function emit(ir: IR, am: Map<string, string>): string {
|
||||
return generatePreviewHtml({ ir, assetMap: am, fontGraph: FONT_GRAPH, tokensCss: ":root { --x: 1; }", sourceUrl: ir.doc.sourceUrl });
|
||||
}
|
||||
|
||||
describe("preview.html static artifact", () => {
|
||||
it("emits a self-contained HTML document with an inline <style> and no runtime scripts", () => {
|
||||
const html = emit(fixtureIr(), assetMap());
|
||||
assert.ok(html.startsWith("<!doctype html>"), "starts with doctype");
|
||||
assert.ok(/<style>[\s\S]*<\/style>/.test(html), "carries an inline <style> block");
|
||||
assert.ok(/<title>Preview Fixture<\/title>/.test(html), "carries the doc title");
|
||||
// Static preview: NO Ditto runtime scripts, no <script> at all.
|
||||
assert.ok(!/<script/i.test(html), "emits no <script> tags (runtime-free)");
|
||||
assert.ok(!/DittoWire|DittoLottie|DittoMotion/.test(html), "no Ditto runtime references");
|
||||
// The tree renders as real HTML with the per-node c<id> class + data-cid.
|
||||
assert.ok(/<body class="cn0" data-cid="n0">/.test(html), "body carries its c<id> class + cid");
|
||||
assert.ok(/<h1 class="cn1" data-cid="n1">/.test(html), "heading rendered as <h1> with class");
|
||||
assert.ok(/Hello preview/.test(html), "text content rendered");
|
||||
});
|
||||
|
||||
it("references assets with RELATIVE paths (public/assets/...), never root-absolute /assets", () => {
|
||||
const html = emit(fixtureIr(), assetMap());
|
||||
// The <img> src and the @font-face url() both resolve relative to public/.
|
||||
assert.ok(/src="public\/assets\/cloned\/images\/logo\.png"/.test(html), "img src relativized to public/assets");
|
||||
assert.ok(/url\("public\/assets\/cloned\/fonts\/abc123\.woff2"\)/.test(html), "font url relativized to public/assets");
|
||||
// No leading-slash /assets refs survive (they'd break when opened at the app-dir root).
|
||||
assert.ok(!/["(]\/assets\//.test(html), "no root-absolute /assets references remain");
|
||||
});
|
||||
|
||||
it("is byte-stable across repeated emission from the same IR (determinism)", () => {
|
||||
const a = emit(fixtureIr(), assetMap());
|
||||
const b = emit(fixtureIr(), assetMap());
|
||||
assert.equal(a, b, "two emissions of the same IR are byte-identical");
|
||||
});
|
||||
|
||||
it("SHARES the css.ts rule collector — the inline CSS is the collectNodeRules/assembleCss output, not a re-derived path", () => {
|
||||
const ir = fixtureIr();
|
||||
const am = assetMap();
|
||||
const html = emit(ir, am);
|
||||
|
||||
// Independently produce the per-node CSS the same way generateCss does (both go through
|
||||
// collectNodeRules → assembleCss with `.c<id>` selectors). If the preview shared the
|
||||
// collector, this exact block must appear verbatim inside its <style>.
|
||||
const rules = collectNodeRules(ir, am);
|
||||
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
|
||||
const sharedNodeCss = assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, "");
|
||||
|
||||
const styleBody = /<style>([\s\S]*)<\/style>/.exec(html)![1]!;
|
||||
assert.ok(styleBody.includes(sharedNodeCss.trim()), "preview inline CSS contains the exact collectNodeRules/assembleCss output");
|
||||
|
||||
// Concretely: the heading's own base rule (color + font-size from collectNodeRules) is present
|
||||
// exactly once — proving it's the shared collector's rule, not a duplicated re-emission.
|
||||
const headingRule = /\.cn1\s*\{[^}]*\}/.exec(sharedNodeCss)![0];
|
||||
assert.ok(headingRule.includes("color:rgb(10, 20, 30)") && headingRule.includes("font-size:40px"), "shared rule carries the node's decls");
|
||||
const occurrences = styleBody.split(headingRule).length - 1;
|
||||
assert.equal(occurrences, 1, "the shared per-node rule appears exactly once (no duplicated emission path)");
|
||||
});
|
||||
|
||||
it("mirrors generateCss's `.c<id>` selectors so the preview and the css-mode app resolve identical rules", () => {
|
||||
const ir = fixtureIr();
|
||||
const am = assetMap();
|
||||
// The legacy per-node css-mode emitter (generateCss) is the ground truth for `.c<id>` rules.
|
||||
const appCss = generateCss(ir, am);
|
||||
const styleBody = /<style>([\s\S]*)<\/style>/.exec(emit(ir, am))![1]!;
|
||||
// Every `.c<id>{…}` base rule generateCss emits must be present verbatim in the preview.
|
||||
for (const m of appCss.matchAll(/\.cn\d+\s*\{[^}]*\}/g)) {
|
||||
assert.ok(styleBody.includes(m[0]), `preview carries app rule ${m[0].slice(0, 40)}…`);
|
||||
}
|
||||
});
|
||||
});
|
||||
+9
-1
@@ -16,7 +16,15 @@ Postgres and S3-compatible storage. The recommended OSS-friendly stack:
|
||||
```bash
|
||||
DATABASE_URL='postgres://...neon.../ditto_site?sslmode=require' npm run db:migrate
|
||||
```
|
||||
Run this on every deploy that changes `packages/db/migrations`.
|
||||
Run this on every deploy that changes `packages/db/migrations`. Drizzle tracks
|
||||
applied migrations in the database, so re-running is a no-op for anything
|
||||
already applied. (Alternatively, apply a single migration by piping its SQL
|
||||
through `psql`/the Neon CLI — but prefer `db:migrate`, which also records the
|
||||
journal entry so future runs stay consistent.)
|
||||
|
||||
Current migrations: `0000` core tables, `0001` signup tokens,
|
||||
`0002` `job_events` (structured clone-progress events behind
|
||||
`GET /v1/clones/:id/events`).
|
||||
|
||||
## 2. Blob storage (R2 / S3)
|
||||
|
||||
|
||||
+72
-5
@@ -24,12 +24,35 @@ packages/
|
||||
|
||||
Two run modes, same HTTP surface:
|
||||
|
||||
- **In-memory (no `DATABASE_URL`)** — the API runs single-page clones **inline** and
|
||||
holds results in memory. Handy for a quick local demo.
|
||||
- **In-memory (no `DATABASE_URL`)** — `POST` enqueues (202) and runs the clone in the
|
||||
background within the API process, holding results in memory. Handy for a quick
|
||||
local demo; same poll-to-completion contract as production.
|
||||
- **DB + queue (`DATABASE_URL` set)** — `POST` enqueues a job (202); a separate
|
||||
**worker** process consumes the queue, runs the clone, stores artifacts, and the
|
||||
client polls to completion. This is the production mode.
|
||||
|
||||
In both modes the job emits structured progress events as it runs — the compiler's
|
||||
granular log stream (`{ event: "captured", ... }`, `{ event: "generated", ... }`,
|
||||
`{ event: "build_start", ... }`, etc.) plus service phases (`{ event: "clone_done" }`,
|
||||
`{ event: "clone_error", error }`). Every event carries a millisecond wall-clock
|
||||
timestamp `t`; the rest of each object is the payload the emitter logged, so field sets
|
||||
vary by event. There is no fixed enum of event types — treat unknown `event` strings as
|
||||
opaque progress and drive UI off the coarse job `status` from `GET /v1/clones/:id`.
|
||||
|
||||
Poll `GET /v1/clones/:id/events?after=N` → `{ jobId, events: [{ t, ... }] }`. `after` is a
|
||||
**sequence cursor** (a 1-based event count, not a timestamp): pass the number of events
|
||||
you have already consumed to get only newer ones. Caveats:
|
||||
|
||||
- The returned event bodies do **not** include the `seq` value, so track the cursor
|
||||
yourself as a running count of events received.
|
||||
- The in-memory backend **ignores `after`** and returns the full list on every poll — so
|
||||
in that mode you always get all events and must de-duplicate by count. The DB backend
|
||||
honors `after` (`seq > N`).
|
||||
|
||||
In production (DB) mode events persist in the `job_events` table (migration
|
||||
`0002_productive_wallflower.sql`) with a per-job `seq`, so they survive worker restarts
|
||||
and remain readable after completion.
|
||||
|
||||
## Local development
|
||||
|
||||
```bash
|
||||
@@ -84,8 +107,10 @@ POST /v1/signup/request { email } → 202 {message} (s
|
||||
POST /v1/signup/verify { token } → 201 {apiKey,message} (consume email token)
|
||||
GET /v1/clones → list (metadata)
|
||||
GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings)
|
||||
GET /v1/clones/:id/events?after=N → { jobId, events } — progress events after cursor N (poll while running)
|
||||
GET /v1/clones/:id/result → the eager CloneResult (text files inline; binaries by URL)
|
||||
GET /v1/clones/:id/files/* → stream one file
|
||||
GET /v1/clones/:id/files/* → stream one generated file (e.g. .../files/preview.html — see Preview)
|
||||
GET /v1/clones/:id/app-preview/* → serve the clone's built static export when present (404 until built)
|
||||
GET /v1/clones/:id/bundle?format=tgz|zip → the whole app as one archive (302 → S3 when configured)
|
||||
DELETE /v1/clones/:id → purge artifacts
|
||||
GET /healthz → { ok: true } (unauthenticated)
|
||||
@@ -106,8 +131,10 @@ one-time email link, and `POST /v1/signup/verify` consumes the token, stores
|
||||
only the API key's SHA-256 hash in Postgres, stores the verified email in the
|
||||
key label for attribution, and returns the raw key once.
|
||||
|
||||
Normal product `options` are `{ mode?: "single" | "multi", styling?: "tailwind" | "css", framework?: "next" | "vite" }`.
|
||||
`mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. Operational options
|
||||
Normal product `options` are `{ mode?: "single" | "multi", styling?: "tailwind" | "css", framework?: "next" | "vite", preview?: boolean }`.
|
||||
`mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. `preview`
|
||||
controls the browsable built export served at `/v1/clones/:id/app-preview/` (see **Preview** below); it is on by
|
||||
default for single-page clones, so pass `preview:true` to force it for a multi-page clone. Operational options
|
||||
remain `{ verify?, asyncVerify?, maxRoutes?, maxCollection?, captureConcurrency?, validationConcurrency?, viewportConcurrency?, noCache? }`; `noCache` is service-level and
|
||||
bypasses the cache. Deprecated aliases (`multiPage`, `humanizeMode`) and dev-only escape
|
||||
hatches are still accepted for compatibility, but are not part of the normal product surface.
|
||||
@@ -122,6 +149,46 @@ attaches the verify report afterward while the worker still has the run artifact
|
||||
current async QA path; a post-hoc verify endpoint would require persisting full capture artifacts,
|
||||
not just the generated app bundle.
|
||||
|
||||
## Preview
|
||||
|
||||
Every clone emits a flat, self-contained **`preview.html`** at generate time — it is a
|
||||
regular entry in the result file map (path `preview.html`, at the app root), and the
|
||||
manifest records it as `preview_html: "preview.html"` (always present). It exists as soon
|
||||
as the compiler's `generate` step finishes emitting the file map — well before any Next
|
||||
build or deploy — so a consumer can show the cloned page within seconds. Its arrival is
|
||||
visible in the event stream as a `{ event: "generated", ... }` event.
|
||||
|
||||
Fetch it through the ordinary files route:
|
||||
|
||||
```bash
|
||||
curl -sS -H "authorization: Bearer $DITTO_API_KEY" \
|
||||
"$DITTO_API_URL/v1/clones/$JOB_ID/files/preview.html" -o preview.html
|
||||
```
|
||||
|
||||
**What it is — and isn't.** `preview.html` is a single HTML file with inline CSS and **no
|
||||
JavaScript** (no runtime scripts). It renders the captured page statically: animations are
|
||||
frozen at their captured start frame, Lottie/`<video>` mounts show their captured
|
||||
poster/first-frame still, and nothing is interactive — no menus, no hover, no scroll
|
||||
effects. It is a faithful *picture* of the page, not a working app. For interaction, use the
|
||||
built export (`/v1/clones/:id/app-preview/`) or the deployed app.
|
||||
|
||||
**Relative assets.** `preview.html` references its images and fonts with relative paths
|
||||
(`public/assets/cloned/...`), so those sub-paths resolve against the same files route the
|
||||
HTML itself came from (`/v1/clones/:id/files/public/assets/cloned/...`). Serve or proxy the
|
||||
file under that mount and the assets load with no rewriting.
|
||||
|
||||
**Iframe auth caveat.** Because the files route is authenticated (when `API_KEYS`/DB keys
|
||||
are set), you cannot drop `.../files/preview.html` straight into an `<iframe src=...>` — the
|
||||
browser can't attach your `Authorization` header to the iframe's asset sub-requests, so
|
||||
images and fonts 404. Front it with a small server-side proxy that injects the key and
|
||||
re-serves both the HTML and its `public/assets/...` sub-paths from your own origin, then
|
||||
point the iframe at the proxy.
|
||||
|
||||
**Intended staging.** The preview is the middle rung of a three-step reveal for a consumer
|
||||
UI: show the **original site** first (instant), swap to **`preview.html`** the moment
|
||||
`generate` finishes (seconds — a static, frozen likeness), then swap to the **deployed app**
|
||||
once the build + deploy completes (fully interactive).
|
||||
|
||||
**Incremental clone (single → multi, for speed).** Clone one URL single-page first
|
||||
(fast app back), then POST the **same URL** with `{ mode: "multi" }` — the second call
|
||||
reuses the first's entry capture (no re-capture of page 1), crawls + captures only the
|
||||
|
||||
Generated
+400
-274
@@ -26,8 +26,8 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pixelmatch": "7.2.0",
|
||||
"playwright": "1.61.1",
|
||||
"pixelmatch": "5.3.0",
|
||||
"playwright": "1.56.1",
|
||||
"pngjs": "7.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -41,16 +41,107 @@
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1000.12",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.12.tgz",
|
||||
"integrity": "sha512-RgNDWfhNRIlNEzePIRrYTNi/6q+wwRMMapojn8YVzw4ZcJRa/gxVMtUbeZARR1gmopuv6oIhMbY7J66qIQ0ynw==",
|
||||
"node_modules/@aws-crypto/crc32": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz",
|
||||
"integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/crc32c": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz",
|
||||
"integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/sha1-browser": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz",
|
||||
"integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/supports-web-crypto": "^5.2.0",
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"@aws-sdk/util-locate-window": "^3.0.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/sha256-browser": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz",
|
||||
"integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "^5.2.0",
|
||||
"@aws-crypto/supports-web-crypto": "^5.2.0",
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"@aws-sdk/util-locate-window": "^3.0.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/sha256-js": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz",
|
||||
"integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/util": "^5.2.0",
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/supports-web-crypto": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz",
|
||||
"integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-crypto/util": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz",
|
||||
"integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.222.0",
|
||||
"@smithy/util-utf8": "^2.0.0",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/checksums": {
|
||||
"version": "3.1000.8",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.8.tgz",
|
||||
"integrity": "sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-crypto/crc32": "5.2.0",
|
||||
"@aws-crypto/crc32c": "5.2.0",
|
||||
"@aws-crypto/util": "5.2.0",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -58,21 +149,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/client-s3": {
|
||||
"version": "3.1079.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1079.0.tgz",
|
||||
"integrity": "sha512-di9U/7Po7qlVYb2dq58ULsbBAE1pBIk53rux+50LQCvH1X+/l1Ys+BIk/QLBtdaK1nADk0xRNEBbA1QWVnMccw==",
|
||||
"version": "3.1074.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1074.0.tgz",
|
||||
"integrity": "sha512-NxC62ifhtaDDdJjZ8f8Y49DCOpviGGSwzdE4KO/DA1vui4dJP4CAq+64HQkdAjFw64i5cVZb5mF6off+pZX/pg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1000.12",
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.62",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.58",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-crypto/sha1-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/credential-provider-node": "^3.972.58",
|
||||
"@aws-sdk/middleware-flexible-checksums": "^3.974.33",
|
||||
"@aws-sdk/middleware-sdk-s3": "^3.972.54",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/fetch-http-handler": "^5.4.6",
|
||||
"@smithy/node-http-handler": "^4.7.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -80,17 +174,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/core": {
|
||||
"version": "3.974.27",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.27.tgz",
|
||||
"integrity": "sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==",
|
||||
"version": "3.974.23",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz",
|
||||
"integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@aws-sdk/xml-builder": "^3.972.33",
|
||||
"@aws/lambda-invoke-store": "^0.3.0",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@aws-sdk/xml-builder": "^3.972.31",
|
||||
"@aws/lambda-invoke-store": "^0.2.2",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/signature-v4": "^5.4.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"bowser": "^2.11.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
@@ -99,15 +193,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-env": {
|
||||
"version": "3.972.53",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.53.tgz",
|
||||
"integrity": "sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w==",
|
||||
"version": "3.972.49",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz",
|
||||
"integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -115,17 +209,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-http": {
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.55.tgz",
|
||||
"integrity": "sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g==",
|
||||
"version": "3.972.51",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz",
|
||||
"integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/fetch-http-handler": "^5.4.6",
|
||||
"@smithy/node-http-handler": "^4.7.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -133,23 +227,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-ini": {
|
||||
"version": "3.972.60",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.60.tgz",
|
||||
"integrity": "sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ==",
|
||||
"version": "3.972.56",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz",
|
||||
"integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.53",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.59",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.53",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.59",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.59",
|
||||
"@aws-sdk/nested-clients": "^3.997.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.51",
|
||||
"@aws-sdk/credential-provider-login": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.55",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/credential-provider-imds": "^4.3.7",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -157,16 +251,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-login": {
|
||||
"version": "3.972.59",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.59.tgz",
|
||||
"integrity": "sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw==",
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz",
|
||||
"integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/nested-clients": "^3.997.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -174,21 +268,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-node": {
|
||||
"version": "3.972.62",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.62.tgz",
|
||||
"integrity": "sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw==",
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz",
|
||||
"integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-provider-env": "^3.972.53",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-ini": "^3.972.60",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.53",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.59",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.59",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/credential-provider-imds": "^4.4.5",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/credential-provider-env": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-http": "^3.972.51",
|
||||
"@aws-sdk/credential-provider-ini": "^3.972.56",
|
||||
"@aws-sdk/credential-provider-process": "^3.972.49",
|
||||
"@aws-sdk/credential-provider-sso": "^3.972.55",
|
||||
"@aws-sdk/credential-provider-web-identity": "^3.972.55",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/credential-provider-imds": "^4.3.7",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -196,15 +290,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-process": {
|
||||
"version": "3.972.53",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.53.tgz",
|
||||
"integrity": "sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw==",
|
||||
"version": "3.972.49",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz",
|
||||
"integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -212,17 +306,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-sso": {
|
||||
"version": "3.972.59",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.59.tgz",
|
||||
"integrity": "sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA==",
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz",
|
||||
"integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/nested-clients": "^3.997.27",
|
||||
"@aws-sdk/token-providers": "3.1079.0",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/token-providers": "3.1074.0",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -230,16 +324,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/credential-provider-web-identity": {
|
||||
"version": "3.972.59",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.59.tgz",
|
||||
"integrity": "sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w==",
|
||||
"version": "3.972.55",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz",
|
||||
"integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/nested-clients": "^3.997.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-flexible-checksums": {
|
||||
"version": "3.974.33",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.33.tgz",
|
||||
"integrity": "sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/checksums": "^3.1000.8",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -247,16 +354,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/middleware-sdk-s3": {
|
||||
"version": "3.972.58",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.58.tgz",
|
||||
"integrity": "sha512-6uaWRRYJGhOqc9EoTSbLDf9nI/doSAb5vAwGshs5/Hlv5Ce25b246lBkbRd/77fLAi+uMI1a70mJzVyLyCEufQ==",
|
||||
"version": "3.972.54",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.54.tgz",
|
||||
"integrity": "sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -264,18 +371,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/nested-clients": {
|
||||
"version": "3.997.27",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.27.tgz",
|
||||
"integrity": "sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ==",
|
||||
"version": "3.997.23",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz",
|
||||
"integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/fetch-http-handler": "^5.6.2",
|
||||
"@smithy/node-http-handler": "^4.9.2",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-crypto/sha256-browser": "5.2.0",
|
||||
"@aws-crypto/sha256-js": "5.2.0",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/fetch-http-handler": "^5.4.6",
|
||||
"@smithy/node-http-handler": "^4.7.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -283,16 +392,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/s3-request-presigner": {
|
||||
"version": "3.1079.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1079.0.tgz",
|
||||
"integrity": "sha512-NfHUaND7WyLUPkO7HCF3MFg4bdscY34A4tm4dPWa31qYzhGNZarRPr/CcRgllxzPoOSD/EHfZ4fQtZnMl2xWFg==",
|
||||
"version": "3.1074.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1074.0.tgz",
|
||||
"integrity": "sha512-cCHgOUZmuJ8h7WhAb5cRfDTENjENkee/XQLr01VQ0ZGPoUnBuUTDfdmXqQGe2sETtSCtALDnwrp7q7i1RhNMaw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.38",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/signature-v4-multi-region": "^3.996.35",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -300,14 +409,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/signature-v4-multi-region": {
|
||||
"version": "3.996.38",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.38.tgz",
|
||||
"integrity": "sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==",
|
||||
"version": "3.996.35",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz",
|
||||
"integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/signature-v4": "^5.6.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/signature-v4": "^5.4.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -315,16 +424,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/token-providers": {
|
||||
"version": "3.1079.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1079.0.tgz",
|
||||
"integrity": "sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A==",
|
||||
"version": "3.1074.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz",
|
||||
"integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/core": "^3.974.27",
|
||||
"@aws-sdk/nested-clients": "^3.997.27",
|
||||
"@aws-sdk/types": "^3.973.15",
|
||||
"@smithy/core": "^3.29.0",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-sdk/core": "^3.974.23",
|
||||
"@aws-sdk/nested-clients": "^3.997.23",
|
||||
"@aws-sdk/types": "^3.973.13",
|
||||
"@smithy/core": "^3.24.6",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -332,12 +441,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/types": {
|
||||
"version": "3.973.15",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.15.tgz",
|
||||
"integrity": "sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==",
|
||||
"version": "3.973.13",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz",
|
||||
"integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/util-locate-window": {
|
||||
"version": "3.965.8",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz",
|
||||
"integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -345,12 +466,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.33",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.33.tgz",
|
||||
"integrity": "sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==",
|
||||
"version": "3.972.31",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz",
|
||||
"integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@smithy/types": "^4.14.3",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -358,9 +479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws/lambda-invoke-store": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
|
||||
"integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
|
||||
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -1328,12 +1449,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/core": {
|
||||
"version": "3.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.1.tgz",
|
||||
"integrity": "sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==",
|
||||
"version": "3.26.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz",
|
||||
"integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@aws-crypto/crc32": "5.2.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1341,13 +1463,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/credential-provider-imds": {
|
||||
"version": "4.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz",
|
||||
"integrity": "sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==",
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz",
|
||||
"integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1355,27 +1477,39 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/fetch-http-handler": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz",
|
||||
"integrity": "sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==",
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz",
|
||||
"integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.9.3",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.3.tgz",
|
||||
"integrity": "sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==",
|
||||
"node_modules/@smithy/is-array-buffer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz",
|
||||
"integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/node-http-handler": {
|
||||
"version": "4.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz",
|
||||
"integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1383,13 +1517,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/signature-v4": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.2.tgz",
|
||||
"integrity": "sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==",
|
||||
"version": "5.5.2",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz",
|
||||
"integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/core": "^3.29.1",
|
||||
"@smithy/types": "^4.15.1",
|
||||
"@smithy/core": "^3.26.0",
|
||||
"@smithy/types": "^4.15.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1397,9 +1531,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.1.tgz",
|
||||
"integrity": "sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==",
|
||||
"version": "4.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz",
|
||||
"integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -1408,6 +1542,32 @@
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-buffer-from": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz",
|
||||
"integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/is-array-buffer": "^2.2.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/util-utf8": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz",
|
||||
"integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/util-buffer-from": "^2.2.0",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.10.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
|
||||
@@ -1646,15 +1806,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
|
||||
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.7.2"
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
@@ -2717,9 +2877,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.28",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.28.tgz",
|
||||
"integrity": "sha512-YwUvVpSF7m1yOblFPrU3Hbo8XhPheBoiyfGuII6z19LnOr6JpDnyyp7LFNrfV56wS8tpvtBFGRISHN02pDdLOA==",
|
||||
"version": "4.12.26",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz",
|
||||
"integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
@@ -2891,18 +3051,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/non-error": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/non-error/-/non-error-0.1.0.tgz",
|
||||
"integrity": "sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -3001,20 +3149,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pg-boss": {
|
||||
"version": "12.25.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-12.25.1.tgz",
|
||||
"integrity": "sha512-qvhIBBLN1OFw6HUl84S4Goe11fyx1yG7H+p8IMWb0K0vQIp8UlHPbhRjrvpI9ttdGJzAkB/0pZ58eRWzsezv+w==",
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz",
|
||||
"integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^5.6.1",
|
||||
"pg": "^8.22.0",
|
||||
"serialize-error": "^13.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"pg-boss": "dist/cli.js"
|
||||
"cron-parser": "^4.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
@@ -3080,17 +3225,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.2.0.tgz",
|
||||
"integrity": "sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
|
||||
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"pngjs": "^7.0.0"
|
||||
"pngjs": "^6.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pixelmatch": "bin/pixelmatch"
|
||||
}
|
||||
},
|
||||
"node_modules/pixelmatch/node_modules/pngjs": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
|
||||
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
@@ -3101,12 +3255,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
|
||||
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
"playwright-core": "1.56.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -3119,9 +3273,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"version": "1.56.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
|
||||
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
@@ -3312,16 +3466,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "13.0.1",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-13.0.1.tgz",
|
||||
"integrity": "sha512-bBZaRwLH9PN5HbLCjPId4dP5bNGEtumcErgOX952IsvOhVPrm3/AeK1y0UHA/QaPG701eg0yEnOKsCOC6X/kaA==",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"non-error": "^0.1.0",
|
||||
"type-fest": "^5.4.1"
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -3484,18 +3637,6 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/tagged-tag": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz",
|
||||
"integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
@@ -3531,15 +3672,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz",
|
||||
"integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==",
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"dependencies": {
|
||||
"tagged-tag": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -3631,9 +3769,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"version": "3.25.76",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
@@ -3656,10 +3794,10 @@
|
||||
"@cloner/core": "*",
|
||||
"@cloner/db": "*",
|
||||
"@cloner/storage": "*",
|
||||
"@hono/node-server": "^2.0.8",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"hono": "^4.12.28",
|
||||
"zod": "^4.4.3"
|
||||
"hono": "^4.6.14",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloner/test-utils": "*",
|
||||
@@ -3668,18 +3806,6 @@
|
||||
"typescript": "5.7.3"
|
||||
}
|
||||
},
|
||||
"packages/api/node_modules/@hono/node-server": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.8.tgz",
|
||||
"integrity": "sha512-GuCWzLxwg218fy1JaHculFsdcuY12hxit83V+algozTPnwhNjLrRL/Alg9OYjLZLoUZ1rw/S4CdTMsnkSKCmFA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "ditto",
|
||||
"version": "0.1.0",
|
||||
@@ -3712,7 +3838,7 @@
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"pg": "^8.13.1",
|
||||
"pg-boss": "^12.25.1"
|
||||
"pg-boss": "^10.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloner/test-utils": "*",
|
||||
@@ -3728,8 +3854,8 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1079.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1079.0",
|
||||
"@aws-sdk/client-s3": "^3.726.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.726.0",
|
||||
"@cloner/core": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -28,10 +28,10 @@
|
||||
"@cloner/core": "*",
|
||||
"@cloner/db": "*",
|
||||
"@cloner/storage": "*",
|
||||
"@hono/node-server": "^2.0.8",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"hono": "^4.12.28",
|
||||
"zod": "^4.4.3"
|
||||
"hono": "^4.6.14",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloner/test-utils": "*",
|
||||
|
||||
+39
-2
@@ -15,6 +15,7 @@ const OptionsSchema = z
|
||||
mode: z.enum(["single", "multi"]).optional(),
|
||||
styling: z.enum(["tailwind", "css"]).optional(),
|
||||
framework: z.enum(["next", "vite"]).optional(),
|
||||
preview: z.boolean().optional(),
|
||||
verify: z.boolean().optional(),
|
||||
asyncVerify: z.boolean().optional(),
|
||||
maxRoutes: z.number().int().positive().optional(),
|
||||
@@ -238,10 +239,12 @@ export function createApp(deps: AppDeps): Hono {
|
||||
|
||||
try {
|
||||
const out = await backend.submit(url, opts);
|
||||
if (out.status === "queued") return c.json({ jobId: out.jobId, status: "queued" }, 202);
|
||||
if (out.status === "queued") return c.json({ jobId: out.jobId, status: "queued" }, out.httpStatus);
|
||||
return c.json(out.result, 200);
|
||||
} catch (e) {
|
||||
return c.json({ status: "failed", error: String(e).slice(0, 500) }, 500);
|
||||
const msg = String(e);
|
||||
if (msg.startsWith("BUSY:")) return c.json({ error: msg.slice(5).trim() }, 429);
|
||||
return c.json({ status: "failed", error: msg.slice(0, 500) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -282,6 +285,40 @@ export function createApp(deps: AppDeps): Hono {
|
||||
return c.body(file.bytes);
|
||||
});
|
||||
|
||||
// Pipeline progress events (poll every ~300ms while a clone runs).
|
||||
app.get("/v1/clones/:id/events", async (c) => {
|
||||
const after = Math.max(0, Number(c.req.query("after") ?? "0") || 0);
|
||||
const events = backend.events ? await backend.events(c.req.param("id"), after) : null;
|
||||
if (!events) return c.json({ error: "not found" }, 404);
|
||||
return c.json({ jobId: c.req.param("id"), events });
|
||||
});
|
||||
|
||||
// Browsable preview of the built clone (static export published by the preview
|
||||
// build under public/app-preview/). References are relative, so the export works
|
||||
// from this mount — the only requirement is a trailing slash on the root.
|
||||
const previewFile = async (c: Context, sub: string) => {
|
||||
const id = c.req.param("id") ?? "";
|
||||
const tryPaths = sub.includes(".")
|
||||
? [`public/app-preview/${sub}`]
|
||||
: [
|
||||
`public/app-preview/${sub}`.replace(/\/$/, "") + "/index.html",
|
||||
`public/app-preview/${sub}`,
|
||||
`public/app-preview/${sub}.html`,
|
||||
];
|
||||
for (const p of tryPaths) {
|
||||
const file = await backend.file(id, p);
|
||||
if (file) {
|
||||
c.header("content-type", file.contentType);
|
||||
c.header("content-length", String(file.bytes.length));
|
||||
return c.body(file.bytes);
|
||||
}
|
||||
}
|
||||
return c.json({ error: "no app preview for this clone (preview builds are on by default for single-page clones; pass options.preview=true otherwise)" }, 404);
|
||||
};
|
||||
app.get("/v1/clones/:id/app-preview", (c) => c.redirect(`/v1/clones/${c.req.param("id")}/app-preview/`, 302));
|
||||
app.get("/v1/clones/:id/app-preview/", (c) => previewFile(c, "index.html"));
|
||||
app.get("/v1/clones/:id/app-preview/:path{.+}", (c) => previewFile(c, c.req.param("path") ?? ""));
|
||||
|
||||
app.delete("/v1/clones/:id", async (c) => {
|
||||
const ok = await backend.remove(c.req.param("id"));
|
||||
return c.json({ deleted: ok }, ok ? 200 : 404);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { CloneOptions, RouteInfo } from "@cloner/core";
|
||||
import type { RestCloneResult } from "./rest.js";
|
||||
import type { RestCloneResult, RestCloneSummary } from "./rest.js";
|
||||
|
||||
export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cached";
|
||||
|
||||
@@ -22,7 +22,7 @@ export type JobView = {
|
||||
};
|
||||
|
||||
export type SubmitOutcome =
|
||||
| { jobId: string; status: "succeeded" | "cached"; httpStatus: 200; result: RestCloneResult }
|
||||
| { jobId: string; status: "succeeded" | "cached"; httpStatus: 200; result: RestCloneResult | RestCloneSummary }
|
||||
| { jobId: string; status: "queued"; httpStatus: 202 };
|
||||
|
||||
export type ResultOutcome =
|
||||
@@ -56,4 +56,6 @@ export interface Backend {
|
||||
facets(jobId: string): Promise<FileFacet[] | null>;
|
||||
/** The whole app as one compressed archive (null if not ready/found). */
|
||||
bundle(jobId: string, format?: BundleFormat): Promise<CloneBundle | null>;
|
||||
/** Pipeline progress events for polling UIs (null if unsupported or job unknown). */
|
||||
events?(jobId: string, after?: number): Promise<Array<Record<string, unknown>> | null>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,13 @@ export class DbBackend implements Backend {
|
||||
return { jobId: hit.jobId, status: "cached", httpStatus: 200, result: { ...r.result, status: "cached" } };
|
||||
}
|
||||
}
|
||||
// In-flight dedup: an identical clone is already queued/running — attach the
|
||||
// caller to that job instead of enqueueing a duplicate capture. (Best-effort:
|
||||
// no unique constraint, so a same-instant race can still double-submit.)
|
||||
const active = await repo.findActiveJobByCacheKey(this.deps.db, key);
|
||||
if (active) {
|
||||
return { jobId: active.id, status: "queued", httpStatus: 202 };
|
||||
}
|
||||
}
|
||||
|
||||
const job = await repo.createJob(this.deps.db, { kind, url, options: options ?? {}, status: "queued", cacheKey: key });
|
||||
@@ -142,4 +149,10 @@ export class DbBackend implements Backend {
|
||||
const url = this.deps.store.uploadBundle ? await this.deps.store.uploadBundle(jobId, format, bytes) : undefined;
|
||||
return { bytes, sha256: sha256hex(bytes), format, url };
|
||||
}
|
||||
|
||||
async events(jobId: string, after = 0): Promise<Array<Record<string, unknown>> | null> {
|
||||
const job = await repo.getJob(this.deps.db, jobId);
|
||||
if (!job) return null;
|
||||
return repo.listJobEvents(this.deps.db, jobId, after);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,24 +10,51 @@ import type { Backend, BundleFormat, CloneBundle, FileFacet, JobView, ResultOutc
|
||||
|
||||
export type RunJob = (input: RunCloneJobInput) => Promise<CloneJobResult>;
|
||||
|
||||
/** Sync, in-memory backend (M1): runs the clone INLINE on submit and holds results
|
||||
* in memory. POST returns the file map immediately (200). */
|
||||
/** In-memory backend: enqueues a clone (202) and runs it in the background so long
|
||||
* captures (multi-viewport + verify) never hold a single HTTP connection open for
|
||||
* minutes — which browsers surface as `TypeError: Failed to fetch` when the link
|
||||
* drops (server restart, hot-reload, idle timeout). Poll /v1/clones/:id + /events. */
|
||||
export class InMemoryBackend implements Backend {
|
||||
constructor(private deps: { store: InMemoryStore; runJob: RunJob; makeTempBase?: () => string; captureCacheDir?: string }) {}
|
||||
|
||||
private activeClones = 0;
|
||||
|
||||
private makeBase(): string {
|
||||
return (this.deps.makeTempBase ?? (() => mkdtempSync(join(tmpdir(), "api-clone-"))))();
|
||||
}
|
||||
|
||||
async submit(url: string, options: CloneOptions | undefined): Promise<SubmitOutcome> {
|
||||
if (this.activeClones > 0 || this.deps.store.list().some((j) => j.status === "running")) {
|
||||
throw new Error("BUSY: another clone is already running — wait for it to finish (use npm run dev:api:stable to avoid hot-reload killing Playwright)");
|
||||
}
|
||||
const id = randomUUID();
|
||||
const base = this.makeBase();
|
||||
const events: Array<Record<string, unknown>> = [];
|
||||
const kind: "clone" | "clone_site" = resolveCloneMode(options) === "multi" ? "clone_site" : "clone";
|
||||
const rec = { id, status: "running" as const, url, kind, options: options ?? {}, createdAt: Date.now(), base, events };
|
||||
this.deps.store.put(rec);
|
||||
void this.runInBackground(id, url, options, rec, base, events, kind);
|
||||
return { jobId: id, status: "queued", httpStatus: 202 };
|
||||
}
|
||||
|
||||
private async runInBackground(
|
||||
id: string,
|
||||
url: string,
|
||||
options: CloneOptions | undefined,
|
||||
rec: { id: string; status: "running"; url: string; kind: "clone" | "clone_site"; options: CloneOptions; createdAt: number; base: string; events: Array<Record<string, unknown>> },
|
||||
base: string,
|
||||
events: Array<Record<string, unknown>>,
|
||||
kind: "clone" | "clone_site",
|
||||
): Promise<void> {
|
||||
this.activeClones++;
|
||||
const log = (e: Record<string, unknown>) => {
|
||||
events.push({ t: Date.now(), ...e });
|
||||
this.deps.store.put({ ...rec, events: [...events] });
|
||||
};
|
||||
try {
|
||||
// captureCacheDir (persistent, shared across submits) powers the single→multi
|
||||
// reuse: a single-page submit stashes its capture; a later multi-page submit for
|
||||
// the same URL reuses it as the entry route (no re-capture) and expands the site.
|
||||
const result = await this.deps.runJob({ url, options, runsDir: base, captureCacheDir: this.deps.captureCacheDir });
|
||||
this.deps.store.put({ id, status: "succeeded", url, kind: result.kind, options: result.options, createdAt: Date.now(), result, base });
|
||||
const result = await this.deps.runJob({ url, options, runsDir: base, captureCacheDir: this.deps.captureCacheDir, log });
|
||||
log({ event: "clone_done" });
|
||||
this.deps.store.put({ id, status: "succeeded", url, kind: result.kind, options: result.options, createdAt: rec.createdAt, result, base, events });
|
||||
if (result.options.asyncVerify) {
|
||||
void verifyCloneJobResult(result, {
|
||||
validationConcurrency: result.options.validationConcurrency,
|
||||
@@ -39,18 +66,25 @@ export class InMemoryBackend implements Backend {
|
||||
result.verify = { error: String(e).slice(0, 500), async: true };
|
||||
});
|
||||
}
|
||||
return { jobId: id, status: "succeeded", httpStatus: 200, result: buildRestResult(id, result, `/v1/clones/${id}/files`) };
|
||||
} catch (e) {
|
||||
try {
|
||||
rmSync(base, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
this.deps.store.put({ id, status: "failed", url, kind: resolveCloneMode(options) === "multi" ? "clone_site" : "clone", options: options ?? {}, createdAt: Date.now(), error: String(e) });
|
||||
throw e;
|
||||
log({ event: "clone_error", error: String(e).slice(0, 300) });
|
||||
this.deps.store.put({ id, status: "failed", url, kind, options: options ?? {}, createdAt: rec.createdAt, error: String(e), events });
|
||||
} finally {
|
||||
this.activeClones = Math.max(0, this.activeClones - 1);
|
||||
}
|
||||
}
|
||||
|
||||
async events(jobId: string): Promise<Array<Record<string, unknown>> | null> {
|
||||
const rec = this.deps.store.get(jobId);
|
||||
if (!rec) return null;
|
||||
return rec.events ?? [];
|
||||
}
|
||||
|
||||
async status(jobId: string): Promise<JobView | null> {
|
||||
const rec = this.deps.store.get(jobId);
|
||||
if (!rec) return null;
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CloneJobResult, CloneOptions } from "@cloner/core";
|
||||
* it is removed on eviction. M2/M4 replace this with DB rows + S3 objects. */
|
||||
export type JobRecord = {
|
||||
id: string;
|
||||
status: "succeeded" | "failed";
|
||||
status: "running" | "succeeded" | "failed";
|
||||
url: string;
|
||||
kind: "clone" | "clone_site";
|
||||
options: CloneOptions;
|
||||
@@ -14,6 +14,8 @@ export type JobRecord = {
|
||||
result?: CloneJobResult;
|
||||
base?: string;
|
||||
error?: string;
|
||||
/** pipeline progress events (compiler log stream + service phases), poll via /events */
|
||||
events?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** In-memory result store with TTL eviction. Swapped for a DB-backed store in M2. */
|
||||
|
||||
@@ -26,13 +26,25 @@ describe("POST /v1/clones (real clone, served fixture)", { skip: hasChromium() ?
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: server.url + "/components.html", options: { interactions: false, components: true, motion: false } }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.status, "succeeded");
|
||||
assert.ok(body.files["src/app/page.tsx"], "has page.tsx");
|
||||
assert.equal(body.files["src/app/page.tsx"].type, "text");
|
||||
assert.ok(body.files["package.json"], "has package.json");
|
||||
assert.ok(body.capture.nodeCount > 0);
|
||||
assert.equal(body.capture.blocked, false);
|
||||
assert.equal(res.status, 202);
|
||||
const queued = await res.json();
|
||||
let body: Record<string, unknown> | undefined;
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const view = await (await app.request(`/v1/clones/${queued.jobId}`)).json();
|
||||
if (view.status === "succeeded") {
|
||||
body = await (await app.request(`/v1/clones/${queued.jobId}/result`)).json();
|
||||
break;
|
||||
}
|
||||
if (view.status === "failed") assert.fail(view.error);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
assert.ok(body);
|
||||
const files = body!.files as Record<string, { type: string }>;
|
||||
assert.equal(body!.status, "succeeded");
|
||||
assert.ok(files["src/app/page.tsx"], "has page.tsx");
|
||||
assert.equal(files["src/app/page.tsx"].type, "text");
|
||||
assert.ok(files["package.json"], "has package.json");
|
||||
assert.ok((body!.capture as { nodeCount: number }).nodeCount > 0);
|
||||
assert.equal((body!.capture as { blocked: boolean }).blocked, false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,17 @@ const fakeRunJob: RunJob = async (input) => {
|
||||
} satisfies CloneJobResult;
|
||||
};
|
||||
|
||||
test("POST /v1/clones returns the eager file map; binaries by reference; streaming + lifecycle", async () => {
|
||||
async function waitForResult(app: ReturnType<typeof createApp>, jobId: string) {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const view = await (await app.request(`/v1/clones/${jobId}`)).json();
|
||||
if (view.status === "succeeded") return await (await app.request(`/v1/clones/${jobId}/result`)).json();
|
||||
if (view.status === "failed") throw new Error(String(view.error));
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error("timeout waiting for clone");
|
||||
}
|
||||
|
||||
test("POST /v1/clones enqueues then completes; binaries by reference; streaming + lifecycle", async () => {
|
||||
const store = new InMemoryStore(60_000);
|
||||
const app = createApp({ backend: new InMemoryBackend({ store, runJob: fakeRunJob }) });
|
||||
try {
|
||||
@@ -43,39 +53,47 @@ test("POST /v1/clones returns the eager file map; binaries by reference; streami
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com/", options: {} }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(body.jobId);
|
||||
assert.equal(body.status, "succeeded");
|
||||
assert.equal(body.kind, "clone");
|
||||
assert.equal(res.status, 202);
|
||||
const queued = await res.json();
|
||||
assert.ok(queued.jobId);
|
||||
assert.equal(queued.status, "queued");
|
||||
|
||||
let body: Record<string, unknown> | undefined;
|
||||
body = await waitForResult(app, queued.jobId);
|
||||
assert.ok(body);
|
||||
assert.equal(body!.status, "succeeded");
|
||||
assert.equal(body!.kind, "clone");
|
||||
|
||||
// Text inline.
|
||||
const page = body.files["src/app/page.tsx"];
|
||||
assert.equal(page.type, "text");
|
||||
assert.ok(page.content.includes("Page"));
|
||||
assert.ok(page.sha256);
|
||||
const files = body!.files as Record<string, { type: string; content?: string; sha256?: string; url?: string }>;
|
||||
const page = files["src/app/page.tsx"];
|
||||
assert.ok(page);
|
||||
assert.equal(page!.type, "text");
|
||||
assert.ok(page!.content!.includes("Page"));
|
||||
assert.ok(page!.sha256);
|
||||
|
||||
// Binary by reference (URL, not bytes).
|
||||
const bin = body.files["public/assets/cloned/images/a.png"];
|
||||
assert.equal(bin.type, "binary");
|
||||
assert.equal(bin.content, undefined);
|
||||
assert.equal(bin.url, `/v1/clones/${body.jobId}/files/public/assets/cloned/images/a.png`);
|
||||
const bin = files["public/assets/cloned/images/a.png"];
|
||||
assert.ok(bin);
|
||||
assert.equal(bin!.type, "binary");
|
||||
assert.equal(bin!.content, undefined);
|
||||
assert.equal(bin!.url, `/v1/clones/${queued.jobId}/files/public/assets/cloned/images/a.png`);
|
||||
|
||||
// Per-file streaming returns the actual bytes.
|
||||
const fileRes = await app.request(bin.url);
|
||||
const fileRes = await app.request(bin!.url!);
|
||||
assert.equal(fileRes.status, 200);
|
||||
assert.equal(fileRes.headers.get("content-type"), "image/png");
|
||||
const bytes = Buffer.from(await fileRes.arrayBuffer());
|
||||
assert.equal(bytes.length, 7);
|
||||
|
||||
// Cheap metadata overview (no file contents).
|
||||
const meta = await (await app.request(`/v1/clones/${body.jobId}`)).json();
|
||||
const meta = await (await app.request(`/v1/clones/${queued.jobId}`)).json();
|
||||
assert.equal(meta.fileCount, 3);
|
||||
assert.equal(meta.capture.nodeCount, 42);
|
||||
assert.equal(meta.totalBytes > 0, true);
|
||||
|
||||
// Full result fetch.
|
||||
const result = await app.request(`/v1/clones/${body.jobId}/result`);
|
||||
const result = await app.request(`/v1/clones/${queued.jobId}/result`);
|
||||
assert.equal(result.status, 200);
|
||||
|
||||
// List.
|
||||
@@ -83,9 +101,9 @@ test("POST /v1/clones returns the eager file map; binaries by reference; streami
|
||||
assert.equal(list.clones.length, 1);
|
||||
|
||||
// Delete purges; subsequent fetch 404s.
|
||||
const del = await app.request(`/v1/clones/${body.jobId}`, { method: "DELETE" });
|
||||
const del = await app.request(`/v1/clones/${queued.jobId}`, { method: "DELETE" });
|
||||
assert.equal((await del.json()).deleted, true);
|
||||
assert.equal((await app.request(`/v1/clones/${body.jobId}`)).status, 404);
|
||||
assert.equal((await app.request(`/v1/clones/${queued.jobId}`)).status, 404);
|
||||
} finally {
|
||||
store.clear();
|
||||
}
|
||||
@@ -118,8 +136,9 @@ test("POST /v1/clones normalizes product options and legacy aliases", async () =
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com/", options: { mode: "multi", styling: "css", framework: "vite" } }),
|
||||
});
|
||||
assert.equal(product.status, 200);
|
||||
const productBody = await product.json();
|
||||
assert.equal(product.status, 202);
|
||||
const productQueued = await product.json();
|
||||
const productBody = await waitForResult(app, productQueued.jobId);
|
||||
assert.deepEqual(productBody.options, { mode: "multi", styling: "css", framework: "vite" });
|
||||
|
||||
const legacy = await app.request("/v1/clones", {
|
||||
@@ -127,8 +146,9 @@ test("POST /v1/clones normalizes product options and legacy aliases", async () =
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ url: "https://example.com/", options: { multiPage: true, humanizeMode: "css" } }),
|
||||
});
|
||||
assert.equal(legacy.status, 200);
|
||||
const legacyBody = await legacy.json();
|
||||
assert.equal(legacy.status, 202);
|
||||
const legacyQueued = await legacy.json();
|
||||
const legacyBody = await waitForResult(app, legacyQueued.jobId);
|
||||
assert.deepEqual(legacyBody.options, { mode: "multi", styling: "css", framework: "next" });
|
||||
} finally {
|
||||
store.clear();
|
||||
|
||||
@@ -52,12 +52,18 @@ test("MCP: list-then-read + bundle contract (never floods context)", async () =>
|
||||
// clone_website → jobId + status only (no files).
|
||||
const cw = parse(await client.callTool({ name: "clone_website", arguments: { url: "https://example.com/", options: {} } }));
|
||||
assert.ok(cw.jobId);
|
||||
assert.equal(cw.status, "succeeded");
|
||||
assert.ok(!("files" in cw));
|
||||
assert.equal(cw.status, "queued");
|
||||
const jobId = cw.jobId;
|
||||
|
||||
// status
|
||||
const status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
|
||||
let status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
|
||||
for (let i = 0; i < 200 && status.status !== "succeeded"; i++) {
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
|
||||
}
|
||||
assert.equal(status.status, "succeeded");
|
||||
|
||||
// status (re-fetch after completion)
|
||||
status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
|
||||
assert.equal(status.status, "succeeded");
|
||||
|
||||
// get_clone_result → metadata only, NO file contents.
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ResolvedCloneOptions = CloneOptions & {
|
||||
interactions: boolean;
|
||||
components: boolean;
|
||||
motion: boolean;
|
||||
preview: boolean;
|
||||
};
|
||||
|
||||
export function resolveCloneMode(options: CloneOptions = {}): CloneMode {
|
||||
@@ -53,5 +54,8 @@ export function resolveCloneOptions(options: CloneOptions = {}): ResolvedCloneOp
|
||||
interactions: options.interactions ?? true,
|
||||
components: options.components ?? true,
|
||||
motion: options.motion ?? true,
|
||||
// Preview builds are the single-page product surface; multi-page exports can be
|
||||
// large, so previewing a site clone stays an explicit opt-in.
|
||||
preview: options.preview ?? mode === "single",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ export type CloneOptions = {
|
||||
mode?: CloneMode;
|
||||
styling?: CloneStyling;
|
||||
framework?: CloneFramework;
|
||||
/** Build a browsable static export of the clone into `public/app-preview/`
|
||||
* (served by the API's app-preview route). Defaults ON for single-page clones. */
|
||||
preview?: boolean;
|
||||
verify?: boolean;
|
||||
/** DB/worker mode: persist the clone first, then attach verify in the background. */
|
||||
asyncVerify?: boolean;
|
||||
@@ -75,7 +78,7 @@ export type CaptureSanity = {
|
||||
blocked: boolean; // bot/egress wall text detected
|
||||
};
|
||||
|
||||
export type CloneTimings = { captureMs: number; generateMs: number; verifyMs?: number };
|
||||
export type CloneTimings = { captureMs: number; generateMs: number; verifyMs?: number; previewMs?: number };
|
||||
|
||||
export type RouteInfo = { route: string; representativeOf?: string };
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE "job_events" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "job_events" ADD CONSTRAINT "job_events_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "job_events_job_id_seq_idx" ON "job_events" USING btree ("job_id","seq");
|
||||
@@ -0,0 +1,461 @@
|
||||
{
|
||||
"id": "de9b94b8-cefb-4d2a-a848-79a552976943",
|
||||
"prevId": "e076bd1e-a7ce-40ab-abd1-9eb11d00ddb9",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.api_keys": {
|
||||
"name": "api_keys",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"key_hash": {
|
||||
"name": "key_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"rate_limit": {
|
||||
"name": "rate_limit",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"api_keys_key_hash_unique": {
|
||||
"name": "api_keys_key_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"key_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.cache": {
|
||||
"name": "cache",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"cache_key": {
|
||||
"name": "cache_key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"job_id": {
|
||||
"name": "job_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"options_hash": {
|
||||
"name": "options_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"compiler_version": {
|
||||
"name": "compiler_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"cache_job_id_jobs_id_fk": {
|
||||
"name": "cache_job_id_jobs_id_fk",
|
||||
"tableFrom": "cache",
|
||||
"tableTo": "jobs",
|
||||
"columnsFrom": [
|
||||
"job_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.clones": {
|
||||
"name": "clones",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"job_id": {
|
||||
"name": "job_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"route_count": {
|
||||
"name": "route_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"file_manifest": {
|
||||
"name": "file_manifest",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"bundle_s3_key": {
|
||||
"name": "bundle_s3_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"verify": {
|
||||
"name": "verify",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"capture_meta": {
|
||||
"name": "capture_meta",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"clones_job_id_jobs_id_fk": {
|
||||
"name": "clones_job_id_jobs_id_fk",
|
||||
"tableFrom": "clones",
|
||||
"tableTo": "jobs",
|
||||
"columnsFrom": [
|
||||
"job_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.job_events": {
|
||||
"name": "job_events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"job_id": {
|
||||
"name": "job_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seq": {
|
||||
"name": "seq",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"payload": {
|
||||
"name": "payload",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"job_events_job_id_seq_idx": {
|
||||
"name": "job_events_job_id_seq_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "job_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "seq",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"job_events_job_id_jobs_id_fk": {
|
||||
"name": "job_events_job_id_jobs_id_fk",
|
||||
"tableFrom": "job_events",
|
||||
"tableTo": "jobs",
|
||||
"columnsFrom": [
|
||||
"job_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.jobs": {
|
||||
"name": "jobs",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"options": {
|
||||
"name": "options",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'{}'::jsonb"
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'queued'"
|
||||
},
|
||||
"cache_key": {
|
||||
"name": "cache_key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"attempts": {
|
||||
"name": "attempts",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 0
|
||||
},
|
||||
"error": {
|
||||
"name": "error",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"compiler_version": {
|
||||
"name": "compiler_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"timings": {
|
||||
"name": "timings",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"finished_at": {
|
||||
"name": "finished_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.signup_tokens": {
|
||||
"name": "signup_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"signup_tokens_token_hash_unique": {
|
||||
"name": "signup_tokens_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,13 @@
|
||||
"when": 1782790200000,
|
||||
"tag": "0001_signup_tokens",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1783202414928,
|
||||
"tag": "0002_productive_wallflower",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"pg": "^8.13.1",
|
||||
"pg-boss": "^12.25.1"
|
||||
"pg-boss": "^10.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloner/test-utils": "*",
|
||||
|
||||
@@ -31,13 +31,23 @@ export async function enqueueClone(boss: PgBoss, jobId: string): Promise<string
|
||||
}
|
||||
|
||||
/** Register the worker handler. Normalizes pg-boss v9 (single job) vs v10 (array)
|
||||
* callback shapes so the consumer just gets a jobId. */
|
||||
export async function workClone(boss: PgBoss, handler: (jobId: string) => Promise<void>): Promise<string> {
|
||||
return boss.work(CLONE_QUEUE, async (job: unknown) => {
|
||||
const arr = Array.isArray(job) ? job : [job];
|
||||
for (const j of arr) {
|
||||
const data = (j as { data?: ClonePayload }).data;
|
||||
if (data?.jobId) await handler(data.jobId);
|
||||
}
|
||||
});
|
||||
* callback shapes so the consumer just gets a jobId.
|
||||
*
|
||||
* `concurrency` registers that many independent pollers on the queue, so up to
|
||||
* N jobs run at once in this process with no head-of-line blocking (each poller
|
||||
* fetches its next job as soon as its current one finishes). */
|
||||
export async function workClone(boss: PgBoss, handler: (jobId: string) => Promise<void>, concurrency = 1): Promise<string[]> {
|
||||
const pollers: string[] = [];
|
||||
for (let i = 0; i < Math.max(1, concurrency); i++) {
|
||||
pollers.push(
|
||||
await boss.work(CLONE_QUEUE, async (job: unknown) => {
|
||||
const arr = Array.isArray(job) ? job : [job];
|
||||
for (const j of arr) {
|
||||
const data = (j as { data?: ClonePayload }).data;
|
||||
if (data?.jobId) await handler(data.jobId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return pollers;
|
||||
}
|
||||
|
||||
+36
-2
@@ -1,6 +1,6 @@
|
||||
import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "./client.js";
|
||||
import { jobs, clones, cache, apiKeys, signupTokens, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js";
|
||||
import { jobs, clones, cache, apiKeys, signupTokens, jobEvents, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js";
|
||||
|
||||
// ---- jobs ----
|
||||
|
||||
@@ -40,6 +40,19 @@ export async function listJobs(db: Db, limit = 50): Promise<Job[]> {
|
||||
return db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit);
|
||||
}
|
||||
|
||||
/** The newest still-active (queued/running) job for a cache key, if any — the
|
||||
* in-flight dedup lookup: identical submits attach to it instead of enqueueing
|
||||
* a duplicate capture. */
|
||||
export async function findActiveJobByCacheKey(db: Db, key: string): Promise<Job | undefined> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(jobs)
|
||||
.where(and(eq(jobs.cacheKey, key), inArray(jobs.status, ["queued", "running"])))
|
||||
.orderBy(desc(jobs.createdAt))
|
||||
.limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteJob(db: Db, id: string): Promise<boolean> {
|
||||
const rows = await db.delete(jobs).where(eq(jobs.id, id)).returning({ id: jobs.id });
|
||||
return rows.length > 0;
|
||||
@@ -111,3 +124,24 @@ export async function consumeSignupToken(db: Db, tokenHash: string): Promise<Sig
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
// ---- job events ----
|
||||
|
||||
export async function appendJobEvent(db: Db, jobId: string, payload: Record<string, unknown>): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ n: sql<number>`coalesce(max(${jobEvents.seq}), 0)` })
|
||||
.from(jobEvents)
|
||||
.where(eq(jobEvents.jobId, jobId));
|
||||
const seq = (row?.n ?? 0) + 1;
|
||||
await db.insert(jobEvents).values({ jobId, seq, payload: { t: Date.now(), ...payload } });
|
||||
return seq;
|
||||
}
|
||||
|
||||
export async function listJobEvents(db: Db, jobId: string, after = 0): Promise<Array<Record<string, unknown>>> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(jobEvents)
|
||||
.where(and(eq(jobEvents.jobId, jobId), sql`${jobEvents.seq} > ${after}`))
|
||||
.orderBy(jobEvents.seq);
|
||||
return rows.map((r) => r.payload as Record<string, unknown>);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, text, jsonb, integer, timestamp, uuid } from "drizzle-orm/pg-core";
|
||||
import { pgTable, text, jsonb, integer, timestamp, uuid, index } from "drizzle-orm/pg-core";
|
||||
|
||||
/** Job lifecycle row. Status mirrors the queue; `cacheKey` ties it to the cache row.
|
||||
* `options`/`timings` are jsonb (the service's typed shapes). */
|
||||
@@ -66,6 +66,17 @@ export const signupTokens = pgTable("signup_tokens", {
|
||||
consumedAt: timestamp("consumed_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
/** Structured clone progress events (mirrors in-memory backend event stream). */
|
||||
export const jobEvents = pgTable("job_events", {
|
||||
id: uuid("id").defaultRandom().primaryKey(),
|
||||
jobId: uuid("job_id")
|
||||
.notNull()
|
||||
.references(() => jobs.id, { onDelete: "cascade" }),
|
||||
seq: integer("seq").notNull(),
|
||||
payload: jsonb("payload").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => [index("job_events_job_id_seq_idx").on(t.jobId, t.seq)]);
|
||||
|
||||
export type Job = typeof jobs.$inferSelect;
|
||||
export type NewJob = typeof jobs.$inferInsert;
|
||||
export type Clone = typeof clones.$inferSelect;
|
||||
@@ -74,3 +85,4 @@ export type CacheRow = typeof cache.$inferSelect;
|
||||
export type ApiKey = typeof apiKeys.$inferSelect;
|
||||
export type SignupToken = typeof signupTokens.$inferSelect;
|
||||
export type NewSignupToken = typeof signupTokens.$inferInsert;
|
||||
export type JobEvent = typeof jobEvents.$inferSelect;
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloner/core": "*",
|
||||
"@aws-sdk/client-s3": "^3.1079.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1079.0"
|
||||
"@aws-sdk/client-s3": "^3.726.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.726.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "4.22.4",
|
||||
|
||||
@@ -11,11 +11,15 @@ export type WorkerEnv = {
|
||||
/** persistent entry-capture cache dir (the single→multi speed path). "" disables it. */
|
||||
captureCacheDir: string;
|
||||
tier: string;
|
||||
/** concurrent clone jobs in this process (WORKER_CONCURRENCY, default 1). Each
|
||||
* slot runs a full Chromium capture — size the container accordingly. */
|
||||
concurrency: number;
|
||||
};
|
||||
|
||||
export function loadWorkerEnv(): WorkerEnv {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required for the worker");
|
||||
const concurrency = Math.max(1, parseInt(process.env.WORKER_CONCURRENCY ?? "1", 10) || 1);
|
||||
return {
|
||||
databaseUrl,
|
||||
artifactsDir: process.env.ARTIFACTS_DIR ?? join(process.cwd(), "local-data", "artifacts"),
|
||||
@@ -23,5 +27,6 @@ export function loadWorkerEnv(): WorkerEnv {
|
||||
harnessDir: process.env.HARNESS_DIR ?? join(process.cwd(), "local-data", "harness"),
|
||||
captureCacheDir: process.env.CAPTURE_CACHE_DIR ?? join(process.cwd(), "local-data", "capture-cache"),
|
||||
tier: process.env.VERIFY_TIER ?? "stage2",
|
||||
concurrency,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,16 @@ export async function processCloneJob(deps: ProcessDeps, jobId: string): Promise
|
||||
const options = (job.options ?? {}) as CloneOptions;
|
||||
// Provision the isolated harness only when this job actually verifies (build).
|
||||
const harnessDir = (options.verify || options.asyncVerify) && deps.harnessProvider ? await deps.harnessProvider() : undefined;
|
||||
const result = await deps.runJob({ url: job.url, options, runsDir: base, harnessDir, tier: deps.tier, captureCacheDir: deps.captureCacheDir || undefined, captureCacheTtlMs: deps.cacheTtlMs });
|
||||
const result = await deps.runJob({
|
||||
url: job.url,
|
||||
options,
|
||||
runsDir: base,
|
||||
harnessDir,
|
||||
tier: deps.tier,
|
||||
captureCacheDir: deps.captureCacheDir || undefined,
|
||||
captureCacheTtlMs: deps.cacheTtlMs,
|
||||
log: (e) => { void repo.appendJobEvent(db, jobId, e); },
|
||||
});
|
||||
|
||||
const manifest = await store.putClone(jobId, result.files);
|
||||
const envelope = { files: manifest.files, routes: result.routes, bundleKey: manifest.bundleKey };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { join } from "node:path";
|
||||
import { runCloneJob } from "@cloner/core";
|
||||
import { createDb, createBoss, workClone } from "@cloner/db";
|
||||
import { artifactStoreFromEnv } from "@cloner/storage";
|
||||
@@ -16,13 +17,32 @@ async function main(): Promise<void> {
|
||||
store,
|
||||
runJob: runCloneJob,
|
||||
cacheTtlMs: env.cacheStaleAfterMs,
|
||||
harnessProvider: makeHarnessProvider(env.harnessDir),
|
||||
captureCacheDir: env.captureCacheDir,
|
||||
tier: env.tier,
|
||||
};
|
||||
|
||||
await workClone(boss, (jobId) => processCloneJob(deps, jobId));
|
||||
console.log(JSON.stringify({ event: "worker_started", artifactsDir: env.artifactsDir, harnessDir: env.harnessDir, cacheStaleAfterMs: env.cacheStaleAfterMs }));
|
||||
// One harness per concurrency slot: verify builds must not share a harness dir
|
||||
// (concurrent framework builds collide). With concurrency 1 this is the plain
|
||||
// harnessDir, preserving existing layouts. At most `concurrency` jobs run at
|
||||
// once (one per poller), so a free slot always exists at checkout.
|
||||
const harnesses = Array.from({ length: env.concurrency }, (_, i) =>
|
||||
makeHarnessProvider(env.concurrency > 1 ? join(env.harnessDir, `slot-${i}`) : env.harnessDir),
|
||||
);
|
||||
const freeSlots = harnesses.map((_, i) => i);
|
||||
|
||||
await workClone(
|
||||
boss,
|
||||
async (jobId) => {
|
||||
const slot = freeSlots.pop() ?? 0;
|
||||
try {
|
||||
await processCloneJob({ ...deps, harnessProvider: harnesses[slot]! }, jobId);
|
||||
} finally {
|
||||
freeSlots.push(slot);
|
||||
}
|
||||
},
|
||||
env.concurrency,
|
||||
);
|
||||
console.log(JSON.stringify({ event: "worker_started", concurrency: env.concurrency, artifactsDir: env.artifactsDir, harnessDir: env.harnessDir, cacheStaleAfterMs: env.cacheStaleAfterMs }));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
|
||||
Reference in New Issue
Block a user