Skip to content

Commit 50d359f

Browse files
Delay Jam and align cache, CSP, and robots with the public pages
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 <Programcode@users.noreply.github.com>
1 parent ce3836e commit 50d359f

15 files changed

Lines changed: 314 additions & 41 deletions

‎docs/operations.md‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,23 @@ Vercel builds and serves the frontend from the Git repository. A push to `main`
4242

4343
- 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;
4444
- the Vite build command and `dist` output;
45-
- 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;
45+
- 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;
4646
- `trailingSlash: false`, which 308-redirects `/app/settings/` to `/app/settings` before routing, since rewrite and redirect sources match exactly;
4747
- 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`;
4848
- 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;
49-
- no-cache headers for `/`, `index.html`, `app-shell.html`, `theme-init.js`, and `sw.js`, and immutable caching for hashed `/assets/`;
50-
- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`;
51-
- 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.
49+
- 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;
50+
- `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;
51+
- 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.
5252

53-
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.
53+
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.
5454

5555
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.
5656

5757
`/embed/debate-timer` is a second Vite HTML entry ([`embed/debate-timer/index.html`](../embed/debate-timer/index.html)), not an app route: it loads only the timer, never the app shell, Jam, analytics or the service worker, so a site that frames it sees no third-party request. It is `noindex` with its canonical URL on `/tools/debate-timer`, and the service worker leaves `/embed/` alone.
5858

5959
[`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 `<lastmod>` 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 `<lastmod>`.
6060

61-
[`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.
61+
[`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.
6262

6363
The guide pages in `public/<slug>/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.
6464

@@ -106,7 +106,7 @@ Admins see a **Developer** card in Settings that signs out, sets the key, and re
106106

107107
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).
108108

109-
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.
109+
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.
110110

111111
## Opt-in previews
112112

‎e2e/loading-performance.spec.ts‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@ import { setLocale, stubSupabaseSession } from './helpers/auth';
44
test('a stalled Jam recorder cannot delay the real application', async ({ page, browserName }) => {
55
test.skip(browserName === 'webkit', 'Jam recording requires MediaRecorder, which this WebKit runtime does not expose.');
66
await page.route('https://js.jam.dev/**', async () => new Promise(() => undefined));
7-
const jamRequest = page.waitForRequest('https://js.jam.dev/**');
7+
const jamRequests: string[] = [];
8+
page.on('request', (request) => {
9+
if (request.url().startsWith('https://js.jam.dev/')) jamRequests.push(request.url());
10+
});
811

912
await page.goto('/', { waitUntil: 'domcontentloaded' });
1013

11-
await jamRequest;
14+
// Jam waits for an interaction, so the landing page is up before any recorder request.
1215
await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible({ timeout: 2_000 });
16+
expect(jamRequests).toEqual([]);
17+
18+
const jamRequest = page.waitForRequest('https://js.jam.dev/**');
19+
await page.evaluate(() => window.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true })));
20+
await jamRequest;
21+
await expect(page.getByRole('heading', { name: /Prepare Cases and Practice Rounds/ })).toBeVisible();
1322
await expect(page.getByText('DebateLab by Pi-Stack is the official')).toHaveCount(0);
1423
});
1524

‎index.html‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
<meta name="description" content="DebateLab is an AI debate prep and practice app for students and competitive debaters: cases, rebuttals, evidence, coaching and timed practice rounds against an AI opponent." />
1313
<meta name="keywords" content="DebateLab, debate lab, AI debate practice, debate practice platform, debate drills, rebuttal practice, argumentation training, debate coach, debate preparation" />
1414
<meta name="author" content="DebateLab by Pi-Stack" />
15-
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
15+
<!-- The shell for /app, /login and /share. Prerender replaces this with the
16+
public index directive on pages that have a canonical URL. -->
17+
<meta name="robots" content="noindex, nofollow" />
1618
<meta name="language" content="English" />
1719
<meta name="theme-color" content="#f1f5f9" media="(prefers-color-scheme: light)" />
1820
<meta name="theme-color" content="#0f172a" media="(prefers-color-scheme: dark)" />

‎public/theme-init.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,19 @@
2020

2121
try {
2222
var pathname = window.location.pathname;
23+
var privateRoute = pathname === '/app' || pathname.indexOf('/app/') === 0
24+
|| pathname === '/login'
25+
|| pathname === '/share' || pathname.indexOf('/share/') === 0;
2326
if (pathname === '/app' || pathname.indexOf('/app/') === 0 || pathname === '/login') {
2427
document.documentElement.classList.add('app-shell');
2528
}
29+
// The shell HTML is noindex. Private routes keep that. The sources page is
30+
// public, including when the app-shell rewrite serves this document.
31+
var robots = document.querySelector('meta[name="robots"]');
32+
if (robots && privateRoute) robots.setAttribute('content', 'noindex, nofollow');
33+
else if (robots && pathname === '/expocit/sources') {
34+
robots.setAttribute('content', 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1');
35+
}
2636
} catch {
2737
// Ignore unavailable location or document globals.
2838
}

‎scripts/check-site.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const DIST = 'dist';
1313
const SITE_ORIGIN = JSON.parse(readFileSync('src/config/site.json', 'utf8')).origin;
1414
const vercel = JSON.parse(readFileSync('vercel.json', 'utf8'));
1515
// Indexed pages that answer no question of their own.
16-
const NO_SHORT_ANSWER = new Set(['/terms', '/privacy']);
16+
const NO_SHORT_ANSWER = new Set(['/terms', '/privacy', '/expocit/sources']);
1717

1818
const problems = [];
1919
const report = (where, message) => problems.push(`${where}: ${message}`);

‎scripts/prerender.mjs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,12 @@ function pageHtml(shell, page, assets) {
7474
[/<meta name="twitter:title" content="[^"]*" \/>/, `<meta name="twitter:title" content="${escapeAttribute(head.title)}" />`, 'twitter:title'],
7575
[/<meta name="twitter:description" content="[^"]*" \/>/, `<meta name="twitter:description" content="${escapeAttribute(head.description)}" />`, 'twitter:description'],
7676
]) html = replaceOnce(html, pattern, value, label);
77-
if (!head.canonicalPath) {
78-
html = replaceOnce(html, /<meta name="robots" content="[^"]*" \/>/, '<meta name="robots" content="noindex" />', 'robots');
79-
}
77+
// The shell document is noindex (app, login, share). A public page puts the
78+
// index directive back; a page without a canonical URL stays noindex.
79+
const robots = head.canonicalPath
80+
? 'index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1'
81+
: 'noindex, nofollow';
82+
html = replaceOnce(html, /<meta name="robots" content="[^"]*" \/>/, `<meta name="robots" content="${robots}" />`, 'robots');
8083

8184
const added = [];
8285
if (head.canonicalPath) {

‎scripts/test-service-worker-offline.mjs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ async function main() {
132132
headers: { 'Cache-Control': 'no-store' },
133133
body: V2_WORKER,
134134
}));
135-
await upgradePage.goto(origin, { waitUntil: 'networkidle' });
135+
await upgradePage.goto(origin, { waitUntil: 'load' });
136136
await upgradePage.evaluate(async () => navigator.serviceWorker.ready);
137137
await upgradePage.waitForFunction(async () => {
138138
const registration = await navigator.serviceWorker.getRegistration();
@@ -153,7 +153,7 @@ async function main() {
153153

154154
const activatedPage = await context.newPage();
155155
await prepare(activatedPage);
156-
await activatedPage.goto(origin, { waitUntil: 'networkidle' });
156+
await activatedPage.goto(origin, { waitUntil: 'load' });
157157
await activatedPage.evaluate(async () => navigator.serviceWorker.ready);
158158
await activatedPage.waitForFunction(async () => {
159159
const keys = await caches.keys();
@@ -167,7 +167,7 @@ async function main() {
167167

168168
const page = await context.newPage();
169169
await prepare(page);
170-
await page.goto(`${origin}/app`, { waitUntil: 'networkidle' });
170+
await page.goto(`${origin}/app`, { waitUntil: 'load' });
171171
await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
172172
await page.evaluate(async () => navigator.serviceWorker.ready);
173173
await page.waitForFunction(async (paths) => {
@@ -192,7 +192,7 @@ async function main() {
192192
await context.setOffline(false);
193193
const prefetchedPage = await context.newPage();
194194
await prepare(prefetchedPage);
195-
await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'networkidle' });
195+
await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'load' });
196196
await prefetchedPage.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
197197
const attackLink = prefetchedPage.locator('main [data-tour="mode-attack"]');
198198
await attackLink.hover();

0 commit comments

Comments
 (0)