Skip to content
Merged
61 changes: 37 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,30 +97,43 @@ Images are published only when a GitHub Release is published (semver tags; prere

## ⚡ Caching

The site is server-rendered and designed to sit behind a CDN (Cloudflare). Cacheable responses
emit `Cache-Control: public, max-age=0, s-maxage=<n>, stale-while-revalidate=<n>`:

- `max-age=0` — browsers always revalidate, so users never see stale data without a check (that
check hits the warm edge cache and returns instantly).
- `s-maxage` — the shared/CDN edge TTL. Cloudflare uses this for its edge cache TTL.
- `stale-while-revalidate` — the edge serves stale content instantly while it refreshes in the background.

Per-route TTLs live in [`src/lib/cache.ts`](src/lib/cache.ts) and are applied via `applyCdnCache()`:

| Route | Fresh (`s-maxage`) | Stale (`swr`) |
| :------------------ | :----------------- | :------------ |
| `/browse` | 60s | 5min |
| `/device/:id` | 5min | 1h |
| `/device/:id` (404) | 30s | 1min |
| `/api/suggest` | 30s | 2min |

The same TTLs also drive Astro's in-process response cache (`Astro.cache.set`), which shields the
upstream API even on edge cache misses. Fingerprinted assets under `/_astro/` are served
`immutable` for a year.

> **Cloudflare:** by default Cloudflare does **not** cache HTML/JSON — only static file extensions.
> To benefit from `s-maxage` on these routes, add a Cache Rule that makes them eligible for cache
> ("Cache Everything"); Cloudflare then honors the origin `s-maxage` for the edge TTL.
The site is server-rendered and meant to sit behind a CDN (Cloudflare in production, Netlify for
deploy previews). The server doesn't cache anything itself, it only sends headers for the CDN.

Pages call `Astro.cache.set()` and [`src/io/cache-provider.ts`](src/io/cache-provider.ts) turns
that into:

- `Cache-Control: public, max-age=<n>`, the TTL for browsers and the CDN alike. The CDN adds
`Age` on the way through, so browsers count from the original render. It's `no-store` when
there's no TTL. We don't use `s-maxage`, because Cloudflare turns off stale-while-revalidate
when it's present.
- `Cache-Tag`, `Last-Modified` and `ETag` when the page provides them.

| Route | TTL |
| :------------------- | :----------------------- |
| `/` | from the API's `max-age` |
| `/browse` | from the API's `max-age` |
| `/devices/:id` | from the API's `max-age` |
| `/devices/:id` (404) | 60s |
| `/surveys` | 1 day |

Pages that don't call `Astro.cache.set()` get no cache headers. Fingerprinted assets under
`/_astro/` are served `immutable` for a year.

SSR requests with any cookie (including feature flags and login cookies), an `Authorization`
header, or `?feature=` always get `Cache-Control: private, no-store`. Responses that set or
delete cookies do too.
Middleware disables Astro's cache headers for these responses so page TTLs cannot override this.
Netlify varies cached pages by query string, `Cookie`, and `Authorization` to keep these requests
away from anonymous cache entries.

> **Cloudflare** doesn't cache HTML by default and doesn't key on cookies, so it needs Cache Rules:
>
> - Bypass the cache when any cookie, an `Authorization` header, or a `feature` query parameter is
> present. Origin headers can't stop Cloudflare from serving a copy it already has.
> - Otherwise make HTML eligible for cache, use the origin's cache headers, and bypass when there
> are none.
> - Keep the query string in the cache key.

## 👀 Want to learn more?

Expand Down
8 changes: 6 additions & 2 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import netlify from "@astrojs/netlify";
import node from "@astrojs/node";
import { paraglideVitePlugin } from "@inlang/paraglide-js";
import { defineConfig, envField, memoryCache } from "astro/config";
import { defineConfig, envField } from "astro/config";

// Netlify sets NETLIFY=true in its builds. Everywhere else (dev, Docker) we
// run the standalone Node server.
Expand Down Expand Up @@ -113,6 +113,10 @@ export default defineConfig({
],
},
cache: {
provider: memoryCache(),
// only emits cache headers, caching itself is done by the cdn in front
provider: {
name: "cdn-headers",
entrypoint: new URL("./src/io/cache-provider.ts", import.meta.url),
},
},
});
5 changes: 5 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ export default tseslint.config(
{
rules: {
"@eslint-community/eslint-comments/require-description": "error",
// feature names are `never` while no feature is registered
"@typescript-eslint/restrict-template-expressions": [
"error",
{ allowNever: true },
],
},
},
configs["flat/recommended"],
Expand Down
2 changes: 0 additions & 2 deletions src/components/SearchBox.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import { getDimensions } from "../io/dimension";
import { m } from "../paraglide/messages.js";
import { localizeHref } from "../paraglide/runtime.js";
import { cachePolicy } from "../utilities/cache";
import { generic, render } from "../utilities/presentation";
import { PresentationRenderPresetRoleIcon } from "../utilities/presentation/preset";

Expand All @@ -23,7 +22,6 @@ let dimensions;
try {
const result = await getDimensions({}, AbortSignal.timeout(3_000));
dimensions = result.dimensions;
Astro.cache.set({ ...cachePolicy(result.caching), tags: ["dimensions"] });
} catch {
dimensions = null;
}
Expand Down
38 changes: 38 additions & 0 deletions src/io/cache-provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {
buildCacheControlDirectives,
setConditionalHeaders,
} from "astro/cache/provider-utils";
import type { CacheProviderFactory } from "astro";

/** emits cache headers for whatever cdn sits in front, stores nothing itself */
const cacheProvider: CacheProviderFactory = () => ({
name: "cdn-headers",
setHeaders: (options) => {
const headers = new Headers();

// the same ttl for browsers and the cdn, which adds `Age` on the way through
const directives =
typeof options.maxAge !== "undefined" && options.maxAge > 0
? buildCacheControlDirectives(options)
: undefined;
if (typeof directives === "undefined") {
headers.set("Cache-Control", "no-store");
return headers;
}

headers.set("Cache-Control", `public, ${directives}`);
// Cookie/auth requests must miss anonymous entries, middleware marks them no-store.
headers.set("Netlify-Vary", "query, header=Cookie|Authorization");
if (typeof options.tags !== "undefined" && options.tags.length > 0) {
headers.set("Cache-Tag", options.tags.join(","));
}
setConditionalHeaders(headers, options);

return headers;
},
// purging is left to the cdn
invalidate: async () => {},
});

// eslint-disable-next-line no-restricted-exports -- astro loads cache providers through their default export
export default cacheProvider;
1 change: 1 addition & 0 deletions src/islands/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ import "./filter-sheet.js";
import "./version-history.js";
import "./price-watch-links.js";
import "./device-search.js";
import "./feature.js";
201 changes: 201 additions & 0 deletions src/islands/debug-menu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { html, LitElement, nothing } from "lit";
import { state } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import type { Ref } from "lit/directives/ref.js";

import { FEATURE_NAMES, FEATURES } from "../types/feature.js";
import { defineElementOnce } from "../utilities/define-element.js";
import {
FEATURE_EVENT,
featureEnable,
featureParamUrl,
features,
featuresReset,
} from "../utilities/feature.js";
import { isAbortError } from "../utilities/is-abort-error.js";
import type { FeatureName, FeatureState } from "../types/feature.js";

/** how long the share popover stays up */
const SHARE_FEEDBACK_MS = 2000;

type ShareResult = "copied" | "failed";

const SHARE_MESSAGE: Record<ShareResult, string> = {
copied: "Link copied",
failed: "Could not copy",
};

export class DebugMenu extends LitElement {
@state() private _features: FeatureState = features();
/** outcome of the last share, said by the popover */
@state() private _shareResult: ShareResult | undefined;

private _shareTimer: number | undefined;
private _shareButton: Ref<HTMLButtonElement> = createRef();
private _feedback: Ref<HTMLElement> = createRef();

private _onFeatures = (): void => {
this._features = features();
};

// opt-out of shadow dom to be able to reference global css classes
protected override createRenderRoot(): HTMLElement {
return this;
}

override connectedCallback(): void {
super.connectedCallback();
window.addEventListener(FEATURE_EVENT, this._onFeatures);
this._onFeatures();
}

override disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(FEATURE_EVENT, this._onFeatures);
window.clearTimeout(this._shareTimer);
this._shareTimer = undefined;
}

/** the names the cookie carries */
private get _enabled(): FeatureName[] {
return FEATURE_NAMES.filter((name) => this._features[name]);
}

private _toggle(name: FeatureName): void {
this._features = featureEnable(name, !this._features[name]);
}

private _reset = (): void => {
this._features = featuresReset();
};

private _share = (): void => {
void this._shareLink(featureParamUrl(window.location.href, this._enabled));
};

private async _shareLink(url: string): Promise<void> {
if (typeof navigator.share === "function") {
try {
await navigator.share({ url });
return;
} catch (error) {
if (isAbortError(error)) {
return;
}
}
}

try {
await navigator.clipboard.writeText(url);
await this._report("copied");
} catch {
await this._report("failed");
}
}

private async _report(result: ShareResult): Promise<void> {
this._shareResult = result;
await this.updateComplete;

const popover = this._feedback.value;
if (typeof popover === "undefined") {
return;
}

window.clearTimeout(this._shareTimer);
if (!popover.matches(":popover-open")) {
const source = this._shareButton.value;
popover.showPopover(
typeof source !== "undefined" ? { source } : undefined,
);
}

this._shareTimer = window.setTimeout(() => {
this._shareTimer = undefined;

const showing = this._feedback.value;
if (showing?.matches(":popover-open") === true) {
showing.hidePopover();
}
}, SHARE_FEEDBACK_MS);
}

private _renderRow(name: FeatureName) {
const { label, description } = FEATURES[name];

return html`
<label class="debug-row">
<span class="debug-row-main">
<span class="debug-row-label">${label}</span>
<span class="debug-row-description">${description}</span>
<code class="debug-row-name">${name}</code>
</span>
<input
class="debug-switch"
type="checkbox"
role="switch"
.checked=${this._features[name]}
@change=${() => this._toggle(name)}
/>
</label>
`;
}

override render() {
const enabled = this._enabled;
// link that carries enablement state for sharing
const link = featureParamUrl(window.location.href, enabled);

return html`
<div class="debug-panel">
<div class="debug-body">
${
FEATURE_NAMES.length > 0
? FEATURE_NAMES.map((name) => this._renderRow(name))
: html`<p class="debug-empty">No features registered.</p>`
}
</div>

<footer class="debug-foot">
<button
type="button"
class="debug-action debug-share"
title=${link}
@click=${this._share}
${ref(this._shareButton)}
>
Share
</button>
<div
class="debug-feedback"
popover="manual"
role="status"
${ref(this._feedback)}
>
${
typeof this._shareResult === "undefined"
? nothing
: SHARE_MESSAGE[this._shareResult]
}
</div>
<button
type="button"
class="debug-action"
?disabled=${enabled.length === 0}
@click=${this._reset}
>
Reset
</button>
</footer>
</div>
`;
}
}

defineElementOnce("debug-menu", DebugMenu);

declare global {
interface HTMLElementTagNameMap {
"debug-menu": DebugMenu;
}
}
26 changes: 26 additions & 0 deletions src/islands/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {
FEATURE_PARAM,
featureDatasetApply,
featureParamEnabled,
featuresSet,
} from "../utilities/feature.js";

/** sources and removes enablement overrides from url and adds `<html data-feature-*>` for prerendered pages */
const apply = (): void => {
const url = new URL(window.location.href);
const override = featureParamEnabled(url.searchParams);
if (typeof override !== "undefined") {
featuresSet(override);

// removes enablement overrides
url.searchParams.delete(FEATURE_PARAM);
window.history.replaceState(window.history.state, "", url);
return;
}

featureDatasetApply();
};

apply();

document.addEventListener("astro:after-swap", apply);
Loading
Loading