mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
fix(website): detect the visitor platform during SSR (#4222)
The homepage download CTA was chosen by a client-only hook that started at "mac-silicon" and corrected itself in an effect, so the server-rendered HTML said "Download for Mac" for every visitor and only became right after hydration. On a phone that window is long enough to photograph. Worse, the detection had no mobile branch at all: Android user agents also say "Linux", so once hydration did land, a Pixel was offered an AppImage. Resolve the platform from the request user agent during SSR instead, so the first paint is already correct. Mobile visitors get the App Store or Play Store as the primary action and a link to /download for the desktop build, rather than a .dmg they cannot open. The markup now varies by user agent, so HTML and server-function responses carry `Cache-Control: private, no-store` and `Vary: User-Agent`. An absent cache-control is not no-store — a shared cache may assign heuristic freshness, and turning on Workers Cache would store a headerless 200 for two hours under a key that ignores the user agent, letting one Android visitor pin the Play Store button for every Mac visitor after them. Also tighten the mobile layout the wrong CTA was sitting in: the primary action gets its own row so the icon buttons stop orphaning one of themselves, provider cards drop to a two-column grid, and the hardcoded mobile line break in the phone showcase gives way to a balanced measure.
This commit is contained in:
@@ -56,13 +56,15 @@ import { AGENT_PAGES } from "~/data/agent-pages";
|
||||
import {
|
||||
appStoreUrl,
|
||||
playStoreUrl,
|
||||
getDownloadOptions,
|
||||
useDetectedPlatform,
|
||||
getDesktopDownload,
|
||||
MOBILE_STORES,
|
||||
AppleIcon,
|
||||
PlayStoreIcon,
|
||||
TerminalIcon,
|
||||
} from "~/downloads";
|
||||
import { useRelease } from "~/routes/__root";
|
||||
import type { DesktopPlatform, MobilePlatform } from "~/platform";
|
||||
import { isMobilePlatform } from "~/platform";
|
||||
import { useRelease, useVisitorPlatform } from "~/routes/__root";
|
||||
import { HeroMockup } from "~/components/hero-mockup";
|
||||
import {
|
||||
ClaudeCodeIcon,
|
||||
@@ -315,7 +317,7 @@ function SectionTitle({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground max-w-lg">{description}</p>
|
||||
<p className="text-base text-pretty text-muted-foreground max-w-lg">{description}</p>
|
||||
{links ? (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 pt-1 text-xs">
|
||||
{links.map((link) => (
|
||||
@@ -414,13 +416,15 @@ function SocialProofCard({ tweet, inert }: { tweet: SocialProofTweet; inert?: bo
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_ICON_CLASS = "h-5 w-5 sm:h-7 sm:w-7";
|
||||
|
||||
function MultiProviderSection() {
|
||||
const providers = [
|
||||
{ name: "Claude Code", icon: <ClaudeIcon size={28} /> },
|
||||
{ name: "Codex", icon: <CodexIcon className="w-7 h-7" /> },
|
||||
{ name: "OpenCode", icon: <OpenCodeIcon className="w-7 h-7" /> },
|
||||
{ name: "Pi", icon: <PiIcon className="w-7 h-7" /> },
|
||||
{ name: "Cursor", icon: <CursorIcon className="w-7 h-7" /> },
|
||||
{ name: "Claude Code", icon: <ClaudeIcon className={PROVIDER_ICON_CLASS} /> },
|
||||
{ name: "Codex", icon: <CodexIcon className={PROVIDER_ICON_CLASS} /> },
|
||||
{ name: "OpenCode", icon: <OpenCodeIcon className={PROVIDER_ICON_CLASS} /> },
|
||||
{ name: "Pi", icon: <PiIcon className={PROVIDER_ICON_CLASS} /> },
|
||||
{ name: "Cursor", icon: <CursorIcon className={PROVIDER_ICON_CLASS} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -428,21 +432,21 @@ function MultiProviderSection() {
|
||||
title="Works with your tools"
|
||||
description="Bring your subscriptions, skills and configuration"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-4">
|
||||
{providers.map((p) => (
|
||||
<div
|
||||
key={p.name}
|
||||
className="flex items-center justify-center gap-3 rounded-xl border border-white/10 bg-white/[0.03] px-5 py-4"
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-3 sm:gap-3 sm:px-5 sm:py-4"
|
||||
>
|
||||
<span className="text-white/80">{p.icon}</span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="shrink-0 text-white/80">{p.icon}</span>
|
||||
<span className="truncate text-sm font-medium sm:text-base">{p.name}</span>
|
||||
</div>
|
||||
))}
|
||||
<a
|
||||
href="/agents"
|
||||
className="flex items-center justify-center gap-3 rounded-xl border border-dashed border-white/10 bg-white/[0.01] px-5 py-4 text-white/50 hover:text-white/80 hover:border-white/20 hover:bg-white/[0.03] transition-colors"
|
||||
className="flex items-center justify-center gap-2 rounded-xl border border-dashed border-white/10 bg-white/[0.01] px-3 py-3 text-white/50 hover:text-white/80 hover:border-white/20 hover:bg-white/[0.03] transition-colors sm:gap-3 sm:px-5 sm:py-4"
|
||||
>
|
||||
<span className="font-medium">+{ADDITIONAL_AGENT_COUNT} more</span>
|
||||
<span className="text-sm font-medium sm:text-base">+{ADDITIONAL_AGENT_COUNT} more</span>
|
||||
</a>
|
||||
</div>
|
||||
</FeatureSection>
|
||||
@@ -842,31 +846,24 @@ function ExtensibleCard({
|
||||
}
|
||||
|
||||
function GetStarted() {
|
||||
const platform = useVisitorPlatform();
|
||||
return (
|
||||
<div className="pt-10">
|
||||
<div className="flex flex-row flex-wrap justify-center gap-3">
|
||||
<DownloadButton />
|
||||
<a
|
||||
href={appStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2 text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="App Store"
|
||||
>
|
||||
<AppleIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href={playStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2 text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Google Play"
|
||||
>
|
||||
<PlayStoreIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<ServerInstallButton />
|
||||
{/* The primary call to action owns its own row on phones so the small icon
|
||||
buttons never wrap and orphan one of themselves onto a line alone. It
|
||||
still hugs its label rather than stretching across the row. */}
|
||||
<div className="mx-auto flex flex-col items-center gap-3 sm:flex-row sm:justify-center">
|
||||
{isMobilePlatform(platform) ? (
|
||||
<StoreButton platform={platform} />
|
||||
) : (
|
||||
<DesktopDownloadButton platform={platform} />
|
||||
)}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
{isMobilePlatform(platform) ? <DesktopAppLink /> : <StoreIconLinks />}
|
||||
<ServerInstallButton />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 pt-6">
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-2 gap-y-1 pt-6">
|
||||
<span className="text-xs text-muted-foreground">Supports</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<AgentBadge name="Claude Code" icon={CLAUDE_CODE_BADGE_ICON} />
|
||||
@@ -877,7 +874,7 @@ function GetStarted() {
|
||||
</div>
|
||||
<a
|
||||
href="/agents"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="whitespace-nowrap text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
+{ADDITIONAL_AGENT_COUNT} more
|
||||
</a>
|
||||
@@ -886,27 +883,74 @@ function GetStarted() {
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadButton() {
|
||||
const release = useRelease();
|
||||
const detectedPlatform = useDetectedPlatform();
|
||||
const primary = getDownloadOptions(release).find((o) => o.platform === detectedPlatform)!;
|
||||
const PrimaryIcon = primary.icon;
|
||||
const PRIMARY_CTA_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-sm font-medium text-background hover:bg-foreground/90 transition-colors";
|
||||
const SECONDARY_CTA_CLASS =
|
||||
"inline-flex items-center justify-center gap-2 rounded-lg border border-white/12 px-3 py-2.5 text-sm text-white hover:bg-white/10 transition-colors";
|
||||
|
||||
function DesktopDownloadButton({ platform }: { platform: DesktopPlatform }) {
|
||||
const download = getDesktopDownload(useRelease(), platform);
|
||||
const Icon = download.icon;
|
||||
return (
|
||||
<a
|
||||
href={primary.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-foreground px-4 py-2 text-sm font-medium text-background hover:bg-foreground/90 transition-colors"
|
||||
>
|
||||
<PrimaryIcon className="h-4 w-4" />
|
||||
Download for {primary.label}
|
||||
<a href={download.href} target="_blank" rel="noopener noreferrer" className={PRIMARY_CTA_CLASS}>
|
||||
<Icon className="h-4 w-4" />
|
||||
Download for {download.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreButton({ platform }: { platform: MobilePlatform }) {
|
||||
const store = MOBILE_STORES[platform];
|
||||
const Icon = store.icon;
|
||||
return (
|
||||
<a href={store.href} target="_blank" rel="noopener noreferrer" className={PRIMARY_CTA_CLASS}>
|
||||
<Icon className="h-4 w-4" />
|
||||
Get the {store.label} app
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// On a phone the desktop build is the secondary path, so it points at /download
|
||||
// instead of handing the visitor a .dmg they cannot open.
|
||||
function DesktopAppLink() {
|
||||
return (
|
||||
<a href="/download" className={SECONDARY_CTA_CLASS}>
|
||||
<Monitor className="h-4 w-4" strokeWidth={1.5} />
|
||||
Desktop app
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function StoreIconLinks() {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={appStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_CTA_CLASS}
|
||||
aria-label="App Store"
|
||||
>
|
||||
<AppleIcon className="h-5 w-5" />
|
||||
</a>
|
||||
<a
|
||||
href={playStoreUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_CTA_CLASS}
|
||||
aria-label="Google Play"
|
||||
>
|
||||
<PlayStoreIcon className="h-5 w-5" />
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const SERVER_INSTALL_TRIGGER = (
|
||||
<span className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2 text-white hover:bg-white/10 transition-colors">
|
||||
<span
|
||||
className="inline-flex items-center justify-center rounded-lg border border-white/12 px-3 py-2.5 text-white hover:bg-white/10 transition-colors"
|
||||
aria-label="Install the daemon on a remote machine"
|
||||
>
|
||||
<TerminalIcon className="h-5 w-5" />
|
||||
</span>
|
||||
);
|
||||
@@ -989,11 +1033,10 @@ function PhoneShowcase() {
|
||||
>
|
||||
<path d="M12 5v14M5 12l7 7 7-7" />
|
||||
</svg>
|
||||
<p className="text-lg text-white/80 text-center">
|
||||
When you want to step away from your desk,
|
||||
<br className="md:hidden" /> you can.
|
||||
<p className="max-w-md text-balance text-center text-lg text-white/80">
|
||||
When you want to step away from your desk, you can.
|
||||
</p>
|
||||
<p className="text-sm text-white/50 text-center">
|
||||
<p className="max-w-sm text-balance text-center text-sm text-white/50">
|
||||
The native mobile app has full feature parity with desktop.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import type { DesktopPlatform, MobilePlatform } from "~/platform";
|
||||
|
||||
export function releaseBase(version: string) {
|
||||
return `https://github.com/getpaseo/paseo/releases/download/v${version}`;
|
||||
@@ -30,68 +31,31 @@ export const appStoreUrl = "https://apps.apple.com/app/paseo-pocket-engineer/id6
|
||||
export const playStoreUrl = "https://play.google.com/store/apps/details?id=sh.paseo";
|
||||
export const webAppUrl = "https://app.paseo.sh";
|
||||
|
||||
type Platform = "mac-silicon" | "mac-intel" | "windows" | "linux";
|
||||
|
||||
export interface DownloadOption {
|
||||
platform: Platform;
|
||||
export interface PrimaryDownload {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: (props: React.SVGProps<SVGSVGElement>) => React.ReactElement;
|
||||
}
|
||||
|
||||
export function getDownloadOptions(release: ReleaseAssetInfo): DownloadOption[] {
|
||||
export function getDesktopDownload(
|
||||
release: ReleaseAssetInfo,
|
||||
platform: DesktopPlatform,
|
||||
): PrimaryDownload {
|
||||
const urls = downloadUrls(release);
|
||||
return [
|
||||
{
|
||||
platform: "mac-silicon",
|
||||
label: "Mac",
|
||||
href: urls.macAppleSilicon,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "mac-intel",
|
||||
label: "Mac Intel",
|
||||
href: urls.macIntel,
|
||||
icon: AppleIcon,
|
||||
},
|
||||
{
|
||||
platform: "windows",
|
||||
label: "Windows",
|
||||
href: urls.windowsExeX64,
|
||||
icon: WindowsIcon,
|
||||
},
|
||||
{
|
||||
platform: "linux",
|
||||
label: "Linux",
|
||||
href: urls.linuxAppImage,
|
||||
icon: LinuxIcon,
|
||||
},
|
||||
];
|
||||
switch (platform) {
|
||||
case "windows":
|
||||
return { label: "Windows", href: urls.windowsExeX64, icon: WindowsIcon };
|
||||
case "linux":
|
||||
return { label: "Linux", href: urls.linuxAppImage, icon: LinuxIcon };
|
||||
case "mac":
|
||||
return { label: "Mac", href: urls.macAppleSilicon, icon: AppleIcon };
|
||||
}
|
||||
}
|
||||
|
||||
export function useDetectedPlatform(): Platform {
|
||||
const [platform, setPlatform] = React.useState<Platform>("mac-silicon");
|
||||
|
||||
React.useEffect(() => {
|
||||
const ua = navigator.userAgent.toLowerCase();
|
||||
if (ua.includes("win")) {
|
||||
setPlatform("windows");
|
||||
} else if (ua.includes("linux")) {
|
||||
setPlatform("linux");
|
||||
} else if (ua.includes("mac")) {
|
||||
// Check for Apple Silicon vs Intel
|
||||
// navigator.platform is deprecated but still the most reliable check
|
||||
const isARM =
|
||||
/arm|aarch64/i.test(navigator.platform) ||
|
||||
// Chrome/Edge on Apple Silicon report x86 platform but have ARM in userAgentData
|
||||
(navigator as unknown as { userAgentData?: { architecture?: string } }).userAgentData
|
||||
?.architecture === "arm";
|
||||
setPlatform(isARM ? "mac-silicon" : "mac-silicon"); // Default to Silicon for modern Macs
|
||||
}
|
||||
}, []);
|
||||
|
||||
return platform;
|
||||
}
|
||||
export const MOBILE_STORES: Record<MobilePlatform, PrimaryDownload> = {
|
||||
ios: { label: "iPhone", href: appStoreUrl, icon: AppleIcon },
|
||||
android: { label: "Android", href: playStoreUrl, icon: PlayStoreIcon },
|
||||
};
|
||||
|
||||
export function AppleIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectPlatform } from "./platform";
|
||||
|
||||
const AGENTS = {
|
||||
android:
|
||||
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Mobile Safari/537.36",
|
||||
iphone:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
windows:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
linux:
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
mac: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36",
|
||||
};
|
||||
|
||||
describe("detectPlatform", () => {
|
||||
it.each([
|
||||
[AGENTS.android, "android"],
|
||||
[AGENTS.iphone, "ios"],
|
||||
[AGENTS.windows, "windows"],
|
||||
[AGENTS.linux, "linux"],
|
||||
[AGENTS.mac, "mac"],
|
||||
])("resolves %s to %s", (userAgent, expected) => {
|
||||
expect(detectPlatform(userAgent)).toBe(expected);
|
||||
});
|
||||
|
||||
it("prefers the mobile OS over the kernel or vendor the same agent also claims", () => {
|
||||
// Android agents say "Linux" and iOS ones say "like Mac OS X", so a
|
||||
// desktop-first check hands a phone a .dmg or an AppImage.
|
||||
expect(detectPlatform(AGENTS.android)).not.toBe("linux");
|
||||
expect(detectPlatform(AGENTS.iphone)).not.toBe("mac");
|
||||
});
|
||||
|
||||
it("falls back to mac for an unknown user agent", () => {
|
||||
expect(detectPlatform("")).toBe("mac");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { getRequestHeader } from "@tanstack/react-start/server";
|
||||
|
||||
export type DesktopPlatform = "mac" | "windows" | "linux";
|
||||
export type MobilePlatform = "ios" | "android";
|
||||
export type VisitorPlatform = DesktopPlatform | MobilePlatform;
|
||||
|
||||
export function isMobilePlatform(platform: VisitorPlatform): platform is MobilePlatform {
|
||||
return platform === "ios" || platform === "android";
|
||||
}
|
||||
|
||||
/**
|
||||
* Order matters: Android user agents also say "Linux", and iOS ones also say
|
||||
* "like Mac OS X". iPadOS Safari sends a desktop Mac user agent with no mobile
|
||||
* marker at all, so an iPad is detected as a Mac — there is no server-side
|
||||
* signal that separates them.
|
||||
*/
|
||||
export function detectPlatform(userAgent: string): VisitorPlatform {
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (/android/.test(ua)) return "android";
|
||||
if (/iphone|ipad|ipod/.test(ua)) return "ios";
|
||||
if (/windows|win32|win64/.test(ua)) return "windows";
|
||||
if (/linux|x11|cros/.test(ua)) return "linux";
|
||||
return "mac";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolved during SSR so the first paint already shows the right call to
|
||||
* action. The worker renders every HTML response per request and sets no
|
||||
* cache-control, so varying the markup on the user agent is safe — if the
|
||||
* homepage ever gains an edge cache, it has to vary on `user-agent` too.
|
||||
*/
|
||||
export const getVisitorPlatform = createServerFn({ method: "GET" }).handler(
|
||||
(): VisitorPlatform => detectPlatform(getRequestHeader("user-agent") ?? ""),
|
||||
);
|
||||
@@ -2,6 +2,8 @@ import type { ReactNode } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import type { ReleaseChannels, ReleaseInfo } from "~/latest-release";
|
||||
import type { VisitorPlatform } from "~/platform";
|
||||
import { getVisitorPlatform } from "~/platform";
|
||||
import { getLatestRelease } from "~/release";
|
||||
import { getStarCount } from "~/stars";
|
||||
|
||||
@@ -19,6 +21,7 @@ const ReleaseCtx = createContext<ReleaseChannels>({
|
||||
beta: null,
|
||||
});
|
||||
const StarsCtx = createContext<StarsContext>({ stars: "" });
|
||||
const PlatformCtx = createContext<VisitorPlatform>("mac");
|
||||
|
||||
const PLAUSIBLE_INIT_SCRIPT = {
|
||||
__html: `window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()`,
|
||||
@@ -38,10 +41,19 @@ export function useStars(): StarsContext {
|
||||
return useContext(StarsCtx);
|
||||
}
|
||||
|
||||
/** The platform the visitor is browsing from, resolved from the request user agent during SSR. */
|
||||
export function useVisitorPlatform(): VisitorPlatform {
|
||||
return useContext(PlatformCtx);
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
loader: async () => {
|
||||
const [release, stars] = await Promise.all([getLatestRelease(), getStarCount()]);
|
||||
return { release, ...stars };
|
||||
const [release, stars, platform] = await Promise.all([
|
||||
getLatestRelease(),
|
||||
getStarCount(),
|
||||
getVisitorPlatform(),
|
||||
]);
|
||||
return { release, platform, ...stars };
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
@@ -68,9 +80,11 @@ function RootComponent() {
|
||||
return (
|
||||
<ReleaseCtx value={data.release}>
|
||||
<StarsCtx value={data}>
|
||||
<RootDocument>
|
||||
<Outlet />
|
||||
</RootDocument>
|
||||
<PlatformCtx value={data.platform}>
|
||||
<RootDocument>
|
||||
<Outlet />
|
||||
</RootDocument>
|
||||
</PlatformCtx>
|
||||
</StarsCtx>
|
||||
</ReleaseCtx>
|
||||
);
|
||||
|
||||
@@ -33,6 +33,32 @@ function docSlugFromMarkdownPath(pathname: string): string | null {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSR reads the request user agent to pick the download call to action (see
|
||||
* `~/platform`), so two visitors on the same URL get different markup. Nothing
|
||||
* caches these responses today — the zone cache sits behind the Worker and
|
||||
* Workers Cache is off — but an absent `cache-control` is not `no-store`: a
|
||||
* shared cache may assign heuristic freshness (RFC 9111 §4.2.2), and enabling
|
||||
* Workers Cache would store a headerless 200 for two hours under a key that
|
||||
* ignores `user-agent`. Either way one Android visitor could pin the Play Store
|
||||
* button for every Mac visitor after them.
|
||||
*
|
||||
* `private` is what actually stops that. `Vary` records the contract so a
|
||||
* compliant cache stays correct if the policy is ever loosened. Static assets
|
||||
* never reach this Worker, so their long-lived `_headers` rules are untouched.
|
||||
*/
|
||||
function withoutSharedCaching(response: Response): Response {
|
||||
const result = new Response(response.body, response);
|
||||
result.headers.set("cache-control", "private, no-store");
|
||||
result.headers.set("vary", "user-agent");
|
||||
return result;
|
||||
}
|
||||
|
||||
function variesByUserAgent(pathname: string, response: Response): boolean {
|
||||
if (pathname.startsWith("/_serverFn/")) return true;
|
||||
return response.headers.get("content-type")?.includes("text/html") ?? false;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
@@ -73,6 +99,7 @@ export default {
|
||||
return markdownResponse(doc.content);
|
||||
}
|
||||
|
||||
return startEntry.fetch(request);
|
||||
const response = await startEntry.fetch(request);
|
||||
return variesByUserAgent(url.pathname, response) ? withoutSharedCaching(response) : response;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user