From 50d359fc0a9a0d92d40f468becd41bdbbbee6720 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 22 Oct 2026 20:57:40 +0000 Subject: [PATCH 1/3] Delay Jam and align cache, CSP, and robots with the public pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jam still loads for every visitor, but only after the first interaction or eight seconds, and its capture iframe is detached on pagehide so it does not keep the document out of the back/forward cache. HTML revalidates instead of using no-store, public images cache for 30 days, CSP names the two Jam hosts the loader uses, and the app shell is noindex while /expocit/sources is indexed. Co-authored-by: Gáborjáni-Szabó Ádám --- docs/operations.md | 14 ++-- e2e/loading-performance.spec.ts | 13 +++- index.html | 4 +- public/theme-init.js | 10 +++ scripts/check-site.mjs | 2 +- scripts/prerender.mjs | 9 ++- scripts/test-service-worker-offline.mjs | 8 +-- src/jam-loader.ts | 67 ++++++++++++++++-- src/lib/seo/pages.ts | 12 +++- src/lib/seo/use-page-head.ts | 2 +- src/pages/SourcesPage.tsx | 2 + src/prerender/entry.tsx | 8 +++ src/test/jam-loader.test.ts | 83 ++++++++++++++++++++++ src/test/vercel-deployment-config.test.ts | 84 ++++++++++++++++++++--- vercel.json | 37 ++++++++-- 15 files changed, 314 insertions(+), 41 deletions(-) create mode 100644 src/test/jam-loader.test.ts diff --git a/docs/operations.md b/docs/operations.md index ae0c82359..f6a834c45 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -42,15 +42,15 @@ Vercel builds and serves the frontend from the Git repository. A push to `main` - automatic Git deployments: `main` only. `git.deploymentEnabled` sets `main` to true and `**` to false. A branch deploys when any matching rule is true, and a branch that matches nothing defaults to true, so the `**` rule has to stay. `*` does not match a slash, which would leave branches such as `cursor/…` deploying. A push that matches no true rule creates no deployment and does not count toward the 100-deployments-per-day cap; - the Vite build command and `dist` output; -- rewrites of the app routes (`/app`, `/login`, `/share`, `/expocit/sources`) to `app-shell.html`; any other path that is not a file gets `404.html` with a real 404 status; +- rewrites of the app routes (`/app`, `/login`, `/share`, `/expocit/sources`) to `app-shell.html`; any other path that is not a file gets `404.html` with a real 404 status. `/expocit/sources` is also prerendered, and the filesystem is served before rewrites, so that file is what visitors and crawlers get. The rewrite remains for a build that did not emit it; - `trailingSlash: false`, which 308-redirects `/app/settings/` to `/app/settings` before routing, since rewrite and redirect sources match exactly; - 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`; - 301 redirects from retired guide slugs to the guide that replaced them, such as `/debate-weighing-guide` to `/how-to-weigh-in-debate`. The `trailingSlash` 308 runs first, so `/debate-weighing-guide/` reaches the same page in two permanent hops and needs no rule of its own; -- no-cache headers for `/`, `index.html`, `app-shell.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`; -- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`; -- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header. +- revalidating cache headers (`public, max-age=0, must-revalidate`, without `no-store`, so a document can enter the back/forward cache) for `/`, `index.html`, `app-shell.html`, and `theme-init.js`. `sw.js` stays `no-store` so a new service worker is not stuck in the HTTP cache. Hashed `/assets/` are immutable. The unhashed images in `public/` (`favicon.ico`, `favicon.png`, `favicon-72.png`, `hero-visual.png`, `placeholder.svg`) are cached for 30 days. Jam's capture iframe is removed on `pagehide` (never via `unload`) so that cross-origin frame cannot veto the cache; +- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`. The app shell's robots meta is `noindex, nofollow`, the same policy. Public pages, including the prerendered `/expocit/sources`, are `index, follow`. A route deletes the platform `Access-Control-Allow-Origin: *` header on HTML responses and leaves it on static assets; +- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Jam is limited to `https://js.jam.dev` and `https://recorder.jam.dev`; `script-src` does not include `unsafe-eval`. Web Analytics and Speed Insights stay on this origin (`/_vercel/insights` and `/_vercel/speed-insights`), which `'self'` already allows. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header. -After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. +After `vite build`, [`scripts/prerender.mjs`](../scripts/prerender.mjs) renders the landing page, `/terms`, `/privacy`, `/expocit/sources`, and the 404 page in Node through [`src/prerender/entry.tsx`](../src/prerender/entry.tsx). It writes them to `dist/index.html`, `dist/terms/index.html`, `dist/privacy/index.html`, `dist/expocit/sources/index.html`, and `dist/404.html`, each with its own title, description, and canonical URL from [`src/lib/seo/pages.ts`](../src/lib/seo/pages.ts); the landing page also carries the Organization, SoftwareApplication (with the trial and Pro offers) and FAQPage JSON-LD. The built shell is kept as `dist/app-shell.html`. On load, the client replaces the prerendered HTML with the same page. A new public SPA page needs an entry in `PAGE_HEADS`, in the prerender entry, and a route kept out of the 404. The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are prerendered the same way, listed in [`src/prerender/tool-pages.tsx`](../src/prerender/tool-pages.tsx) with heads in [`src/lib/seo/tool-pages.ts`](../src/lib/seo/tool-pages.ts). They render their own JSON-LD in the page body, so the dev server and the prerendered HTML carry the same structured data, and their `dateModified` is `TOOL_PAGES_MODIFIED` in that file: bump it with a substantive content change. Their speech times come from [`src/lib/free-tools/formats.ts`](../src/lib/free-tools/formats.ts), which cites a league source per format. @@ -58,7 +58,7 @@ The free tools under `/tools` ([`src/pages/tools/`](../src/pages/tools/)) are pr [`scripts/sitemap.mjs`](../scripts/sitemap.mjs) then writes `dist/sitemap.xml` from every page in `dist/` that names itself as canonical and is not `noindex`. Each `` is the `dateModified` the page declares in its JSON-LD: the guide pages' `CONTENT_DATE` in [`scripts/generate_seo_content.py`](../scripts/generate_seo_content.py), the legal pages' "last updated" dates, the tool pages' `TOOL_PAGES_MODIFIED`, and the landing page's last source commit. A page without a date is listed without ``. -[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when an indexed page other than `/terms` and `/privacy` lacks its question H2 (`#short-answer`) as the first H2, or when an image has no alt text, a button has no name or a form field has no label. +[`scripts/check-site.mjs`](../scripts/check-site.mjs) runs next and fails the build when an internal link, a `#fragment`, a sitemap URL or an `llms.txt` URL does not resolve (it applies the `vercel.json` redirects and rewrites), when JSON-LD does not parse or carries HTML entities, when an indexed page other than `/terms`, `/privacy`, and `/expocit/sources` lacks its question H2 (`#short-answer`) as the first H2, or when an image has no alt text, a button has no name or a form field has no label. The guide pages in `public//index.html` are generated, not hand-edited: run `python3 scripts/generate_seo_content.py` and commit its output. Short pages live in its `PAGES` list; pages with long-form copy, their own highlight-card descriptions, and their own FAQs (which also feed the page's FAQPage JSON-LD) live in [`scripts/seo_guides.py`](../scripts/seo_guides.py). Keep `PAGES` in order, because each page's related-guide links are picked by position. A guide entry can set its own `modified` date, which overrides `CONTENT_DATE` for that page alone. @@ -106,7 +106,7 @@ Admins see a **Developer** card in Settings that signs out, sets the key, and re A build can pin one project with the `VITE_SUPABASE_ENV` build variable (`production` or `staging`; any other non-empty value fails the build). A pinned build ignores the localStorage key, hides the Settings card and the strip's **Switch to Production** button, and does not reload when another tab switches. The strip still shows on a pinned staging build. The staging Vercel project uses this so a requested preview is pinned to staging; see [Opt-in previews](#opt-in-previews). -The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. +The switch only works while the `connect-src` directive in [`vercel.json`](../vercel.json) allows the staging project origin. It lists exactly the two project origins from [`src/lib/supabase/projects.ts`](../src/lib/supabase/projects.ts), and `src/test/vercel-deployment-config.test.ts` fails if they drift apart. The same file is deployed to production and to `debatelab-staging`, and an unpinned production preview can still select staging, so the staging origin stays in the production site's CSP. ## Opt-in previews diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 8a13a2a83..3fba6cb0c 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -4,12 +4,21 @@ import { setLocale, stubSupabaseSession } from './helpers/auth'; test('a stalled Jam recorder cannot delay the real application', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); await page.route('https://js.jam.dev/**', async () => new Promise(() => undefined)); - const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + const jamRequests: string[] = []; + page.on('request', (request) => { + if (request.url().startsWith('https://js.jam.dev/')) jamRequests.push(request.url()); + }); await page.goto('/', { waitUntil: 'domcontentloaded' }); - await jamRequest; + // Jam waits for an interaction, so the landing page is up before any recorder request. await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 }); + expect(jamRequests).toEqual([]); + + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + await jamRequest; + await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible(); await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); }); diff --git a/index.html b/index.html index 36bf83a47..43dd2e7a9 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,9 @@ - + + diff --git a/public/theme-init.js b/public/theme-init.js index e56ec9bb0..2247c956a 100644 --- a/public/theme-init.js +++ b/public/theme-init.js @@ -20,9 +20,19 @@ try { var pathname = window.location.pathname; + var privateRoute = pathname === '/app' || pathname.indexOf('/app/') === 0 + || pathname === '/login' + || pathname === '/share' || pathname.indexOf('/share/') === 0; if (pathname === '/app' || pathname.indexOf('/app/') === 0 || pathname === '/login') { document.documentElement.classList.add('app-shell'); } + // The shell HTML is noindex. Private routes keep that. The sources page is + // public, including when the app-shell rewrite serves this document. + var robots = document.querySelector('meta[name="robots"]'); + if (robots && privateRoute) robots.setAttribute('content', 'noindex, nofollow'); + else if (robots && pathname === '/expocit/sources') { + robots.setAttribute('content', 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'); + } } catch { // Ignore unavailable location or document globals. } diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index 1f1258218..5fc4c9a6a 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -13,7 +13,7 @@ const DIST = 'dist'; const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin; const vercel = JSON.parse(readFileSync('vercel.json', 'utf8')); // Indexed pages that answer no question of their own. -const NO_SHORT_ANSWER = new Set(['/terms', '/privacy']); +const NO_SHORT_ANSWER = new Set(['/terms', '/privacy', '/expocit/sources']); const problems = []; const report = (where, message) => problems.push(`${where}: ${message}`); diff --git a/scripts/prerender.mjs b/scripts/prerender.mjs index 7107b2a73..510ad6f7b 100644 --- a/scripts/prerender.mjs +++ b/scripts/prerender.mjs @@ -74,9 +74,12 @@ function pageHtml(shell, page, assets) { [//, ``, 'twitter:title'], [//, ``, 'twitter:description'], ]) html = replaceOnce(html, pattern, value, label); - if (!head.canonicalPath) { - html = replaceOnce(html, //, '', 'robots'); - } + // The shell document is noindex (app, login, share). A public page puts the + // index directive back; a page without a canonical URL stays noindex. + const robots = head.canonicalPath + ? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1' + : 'noindex, nofollow'; + html = replaceOnce(html, //, ``, 'robots'); const added = []; if (head.canonicalPath) { diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index 561f2700b..8912650d7 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -132,7 +132,7 @@ async function main() { headers: { 'Cache-Control': 'no-store' }, body: V2_WORKER, })); - await upgradePage.goto(origin, { waitUntil: 'networkidle' }); + await upgradePage.goto(origin, { waitUntil: 'load' }); await upgradePage.evaluate(async () => navigator.serviceWorker.ready); await upgradePage.waitForFunction(async () => { const registration = await navigator.serviceWorker.getRegistration(); @@ -153,7 +153,7 @@ async function main() { const activatedPage = await context.newPage(); await prepare(activatedPage); - await activatedPage.goto(origin, { waitUntil: 'networkidle' }); + await activatedPage.goto(origin, { waitUntil: 'load' }); await activatedPage.evaluate(async () => navigator.serviceWorker.ready); await activatedPage.waitForFunction(async () => { const keys = await caches.keys(); @@ -167,7 +167,7 @@ async function main() { const page = await context.newPage(); await prepare(page); - await page.goto(`${origin}/app`, { waitUntil: 'networkidle' }); + await page.goto(`${origin}/app`, { waitUntil: 'load' }); await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); await page.evaluate(async () => navigator.serviceWorker.ready); await page.waitForFunction(async (paths) => { @@ -192,7 +192,7 @@ async function main() { await context.setOffline(false); const prefetchedPage = await context.newPage(); await prepare(prefetchedPage); - await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'networkidle' }); + await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'load' }); await prefetchedPage.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS }); const attackLink = prefetchedPage.locator('main [data-tour="mode-attack"]'); await attackLink.hover(); diff --git a/src/jam-loader.ts b/src/jam-loader.ts index 83781ef21..643cc88e1 100644 --- a/src/jam-loader.ts +++ b/src/jam-loader.ts @@ -4,6 +4,22 @@ const JAM_SCRIPT_URLS = [ ] as const; const JAM_SCRIPT_TIMEOUT_MS = 3_000; +/** + * Jam stays off the critical path. The capture iframe at recorder.jam.dev + * keeps a worker request open, so the network never goes idle once the + * scripts run. Load on the first interaction, and otherwise only after the + * page has had this long to paint. Callers must not wait for `networkidle`. + * + * That iframe is cross-origin. Chrome cannot tell whether it is eligible for + * the back/forward cache, and an `unload` listener inside it would block the + * whole page. `pagehide` removes the frame first (it does not block the cache + * the way `unload` and `beforeunload` do) and `pageshow` puts the same node + * back when the page is restored. + */ +export const JAM_PASSIVE_DELAY_MS = 8_000; + +const JAM_FRAME_SELECTOR = 'iframe[data-jam-capture], iframe[data-jam-recorder], iframe[src*="jam.dev"]'; + interface JamLoaderOptions { documentRef?: Document; mediaRecorderAvailable?: boolean; @@ -49,23 +65,60 @@ export async function loadJamRecorder(options: JamLoaderOptions = {}): Promise appendJamModule(documentRef, source))); } +function detachJamFrames(documentRef: Document): HTMLIFrameElement[] { + const frames = [...documentRef.querySelectorAll(JAM_FRAME_SELECTOR)]; + for (const frame of frames) frame.remove(); + return frames; +} + +function pageWasPersisted(event: Event): boolean { + return 'persisted' in event && Boolean((event as PageTransitionEvent).persisted); +} + export function scheduleJamRecorder(options: JamLoaderOptions = {}): void { const documentRef = options.documentRef ?? document; const windowRef = documentRef.defaultView ?? window; - const loadWhenIdle = (): void => { + let started = false; + let detachedFrames: HTMLIFrameElement[] = []; + + // pagehide, not unload: unload listeners make the page ineligible for bfcache. + windowRef.addEventListener('pagehide', () => { + detachedFrames = detachJamFrames(documentRef); + }, { capture: true }); + windowRef.addEventListener('pageshow', (event) => { + if (!pageWasPersisted(event)) return; + const parent = documentRef.body ?? documentRef.documentElement; + for (const frame of detachedFrames) { + if (!frame.isConnected) parent.append(frame); + } + detachedFrames = []; + }); + + const start = (): void => { + if (started) return; + started = true; + windowRef.removeEventListener('pointerdown', onInteract, true); + windowRef.removeEventListener('keydown', onInteract, true); const load = (): void => { void loadJamRecorder(options).catch((): undefined => undefined); }; if (typeof windowRef.requestIdleCallback === 'function') { - windowRef.requestIdleCallback(load); + windowRef.requestIdleCallback(load, { timeout: 2_000 }); } else { windowRef.setTimeout(load, 0); } }; - if (documentRef.readyState === 'complete') { - loadWhenIdle(); - } else { - windowRef.addEventListener('load', loadWhenIdle, { once: true }); - } + const onInteract = (): void => { + start(); + }; + + const arm = (): void => { + windowRef.addEventListener('pointerdown', onInteract, { capture: true, passive: true }); + windowRef.addEventListener('keydown', onInteract, true); + windowRef.setTimeout(start, JAM_PASSIVE_DELAY_MS); + }; + + if (documentRef.readyState === 'complete') arm(); + else windowRef.addEventListener('load', arm, { once: true }); } diff --git a/src/lib/seo/pages.ts b/src/lib/seo/pages.ts index 2d7799902..3c3aa1e79 100644 --- a/src/lib/seo/pages.ts +++ b/src/lib/seo/pages.ts @@ -44,6 +44,11 @@ export const PAGE_HEADS = { description: `How ${BRAND} collects, uses, stores, and shares your information, how AI requests are processed, and the rights you have over your data.`, canonicalPath: '/privacy', }, + '/expocit/sources': { + title: `Sources | ${BRAND}`, + description: `The sources consulted during the research and development of ${BRAND}, listed alphabetically by author surname or institutional name.`, + canonicalPath: '/expocit/sources', + }, ...TOOL_PAGE_HEADS, '*': { title: `Page not found | ${BRAND}`, @@ -53,7 +58,12 @@ export const PAGE_HEADS = { export type PrerenderedRoute = keyof typeof PAGE_HEADS; -/** What index.html, the shell for every other route, says. */ +/** + * Head the client restores for a public page. index.html itself (the app + * shell copied to app-shell.html) says `noindex, nofollow`, matching the + * X-Robots-Tag on /app, /login and /share. Prerender writes `robots` onto + * every page that has a canonical URL. + */ export const SHELL_HEAD = { title: 'DebateLab', description: PAGE_HEADS['/'].description, diff --git a/src/lib/seo/use-page-head.ts b/src/lib/seo/use-page-head.ts index 7e6b15740..640723f85 100644 --- a/src/lib/seo/use-page-head.ts +++ b/src/lib/seo/use-page-head.ts @@ -33,7 +33,7 @@ export function usePageHead(route: PrerenderedRoute) { const head: { title: string; description: string; canonicalPath?: string } = PAGE_HEADS[route]; document.title = head.title; setMeta('description', head.description); - setMeta('robots', head.canonicalPath ? SHELL_HEAD.robots : 'noindex'); + setMeta('robots', head.canonicalPath ? SHELL_HEAD.robots : 'noindex, nofollow'); setCanonical(head.canonicalPath); return () => { document.title = SHELL_HEAD.title; diff --git a/src/pages/SourcesPage.tsx b/src/pages/SourcesPage.tsx index a2f81fc4b..a03056bff 100644 --- a/src/pages/SourcesPage.tsx +++ b/src/pages/SourcesPage.tsx @@ -1,8 +1,10 @@ import { useTranslation } from 'react-i18next'; import { PublicPageShell } from '@/components/layout/PublicPageShell'; +import { usePageHead } from '@/lib/seo/use-page-head'; export default function SourcesPage() { const { t } = useTranslation(); + usePageHead('/expocit/sources'); const sources = [ { authors: "AITHAL, V.; SILVER, J.", diff --git a/src/prerender/entry.tsx b/src/prerender/entry.tsx index 4df6ec36b..539af94cb 100644 --- a/src/prerender/entry.tsx +++ b/src/prerender/entry.tsx @@ -11,6 +11,7 @@ import LandingPage from '@/pages/LandingPage'; import TermsPage from '@/pages/TermsPage'; import PrivacyPage from '@/pages/PrivacyPage'; import NotFound from '@/pages/NotFound'; +import SourcesPage from '@/pages/SourcesPage'; import { TOOL_PAGES } from './tool-pages'; const unavailable = () => Promise.reject(new Error('Not available while prerendering')); @@ -202,6 +203,13 @@ export async function prerenderPages({ landingModified }: { landingModified?: st head: PAGE_HEADS[route], body: render(route, Page), })), + { + route: '/expocit/sources', + file: 'expocit/sources/index.html', + module: 'src/pages/SourcesPage.tsx', + head: PAGE_HEADS['/expocit/sources'], + body: render('/expocit/sources', SourcesPage), + }, { route: '*', file: '404.html', diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts new file mode 100644 index 000000000..fe6559622 --- /dev/null +++ b/src/test/jam-loader.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { JAM_PASSIVE_DELAY_MS, scheduleJamRecorder } from "@/jam-loader"; + +describe("scheduleJamRecorder", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal("requestIdleCallback", undefined); + // jsdom's window timers are not the ones Vitest replaces on globalThis. + window.setTimeout = globalThis.setTimeout.bind(globalThis); + window.clearTimeout = globalThis.clearTimeout.bind(globalThis); + document.head.querySelectorAll("script[data-jam-recorder]").forEach((node) => node.remove()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + document.head.querySelectorAll("script[data-jam-recorder]").forEach((node) => node.remove()); + document.body.querySelectorAll("iframe").forEach((frame) => frame.remove()); + }); + + function jamScripts(): string[] { + return [...document.head.querySelectorAll("script[data-jam-recorder]")].map((script) => script.getAttribute("src") ?? ""); + } + + it("stays off the page until the first interaction", () => { + scheduleJamRecorder({ mediaRecorderAvailable: true }); + vi.advanceTimersByTime(JAM_PASSIVE_DELAY_MS - 1); + expect(jamScripts()).toEqual([]); + + window.dispatchEvent(new Event("pointerdown")); + vi.advanceTimersByTime(1); + expect(jamScripts()).toEqual([ + "https://js.jam.dev/recorder.js", + "https://js.jam.dev/capture.js", + ]); + }); + + it("loads after the passive delay when nobody interacts", () => { + scheduleJamRecorder({ mediaRecorderAvailable: true }); + vi.advanceTimersByTime(JAM_PASSIVE_DELAY_MS + 1); + expect(jamScripts()).toEqual([ + "https://js.jam.dev/recorder.js", + "https://js.jam.dev/capture.js", + ]); + }); + + it("does not load where MediaRecorder is missing", () => { + scheduleJamRecorder({ mediaRecorderAvailable: false }); + window.dispatchEvent(new KeyboardEvent("keydown")); + vi.advanceTimersByTime(JAM_PASSIVE_DELAY_MS + 1); + expect(jamScripts()).toEqual([]); + }); + + it("removes Jam frames on pagehide and restores them from the back/forward cache", () => { + const listened: string[] = []; + const realAdd = window.addEventListener.bind(window); + vi.spyOn(window, "addEventListener").mockImplementation((type, listener, options) => { + listened.push(String(type)); + realAdd(type, listener, options); + }); + + scheduleJamRecorder({ mediaRecorderAvailable: false }); + expect(listened).toContain("pagehide"); + expect(listened).toContain("pageshow"); + expect(listened).not.toContain("unload"); + expect(listened).not.toContain("beforeunload"); + + const frame = document.createElement("iframe"); + frame.src = "https://recorder.jam.dev/capture"; + frame.dataset.jamCapture = "true"; + document.body.append(frame); + + window.dispatchEvent(new Event("pagehide")); + expect(frame.isConnected).toBe(false); + + const restored = new Event("pageshow"); + Object.defineProperty(restored, "persisted", { value: true }); + window.dispatchEvent(restored); + expect(frame.isConnected).toBe(true); + frame.remove(); + }); +}); diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index ebbaf2dea..f4790eb06 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -16,6 +16,11 @@ type VercelConfig = { redirects?: Array<{ source: string; destination: string; statusCode?: number }>; rewrites?: Array<{ source: string; destination: string }>; headers?: HeaderRule[]; + routes?: Array<{ + src?: string; + continue?: boolean; + transforms?: Array<{ op?: string; target?: { key?: string } }>; + }>; }; function loadConfig(): VercelConfig { @@ -175,6 +180,8 @@ describe("Vercel frontend deployment configuration", () => { expect(indexHtml).not.toContain('// Prevent flash of wrong theme'); expect(themeBootstrap).toContain("document.documentElement.classList.add('app-shell')"); expect(themeBootstrap).toContain("pathname === '/login'"); + expect(themeBootstrap).toContain("pathname === '/expocit/sources'"); + expect(themeBootstrap).toContain("noindex, nofollow"); expect(themeBootstrap).toContain('window.__showDebateBootstrapError = showBootstrapError'); expect(themeBootstrap).toContain("debate-trainer-settings"); }); @@ -185,11 +192,23 @@ describe("Vercel frontend deployment configuration", () => { const indexHtml = readProjectFile("index.html"); const bootstrap = readProjectFile("src/bootstrap.ts"); const jamLoader = readProjectFile("src/jam-loader.ts"); + const directive = (name: string) => cspDirective(csp, name); - expect(csp).toContain("script-src 'self' https://*.jam.dev"); - expect(csp).toContain("frame-src 'self' https://accounts.google.com https://*.jam.dev"); - // Loading the recorder is useless if it cannot upload the capture. - expect(csp).toContain("connect-src 'self' https://*.jam.dev"); + // The parent page loads js.jam.dev and frames recorder.jam.dev. A wildcard + // would allow every other host on that domain. Jam's rspack runtime contains + // Function("return this"); do not allow unsafe-eval for that fallback. + expect(directive("script-src")).toBe("script-src 'self' https://js.jam.dev"); + expect(directive("frame-src")).toBe("frame-src 'self' https://accounts.google.com https://recorder.jam.dev"); + expect(directive("connect-src")).toContain("https://js.jam.dev"); + expect(directive("connect-src")).toContain("https://recorder.jam.dev"); + expect(directive("img-src")).toContain("https://js.jam.dev"); + expect(directive("img-src")).toContain("https://recorder.jam.dev"); + expect(csp).not.toContain("*.jam.dev"); + expect(csp).not.toContain("unsafe-eval"); + // Web Analytics and Speed Insights are same-origin (/_vercel/insights and + // /_vercel/speed-insights), so script-src and connect-src 'self' allow them. + expect(directive("script-src")).toContain("'self'"); + expect(directive("connect-src")).toContain("'self'"); expect(indexHtml).toContain(''); expect(indexHtml).toContain(''); expect(indexHtml).toContain('data-startup-shell'); @@ -263,19 +282,66 @@ describe("Vercel frontend deployment configuration", () => { }, ); - it.each(["/", "/index.html", "/app-shell.html", "/theme-init.js", "/sw.js"])( - "prevents stale caching for %s", + it("indexes the public sources page and noindexes the app shell document", () => { + const config = loadConfig(); + const indexHtml = readProjectFile("index.html"); + const prerender = readProjectFile("scripts/prerender.mjs"); + const publicRobots = "index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1"; + + expect(headersAt(config, "/expocit/sources")).not.toHaveProperty("X-Robots-Tag"); + expect(headersAt(config, "/")).not.toHaveProperty("X-Robots-Tag"); + expect(headersAt(config, "/app")).toMatchObject({ "X-Robots-Tag": "noindex, nofollow" }); + expect(indexHtml).toContain(''); + expect(prerender).toContain(publicRobots); + expect(prerender).toContain("noindex, nofollow"); + expect(readProjectFile("src/lib/seo/pages.ts")).toContain(`robots: '${publicRobots}'`); + }); + + it.each(["/", "/index.html", "/app-shell.html", "/theme-init.js"])( + "revalidates %s without no-store, so the back/forward cache can keep the document", (source) => { - const config = loadConfig(); - expect(headersFor(config, source)["Cache-Control"]).toContain("no-cache"); - expect(headersFor(config, source)["Cache-Control"]).toContain("max-age=0"); + const cache = headersFor(loadConfig(), source)["Cache-Control"]; + expect(cache).toContain("max-age=0"); + expect(cache).toContain("must-revalidate"); + expect(cache).not.toContain("no-store"); }, ); + it("does not cache the service worker, so an update is not stuck", () => { + const cache = headersFor(loadConfig(), "/sw.js")["Cache-Control"]; + expect(cache).toContain("no-store"); + expect(cache).toContain("max-age=0"); + }); + it("caches hashed Vite assets immutably", () => { const config = loadConfig(); expect(headersFor(config, "/assets/(.*)")["Cache-Control"]).toBe( "public, max-age=31536000, immutable", ); }); + + it("caches the unhashed public images for 30 days", () => { + const config = loadConfig(); + for (const path of ["/favicon.ico", "/favicon.png", "/favicon-72.png", "/hero-visual.png", "/placeholder.svg"]) { + expect(headersAt(config, path)["Cache-Control"]).toBe("public, max-age=2592000"); + } + expect(headersAt(config, "/assets/index-abc123.js")["Cache-Control"]).toBe( + "public, max-age=31536000, immutable", + ); + }); + + it("strips the platform Access-Control-Allow-Origin header from HTML and not from assets", () => { + const routes = loadConfig().routes ?? []; + const html = routes.find((route) => route.transforms?.some((transform) => transform.op === "delete")); + expect(html?.continue).toBe(true); + const deleted = html?.transforms?.filter((transform) => transform.op === "delete").map((transform) => transform.target?.key); + expect(deleted).toEqual(["access-control-allow-origin", "Access-Control-Allow-Origin"]); + const pattern = new RegExp(html?.src ?? "$^"); + for (const path of ["/", "/app", "/login", "/share/abc", "/expocit/sources", "/terms", "/index.html", "/online-debate-timer"]) { + expect(pattern.test(path), path).toBe(true); + } + for (const path of ["/favicon.png", "/hero-visual.png", "/assets/main.js", "/theme-init.js", "/sw.js", "/robots.txt", "/_vercel/insights/script.js"]) { + expect(pattern.test(path), path).toBe(false); + } + }); }); diff --git a/vercel.json b/vercel.json index 4adfb3c7f..04fd8245a 100644 --- a/vercel.json +++ b/vercel.json @@ -141,7 +141,7 @@ "headers": [ { "key": "Content-Security-Policy", - "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob: https://*.jam.dev; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://*.jam.dev; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com https://*.jam.dev; connect-src 'self' https://*.jam.dev https://uziwdtkzofptihahfooo.supabase.co https://brifeyypjauyusmkptkx.supabase.co https://*.ingest.sentry.io https://*.sentry.io https://eu.i.posthog.com https://*.posthog.com;" + "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; img-src 'self' data: blob: https://js.jam.dev https://recorder.jam.dev; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' https://js.jam.dev; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com https://recorder.jam.dev; connect-src 'self' https://js.jam.dev https://recorder.jam.dev https://uziwdtkzofptihahfooo.supabase.co https://brifeyypjauyusmkptkx.supabase.co https://*.ingest.sentry.io https://*.sentry.io https://eu.i.posthog.com https://*.posthog.com;" }, { "key": "X-Frame-Options", @@ -212,7 +212,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -221,7 +221,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -230,7 +230,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -239,7 +239,7 @@ "headers": [ { "key": "Cache-Control", - "value": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" + "value": "public, max-age=0, must-revalidate" } ] }, @@ -260,6 +260,33 @@ "value": "public, max-age=31536000, immutable" } ] + }, + { + "source": "/(favicon\\.ico|favicon\\.png|favicon-72\\.png|hero-visual\\.png|placeholder\\.svg)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=2592000" + } + ] + } + ], + "routes": [ + { + "src": "^/(?!assets(?:/|$)|_vercel(?:/|$)|.*\\.(?:js|mjs|css|png|jpe?g|gif|webp|avif|svg|ico|txt|xml|json|map|webmanifest|woff2?)$).*$", + "continue": true, + "transforms": [ + { + "type": "response.headers", + "op": "delete", + "target": { "key": "access-control-allow-origin" } + }, + { + "type": "response.headers", + "op": "delete", + "target": { "key": "Access-Control-Allow-Origin" } + } + ] } ] } From c506b2aa5e3905a4b9ea7b255543d79ad9a3ff5c Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 21:06:49 +0000 Subject: [PATCH 2/3] Leave Jam off after a back/forward cache restore Putting the capture iframe back on pageshow reloads it, and the new frame starts a handshake that Jam's already-initialized parent script rejects. pagehide still removes the frame so the page stays cache-eligible; the restored page simply runs without Jam until the next full load. --- e2e/loading-performance.spec.ts | 25 +++++++++++++++++++++++++ src/jam-loader.ts | 28 ++++++++-------------------- src/test/jam-loader.test.ts | 7 +++---- 3 files changed, 36 insertions(+), 24 deletions(-) diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 3fba6cb0c..5148eb0ac 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -22,6 +22,31 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page, await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); }); +test('a page restored from the back/forward cache leaves Jam off instead of re-adding its frame', async ({ page, browserName }) => { + test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); + await page.route('https://js.jam.dev/**', (route) => route.fulfill({ status: 200, contentType: 'text/javascript', body: '' })); + + await page.goto('/', { waitUntil: 'load' }); + const heading = page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ }); + await expect(heading).toBeVisible(); + const jamRequest = page.waitForRequest('https://js.jam.dev/**'); + await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))); + await jamRequest; + + // Stand-in for the capture frame Jam injects; the real one is cross-origin. + const frames = await page.evaluate(() => { + const frame = document.createElement('iframe'); + frame.dataset.jamCapture = 'true'; + document.body.append(frame); + window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true })); + const afterHide = document.querySelectorAll('iframe[data-jam-capture]').length; + window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true })); + return { afterHide, afterShow: document.querySelectorAll('iframe[data-jam-capture]').length }; + }); + expect(frames).toEqual({ afterHide: 0, afterShow: 0 }); + await expect(heading).toBeVisible(); +}); + test('public routes keep the real UI after refresh', async ({ page }) => { await setLocale(page, 'en'); for (const [path, label] of [ diff --git a/src/jam-loader.ts b/src/jam-loader.ts index 643cc88e1..48e9ddfb2 100644 --- a/src/jam-loader.ts +++ b/src/jam-loader.ts @@ -13,8 +13,10 @@ const JAM_SCRIPT_TIMEOUT_MS = 3_000; * That iframe is cross-origin. Chrome cannot tell whether it is eligible for * the back/forward cache, and an `unload` listener inside it would block the * whole page. `pagehide` removes the frame first (it does not block the cache - * the way `unload` and `beforeunload` do) and `pageshow` puts the same node - * back when the page is restored. + * the way `unload` and `beforeunload` do). The frame is not put back when the + * page is restored: a re-added frame reloads and starts a new handshake, which + * Jam's already-initialized parent script rejects. Jam stays off on a page + * restored from the back/forward cache and returns on the next full load. */ export const JAM_PASSIVE_DELAY_MS = 8_000; @@ -65,34 +67,20 @@ export async function loadJamRecorder(options: JamLoaderOptions = {}): Promise appendJamModule(documentRef, source))); } -function detachJamFrames(documentRef: Document): HTMLIFrameElement[] { - const frames = [...documentRef.querySelectorAll(JAM_FRAME_SELECTOR)]; - for (const frame of frames) frame.remove(); - return frames; -} - -function pageWasPersisted(event: Event): boolean { - return 'persisted' in event && Boolean((event as PageTransitionEvent).persisted); +function detachJamFrames(documentRef: Document): void { + for (const frame of documentRef.querySelectorAll(JAM_FRAME_SELECTOR)) frame.remove(); } export function scheduleJamRecorder(options: JamLoaderOptions = {}): void { const documentRef = options.documentRef ?? document; const windowRef = documentRef.defaultView ?? window; let started = false; - let detachedFrames: HTMLIFrameElement[] = []; // pagehide, not unload: unload listeners make the page ineligible for bfcache. + // Nothing is restored on pageshow; see the note on JAM_PASSIVE_DELAY_MS. windowRef.addEventListener('pagehide', () => { - detachedFrames = detachJamFrames(documentRef); + detachJamFrames(documentRef); }, { capture: true }); - windowRef.addEventListener('pageshow', (event) => { - if (!pageWasPersisted(event)) return; - const parent = documentRef.body ?? documentRef.documentElement; - for (const frame of detachedFrames) { - if (!frame.isConnected) parent.append(frame); - } - detachedFrames = []; - }); const start = (): void => { if (started) return; diff --git a/src/test/jam-loader.test.ts b/src/test/jam-loader.test.ts index fe6559622..0ede1826a 100644 --- a/src/test/jam-loader.test.ts +++ b/src/test/jam-loader.test.ts @@ -52,7 +52,7 @@ describe("scheduleJamRecorder", () => { expect(jamScripts()).toEqual([]); }); - it("removes Jam frames on pagehide and restores them from the back/forward cache", () => { + it("removes Jam frames on pagehide and leaves them off after a back/forward cache restore", () => { const listened: string[] = []; const realAdd = window.addEventListener.bind(window); vi.spyOn(window, "addEventListener").mockImplementation((type, listener, options) => { @@ -62,7 +62,6 @@ describe("scheduleJamRecorder", () => { scheduleJamRecorder({ mediaRecorderAvailable: false }); expect(listened).toContain("pagehide"); - expect(listened).toContain("pageshow"); expect(listened).not.toContain("unload"); expect(listened).not.toContain("beforeunload"); @@ -77,7 +76,7 @@ describe("scheduleJamRecorder", () => { const restored = new Event("pageshow"); Object.defineProperty(restored, "persisted", { value: true }); window.dispatchEvent(restored); - expect(frame.isConnected).toBe(true); - frame.remove(); + expect(frame.isConnected).toBe(false); + expect(document.querySelectorAll("iframe[data-jam-capture]")).toHaveLength(0); }); }); From 177269d232cdfbac1b269954eb42b7b23491958c Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 22 Oct 2026 21:12:21 +0000 Subject: [PATCH 3/3] Say when Jam can still start after a back/forward cache restore Only a Jam that already loaded stays off after a restore. A trigger that had not fired yet may still start it, which is a clean first load. --- e2e/loading-performance.spec.ts | 2 +- src/jam-loader.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/e2e/loading-performance.spec.ts b/e2e/loading-performance.spec.ts index 5148eb0ac..2c6f1ce6d 100644 --- a/e2e/loading-performance.spec.ts +++ b/e2e/loading-performance.spec.ts @@ -22,7 +22,7 @@ test('a stalled Jam recorder cannot delay the real application', async ({ page, await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0); }); -test('a page restored from the back/forward cache leaves Jam off instead of re-adding its frame', async ({ page, browserName }) => { +test('pagehide removes the Jam frame and a persisted pageshow does not re-add it (synthetic events, stubbed Jam)', async ({ page, browserName }) => { test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.'); await page.route('https://js.jam.dev/**', (route) => route.fulfill({ status: 200, contentType: 'text/javascript', body: '' })); diff --git a/src/jam-loader.ts b/src/jam-loader.ts index 48e9ddfb2..677460842 100644 --- a/src/jam-loader.ts +++ b/src/jam-loader.ts @@ -15,8 +15,11 @@ const JAM_SCRIPT_TIMEOUT_MS = 3_000; * whole page. `pagehide` removes the frame first (it does not block the cache * the way `unload` and `beforeunload` do). The frame is not put back when the * page is restored: a re-added frame reloads and starts a new handshake, which - * Jam's already-initialized parent script rejects. Jam stays off on a page - * restored from the back/forward cache and returns on the next full load. + * Jam's already-initialized parent script rejects. So once Jam has loaded, it + * stays off on a page restored from the back/forward cache and returns on the + * next full load. If it had not started before the page was hidden, the + * pending trigger may still start it after the restore; that is a clean first + * load, not a reconnect. */ export const JAM_PASSIVE_DELAY_MS = 8_000;