diff --git a/README.md b/README.md index 3f91ad3..77b80d3 100644 --- a/README.md +++ b/README.md @@ -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=, stale-while-revalidate=`: - -- `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=`, 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? diff --git a/astro.config.mjs b/astro.config.mjs index 6e7c09e..98652ec 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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. @@ -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), + }, }, }); diff --git a/eslint.config.mjs b/eslint.config.mjs index 70b02c8..86ba9e1 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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"], diff --git a/src/components/SearchBox.astro b/src/components/SearchBox.astro index e4dae10..eb8b28f 100644 --- a/src/components/SearchBox.astro +++ b/src/components/SearchBox.astro @@ -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"; @@ -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; } diff --git a/src/io/cache-provider.ts b/src/io/cache-provider.ts new file mode 100644 index 0000000..490fcd5 --- /dev/null +++ b/src/io/cache-provider.ts @@ -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; diff --git a/src/islands/client.ts b/src/islands/client.ts index 3562925..7dd1687 100644 --- a/src/islands/client.ts +++ b/src/islands/client.ts @@ -3,3 +3,4 @@ import "./filter-sheet.js"; import "./version-history.js"; import "./price-watch-links.js"; import "./device-search.js"; +import "./feature.js"; diff --git a/src/islands/debug-menu.ts b/src/islands/debug-menu.ts new file mode 100644 index 0000000..f83fdce --- /dev/null +++ b/src/islands/debug-menu.ts @@ -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 = { + 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 = createRef(); + private _feedback: Ref = 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 { + 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 { + 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` + + `; + } + + override render() { + const enabled = this._enabled; + // link that carries enablement state for sharing + const link = featureParamUrl(window.location.href, enabled); + + return html` +
+
+ ${ + FEATURE_NAMES.length > 0 + ? FEATURE_NAMES.map((name) => this._renderRow(name)) + : html`

No features registered.

` + } +
+ +
+ +
+ ${ + typeof this._shareResult === "undefined" + ? nothing + : SHARE_MESSAGE[this._shareResult] + } +
+ +
+
+ `; + } +} + +defineElementOnce("debug-menu", DebugMenu); + +declare global { + interface HTMLElementTagNameMap { + "debug-menu": DebugMenu; + } +} diff --git a/src/islands/feature.ts b/src/islands/feature.ts new file mode 100644 index 0000000..45cbe75 --- /dev/null +++ b/src/islands/feature.ts @@ -0,0 +1,26 @@ +import { + FEATURE_PARAM, + featureDatasetApply, + featureParamEnabled, + featuresSet, +} from "../utilities/feature.js"; + +/** sources and removes enablement overrides from url and adds `` 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); diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index fabfca3..00a1354 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -9,6 +9,7 @@ import PlausibleAnalytics from "../components/PlausibleAnalytics.astro"; import { m } from "../paraglide/messages.js"; import { getLocale } from "../paraglide/runtime.js"; import globalCss from "../styles/global.css?url"; +import { featureDataset, features } from "../utilities/feature"; interface Props { title?: string; @@ -17,9 +18,11 @@ interface Props { } const { title, page = "page", back } = Astro.props; + +const featureAttributes = featureDataset(features()); --- - + diff --git a/src/middleware.ts b/src/middleware.ts index c17b13c..1a19e34 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,8 +4,11 @@ import { defineMiddleware, sequence } from "astro:middleware"; // middleware isn't shipped to clients, therefor perform side-effect import of caching setup here import "./io/http-cache.ts"; +import { cache } from "./middleware/cache"; import { paraglideMiddleware } from "./paraglide/server.js"; +import { featureParamApply } from "./utilities/feature"; import { localeFromParam } from "./utilities/locale"; +import { scopeFeature } from "./utilities/scope.ts"; // Astro routes `src/pages/[...locale]/`, Paraglide resolves the locale for `getLocale()` const paraglide = defineMiddleware((context, next) => { @@ -26,6 +29,17 @@ const paraglide = defineMiddleware((context, next) => { return paraglideMiddleware(request, () => next()); }); +// applies enablement from `?feature=` query parameters and provides render context +const feature = defineMiddleware(async (context, next) => { + if (context.isPrerendered) { + return next(); + } + + featureParamApply(context); + + return scopeFeature.run(context.cookies, next); +}); + const noindex = defineMiddleware(async (_, next) => { const response = await next(); @@ -36,4 +50,4 @@ const noindex = defineMiddleware(async (_, next) => { return response; }); -export const onRequest = sequence(paraglide, noindex); +export const onRequest = sequence(cache, feature, paraglide, noindex); diff --git a/src/middleware/cache.ts b/src/middleware/cache.ts new file mode 100644 index 0000000..e7f1fc7 --- /dev/null +++ b/src/middleware/cache.ts @@ -0,0 +1,29 @@ +import { defineMiddleware } from "astro:middleware"; + +import { FEATURE_PARAM } from "../utilities/feature"; + +export const cache = defineMiddleware(async (context, next) => { + if (context.isPrerendered) { + return next(); + } + + // Capture incoming state before downstream middleware changes cookies. + const personalized = + context.request.headers.has("cookie") || + context.request.headers.has("authorization") || + context.url.searchParams.has(FEATURE_PARAM); + + const response = await next(); + + if ( + personalized || + response.headers.has("set-cookie") || + context.cookies.headers().next().done !== true + ) { + // Astro applies the provider's headers after middleware returns. + context.cache.set(false); + response.headers.set("Cache-Control", "private, no-store"); + } + + return response; +}); diff --git a/src/pages/debug/index.astro b/src/pages/debug/index.astro new file mode 100644 index 0000000..2a1939c --- /dev/null +++ b/src/pages/debug/index.astro @@ -0,0 +1,25 @@ +--- +import Layout from "../../layouts/Layout.astro"; +--- + + + + +
+
+

Debug

+

+ Features that are deployed but not on by default. Changes apply to pages + you open next. +

+
+ +
+ +
+
+
+ + diff --git a/src/styles/debug-menu.css b/src/styles/debug-menu.css new file mode 100644 index 0000000..a0e6677 --- /dev/null +++ b/src/styles/debug-menu.css @@ -0,0 +1,129 @@ +.debug-panel { + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border); + border-radius: var(--radius-4); + overflow: hidden; +} + +.debug-body { + padding: var(--space-2) var(--space-5) var(--space-4); +} + +.debug-row { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) 0; + border-top: 1px solid var(--border-subtle); + cursor: pointer; +} +.debug-row:first-child { + border-top: 0; +} +.debug-row-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.debug-row-label { + font-size: var(--fs-14); + font-weight: var(--fw-medium); +} +.debug-empty { + margin: var(--space-4) 0; + font-size: var(--fs-13); + color: var(--fg-subtle); +} +.debug-row-description { + font-size: var(--fs-13); + line-height: var(--lh-snug); + color: var(--fg-muted); +} +.debug-row-name { + font-family: var(--font-mono); + font-size: var(--fs-12); + color: var(--fg-subtle); +} + +.debug-switch { + flex-shrink: 0; + appearance: none; + -webkit-appearance: none; + margin: 0; + width: 40px; + height: 24px; + border-radius: var(--radius-pill); + border: 1px solid var(--border-strong); + background: var(--surface-tint-mid); + cursor: pointer; + position: relative; + transition: background var(--transition-fast); +} +.debug-switch::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--surface); + box-shadow: var(--shadow-1); + transition: transform var(--transition-fast); +} +.debug-switch:checked { + background: var(--primary); + border-color: var(--primary); +} +.debug-switch:checked::after { + transform: translateX(16px); + background: var(--fg-on-primary); +} +.debug-switch:focus-visible { + outline: 0; + box-shadow: var(--ring-focus); +} + +.debug-foot { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--space-2); + padding: var(--space-3) var(--space-5); + border-top: 1px solid var(--border); + background: var(--surface-sunken); +} +/* keeps reset hard right */ +.debug-share { + margin-right: auto; +} +.debug-feedback { + position-area: top; + margin-bottom: var(--space-2); + padding: var(--space-2) var(--space-3); + background: var(--surface); + color: var(--fg); + border: 1px solid var(--border-strong); + border-radius: var(--radius-3); + box-shadow: var(--shadow-3); + font-size: var(--fs-13); + font-weight: var(--fw-medium); +} +.debug-action { + font: inherit; + font-size: var(--fs-13); + font-weight: var(--fw-medium); + padding: 6px var(--space-3); + border: 1px solid var(--border-strong); + border-radius: var(--radius-3); + background: var(--surface); + color: var(--fg); + cursor: pointer; +} +.debug-action:disabled { + color: var(--fg-disabled); + cursor: default; +} diff --git a/src/styles/global.css b/src/styles/global.css index c55a4ec..de4e3bb 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -18,6 +18,7 @@ @import "./sheet.css"; @import "./loading.css"; @import "./redaction.css"; +@import "./debug-menu.css"; @import "./edit-field.css"; @import "./edit-field-view.css"; @import "./edit-form.css"; diff --git a/src/types/feature.ts b/src/types/feature.ts new file mode 100644 index 0000000..d927239 --- /dev/null +++ b/src/types/feature.ts @@ -0,0 +1,16 @@ +import { keysOf } from "../utilities/record"; + +export type FeatureDefinition = { + label: string; + description: string; +}; + +export const FEATURES = {} as const satisfies Record; + +export type FeatureName = keyof typeof FEATURES; +export type FeatureState = Record; + +export const isFeatureName = (value: unknown): value is FeatureName => + typeof value === "string" && Object.hasOwn(FEATURES, value); + +export const FEATURE_NAMES: readonly FeatureName[] = keysOf(FEATURES); diff --git a/src/utilities/cache.ts b/src/utilities/cache.ts index 898a01d..045ef53 100644 --- a/src/utilities/cache.ts +++ b/src/utilities/cache.ts @@ -1,8 +1,8 @@ import type { IoHeadersCaching } from "../io"; /** - * Turns the API caching headers into `Astro.cache.set()` options. They only - * drive Astro's in-process cache, the response has no `Cache-Control` header. + * Turns the API caching headers into `Astro.cache.set()` options, which + * `src/io/cache-provider.ts` turns into response headers for the CDN. */ export const cachePolicy = (headers: IoHeadersCaching) => ({ // exact optional property buffoonery diff --git a/src/utilities/feature.ts b/src/utilities/feature.ts new file mode 100644 index 0000000..0c8cda5 --- /dev/null +++ b/src/utilities/feature.ts @@ -0,0 +1,284 @@ +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- false positive, referenced in comment +import type { AsyncLocalStorage } from "node:async_hooks"; + +import { FEATURE_NAMES, isFeatureName } from "../types/feature"; +import { recordOf } from "./record"; +import type { FeatureName, FeatureState } from "../types/feature"; + +export const FEATURE_COOKIE = "device-database.features"; + +/** spells the enabled set out in a url, so a state can be linked to */ +export const FEATURE_PARAM = "feature"; + +const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; +const COOKIE_PATH = "/"; + +/** fired on `window` after a flag changed, so client features can re-render */ +export const FEATURE_EVENT = "device-database:features"; + +const SEPARATOR = "|"; + +export type FeatureCookieJar = { + get: (key: string) => { value: string } | undefined; +}; + +/** attributes of the cookie */ +type FeatureCookieOptions = { + path: string; + maxAge: number; + sameSite: "lax"; + secure: boolean; +}; + +export type FeatureCookieJarWritable = FeatureCookieJar & { + set: (key: string, value: string, options: FeatureCookieOptions) => void; + delete: (key: string, options: { path: string }) => void; +}; + +/** also needs the url because `secure` has to follow the scheme */ +export type FeatureTarget = { + cookies: FeatureCookieJarWritable; + url: { protocol: string }; +}; + +export type FeatureRequest = { + cookies: FeatureCookieJarWritable; + url: URL; +}; + +/** + * astro global is only available in `.astro` files and middlewares. + * page rendering happens in middleware callback chain + * → introduce global thunk that is set with {@link featureRenderInstall} in ssr context + * + * thunk is eventually populated with help of {@link AsyncLocalStorage} + */ +let renderer: (() => FeatureCookieJar | undefined) | undefined; +/** allows setting {@link renderer} */ +export const featureRenderInstall = ( + resolve: () => FeatureCookieJar | undefined, +) => { + renderer = resolve; +}; + +const fromDocument = (): string | undefined => { + if (typeof document === "undefined") { + return undefined; + } + + for (const pair of document.cookie.split(";")) { + const separator = pair.indexOf("="); + if ( + separator === -1 || + pair.slice(0, separator).trim() !== FEATURE_COOKIE + ) { + continue; + } + + const raw = pair.slice(separator + 1).trim(); + try { + return decodeURIComponent(raw); + } catch { + // a hand-edited cookie can be invalid percent-encoding + return raw; + } + } + + return undefined; +}; + +const cookieValue = (jar?: FeatureCookieJar): string | undefined => + typeof jar === "undefined" ? fromDocument() : jar.get(FEATURE_COOKIE)?.value; + +/** unknown names are dropped, so a cookie outliving a removed flag is harmless */ +export const featureParseEnabled = (value: string): Set => { + const enabled: Set = new Set(); + for (const name of value.split(SEPARATOR)) { + if (!isFeatureName(name)) { + continue; + } + + enabled.add(name); + } + + return enabled; +}; + +export const featureSerializeEnabled = (enabled: Set) => + [...enabled].join(SEPARATOR); + +const featureStateOf = (enabled: Set): FeatureState => + recordOf(FEATURE_NAMES, (name) => enabled.has(name)); + +/** what the cookie carries, empty when it is absent or unreadable */ +const enabledOf = (jar?: FeatureCookieJar): Set => { + const value = cookieValue(jar); + + return typeof value !== "undefined" ? featureParseEnabled(value) : new Set(); +}; + +/** `data-feature-*` attributes for css queries */ +export const featureDataset = (state: FeatureState): Record => + Object.fromEntries( + FEATURE_NAMES.map( + (name) => [`data-feature-${name}`, state[name] ? "on" : "off"] as const, + ), + ); + +/** applies data-feature-*` attributes for css queries in csr context */ +export const featureDatasetApply = (state: FeatureState = features()) => { + for (const [attribute, value] of Object.entries(featureDataset(state))) { + document.documentElement.setAttribute(attribute, value); + } +}; + +const cookieOptions = (url: { protocol: string }): FeatureCookieOptions => ({ + path: COOKIE_PATH, + maxAge: COOKIE_MAX_AGE_SECONDS, + sameSite: "lax", + secure: url.protocol === "https:", +}); + +const serializeCookie = ( + value: string, + options: FeatureCookieOptions, +): string => + [ + `${FEATURE_COOKIE}=${encodeURIComponent(value)}`, + `path=${options.path}`, + `Max-Age=${options.maxAge}`, + `SameSite=${options.sameSite}`, + ...(options.secure ? ["Secure"] : []), + ].join("; "); + +const writeCookie = (value: string, target?: FeatureTarget): void => { + if (typeof target !== "undefined") { + if (value.length === 0) { + target.cookies.delete(FEATURE_COOKIE, { path: COOKIE_PATH }); + } else { + target.cookies.set(FEATURE_COOKIE, value, cookieOptions(target.url)); + } + + return; + } + + if (typeof document === "undefined") { + throw new Error("cookie jar needed in ssr context"); + } + + const options = cookieOptions(window.location); + document.cookie = serializeCookie( + value, + value.length === 0 ? { ...options, maxAge: 0 } : options, + ); +}; + +const announce = (state: FeatureState): FeatureState => { + if (typeof document === "undefined") { + return state; + } + + featureDatasetApply(state); + + window.dispatchEvent( + new CustomEvent(FEATURE_EVENT, { detail: state }), + ); + + return state; +}; + +/** persists the whole set and announces it */ +export const featuresSet = ( + enabled: Set, + target?: FeatureTarget, +): FeatureState => { + writeCookie(featureSerializeEnabled(enabled), target); + + return announce(featureStateOf(enabled)); +}; + +/** persists enablement and announces it */ +export const featureEnable = ( + name: FeatureName, + enabled: boolean, + target?: FeatureTarget, +): FeatureState => { + const next = enabledOf(target?.cookies); + + if (enabled) { + next.add(name); + } else { + next.delete(name); + } + + return featuresSet(next, target); +}; + +/** drops the cookie, turning every feature off */ +export const featuresReset = (target?: FeatureTarget): FeatureState => + featuresSet(new Set(), target); + +/** what an enablement url asks for */ +export const featureParamEnabled = ( + params: URLSearchParams, +): Set | undefined => { + if (!params.has(FEATURE_PARAM)) { + return undefined; + } + + const enabled: Set = new Set(); + for (const name of params.getAll(FEATURE_PARAM)) { + if (!isFeatureName(name)) { + continue; + } + + enabled.add(name); + } + + return enabled; +}; + +/** all enabled features */ +export const features = (jar?: FeatureCookieJar): FeatureState => + featureStateOf(enabledOf(jar ?? renderer?.())); + +/** enablement state of single feature */ +export const feature = (name: FeatureName, jar?: FeatureCookieJar): boolean => + features(jar)[name]; + +/** builds enablement url */ +export const featureParamUrl = ( + url: URL | string, + enabled: Iterable, +): string => { + const next = new URL(url); + next.searchParams.delete(FEATURE_PARAM); + + const names = [...enabled]; + for (const name of names) { + next.searchParams.append(FEATURE_PARAM, name); + } + + if (names.length === 0) { + // empty feature parameter indicates that all features should be disabled + next.searchParams.append(FEATURE_PARAM, ""); + } + + return next.toString(); +}; + +/** applies feature override requested in `?feature=` enablement url */ +export const featureParamApply = (request: FeatureRequest): void => { + const asked = featureParamEnabled(request.url.searchParams); + if (typeof asked === "undefined") { + return; + } + + featuresSet(asked, request); +}; + +declare global { + interface WindowEventMap { + [FEATURE_EVENT]: CustomEvent; + } +} diff --git a/src/utilities/record.ts b/src/utilities/record.ts new file mode 100644 index 0000000..2f75a7c --- /dev/null +++ b/src/utilities/record.ts @@ -0,0 +1,10 @@ +/** `Object.keys` that keeps the key type of a closed record */ +export const keysOf = (record: Record): K[] => + Object.keys(record) as K[]; + +/** `Object.fromEntries` that keeps the key type */ +export const recordOf = ( + keys: readonly K[], + value: (key: K) => V, +): Record => + Object.fromEntries(keys.map((key) => [key, value(key)])) as Record; diff --git a/src/utilities/scope.ts b/src/utilities/scope.ts new file mode 100644 index 0000000..f87db58 --- /dev/null +++ b/src/utilities/scope.ts @@ -0,0 +1,8 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { featureRenderInstall } from "./feature"; +import type { FeatureCookieJar } from "./feature"; + +export const scopeFeature: AsyncLocalStorage = + new AsyncLocalStorage(); +featureRenderInstall(() => scopeFeature.getStore()); diff --git a/test/cache-provider.test.ts b/test/cache-provider.test.ts new file mode 100644 index 0000000..ced9dbe --- /dev/null +++ b/test/cache-provider.test.ts @@ -0,0 +1,192 @@ +import { createContext } from "astro/middleware"; +import { describe, expect, test, vi } from "vitest"; +import type { APIContext, CacheOptions } from "astro"; + +import { + applyCacheHeaders, + AstroCache, +} from "../node_modules/astro/dist/core/cache/runtime/cache.js"; +import cacheProvider from "../src/io/cache-provider"; +import { cache } from "../src/middleware/cache"; +import { FEATURE_COOKIE, featureParamApply } from "../src/utilities/feature"; + +vi.mock("astro:middleware", () => import("astro/middleware")); + +const render = async ({ + headers, + query = "", + options = { maxAge: 60 }, + page = () => new Response("page"), +}: { + headers?: HeadersInit; + query?: string; + options?: CacheOptions | false; + page?: (context: APIContext) => Response; +} = {}) => { + const request = new Request(`https://example.com/surveys${query}`, { + ...(headers ? { headers } : {}), + }); + const context = createContext({ request, defaultLocale: "en" }); + context.cache = new AstroCache(cacheProvider(undefined)); + const response = await cache(context, () => { + context.cache.set(options); + return Promise.resolve(page(context)); + }); + if (!(response instanceof Response)) { + throw new Error("Cache middleware must return a response"); + } + // Exercise Astro's real post-middleware header application. + applyCacheHeaders(context.cache, response, request); + return { context, response }; +}; + +describe("CDN cache headers", () => { + test("preserves anonymous TTLs, validators, tags and preview cache keys", async (t) => { + const { response } = await render({ + options: { + maxAge: 60, + swr: 30, + tags: ["surveys"], + etag: '"version"', + lastModified: new Date("2026-01-01T00:00:00Z"), + }, + }); + t.expect(response.headers.get("cache-control")).toBe( + "public, max-age=60, stale-while-revalidate=30", + ); + t.expect(response.headers.get("netlify-vary")).toBe( + "query, header=Cookie|Authorization", + ); + t.expect(response.headers.get("cache-tag")).toBe("surveys"); + t.expect(response.headers.get("etag")).toBe('"version"'); + t.expect(response.headers.get("last-modified")).toBe( + "Thu, 01 Jan 2026 00:00:00 GMT", + ); + }); + + test.each([undefined, 0, -1])( + "does not cache a TTL of %s", + async (maxAge) => { + const { response } = await render({ + options: { + tags: ["surveys"], + ...(maxAge === undefined ? {} : { maxAge }), + }, + }); + expect(response.headers.get("cache-control")).toBe("no-store"); + }, + ); + + test("leaves anonymous pages without cache options unchanged", async (t) => { + const { response } = await render({ options: {} }); + t.expect(response.headers.has("cache-control")).toBe(false); + }); +}); + +describe("cache bypass middleware", () => { + test.each([ + { headers: { Cookie: `${FEATURE_COOKIE}=example` } }, + { headers: { Cookie: `${FEATURE_COOKIE}=` } }, + { headers: { Cookie: "github_oauth=example" } }, + { headers: { Cookie: "unknown_login_cookie=example" } }, + { headers: { Cookie: "" } }, + { headers: { Authorization: "Bearer example" } }, + { headers: { Authorization: "" } }, + { query: "?feature=example" }, + { query: "?feature=" }, + ])("bypasses personalized requests: %j", async (input) => { + for (const options of [{ maxAge: 60 }, {}]) { + const { response } = await render({ ...input, options }); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + } + }); + + test.each([undefined, "public, max-age=60", "no-cache"])( + "bypasses raw Set-Cookie regardless of Cache-Control: %s", + async (cacheControl) => { + const { response } = await render({ + page: () => + new Response(null, { + status: 302, + headers: { + "Set-Cookie": "login=example; Path=/; HttpOnly", + Location: "/", + ...(cacheControl ? { "Cache-Control": cacheControl } : {}), + }, + }), + }); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect(response.status).toBe(302); + expect(response.headers.has("set-cookie")).toBe(true); + }, + ); + + test.each(["set", "delete"] as const)( + "bypasses buffered cookies.%s without consuming outgoing cookies", + async (operation) => { + const { context, response } = await render({ + page: (context) => { + if (operation === "set") { + context.cookies.set("login", "example", { path: "/" }); + } else { + context.cookies.delete("login", { path: "/" }); + } + context.cache.set({ maxAge: 3600 }); + return new Response("page"); + }, + }); + expect(response.headers.get("cache-control")).toBe("private, no-store"); + expect([...context.cookies.headers()]).toHaveLength(1); + expect([...context.cookies.headers()][0]).toContain("login="); + }, + ); + + test("feature query changes retain outgoing cookies and no-store", async (t) => { + const { context, response } = await render({ + query: "?feature=", + page: (context) => { + featureParamApply(context); + return new Response("page"); + }, + }); + t.expect(response.headers.get("cache-control")).toBe("private, no-store"); + t.expect([...context.cookies.headers()][0]).toContain(`${FEATURE_COOKIE}=`); + }); + + test("does not treat reading cookies as setting them", async (t) => { + const { response } = await render({ + page: (context) => { + context.cookies.get("login"); + return new Response("page"); + }, + }); + t.expect(response.headers.get("cache-control")).toBe("public, max-age=60"); + }); + + test("skips prerender request headers", async (t) => { + const request = new Request("https://example.com/about"); + const context = createContext({ request, defaultLocale: "en" }); + context.isPrerendered = true; + Object.defineProperty(request, "headers", { + get() { + throw new Error("Prerender headers must not be read"); + }, + }); + const response = new Response("static"); + t.expect(await cache(context, () => Promise.resolve(response))).toBe( + response, + ); + }); + + test("propagates downstream errors", async (t) => { + await t + .expect( + render({ + page: () => { + throw new Error("render failed"); + }, + }), + ) + .rejects.toThrow("render failed"); + }); +}); diff --git a/test/feature.test.ts b/test/feature.test.ts new file mode 100644 index 0000000..21217df --- /dev/null +++ b/test/feature.test.ts @@ -0,0 +1,396 @@ +import { describe, test, vi } from "vitest"; + +import { FEATURES } from "../src/types/feature"; +import { + feature, + FEATURE_COOKIE, + FEATURE_PARAM, + featureDataset, + featureEnable, + featureParamApply, + featureParamEnabled, + featureParamUrl, + featureParseEnabled, + features, + featureSerializeEnabled, + featuresReset, + featuresSet, +} from "../src/utilities/feature"; +import { recordOf } from "../src/utilities/record"; +import { scopeFeature } from "../src/utilities/scope"; +import type { FeatureName, FeatureState } from "../src/types/feature"; +import type { FeatureCookieJarWritable } from "../src/utilities/feature"; + +// the real registry can be empty, so the tests bring their own features +vi.mock("../src/types/feature", () => { + const FEATURES = { + one: { label: "one", description: "first test feature" }, + two: { label: "two", description: "second test feature" }, + }; + const FEATURE_NAMES = Object.keys(FEATURES); + + return { + FEATURES, + FEATURE_NAMES, + isFeatureName: (value: unknown) => + (FEATURE_NAMES as unknown[]).includes(value), + }; +}); + +const names = Object.keys(FEATURES) as [FeatureName, ...FeatureName[]]; +const [name] = names; + +const stateWith = (enabled: Iterable): FeatureState => { + const on = new Set(enabled); + + return recordOf(names, (n) => on.has(n)); +}; + +const allOff = stateWith([]); + +/** read-only half, stand in for `Astro.cookies` */ +const jar = (value: string) => ({ + get: (key: string) => (key === FEATURE_COOKIE ? { value } : undefined), +}); + +type SetOptions = Parameters[2]; + +/** stand in for `Astro` */ +const target = (initial?: string, protocol = "https:") => { + let value = initial; + const writes: SetOptions[] = []; + let deletes = 0; + + return { + cookies: { + get: (key: string) => + key === FEATURE_COOKIE && typeof value !== "undefined" + ? { value } + : undefined, + set: (key: string, next: string, options: SetOptions) => { + if (key === FEATURE_COOKIE) { + value = next; + writes.push(options); + } + }, + delete: (key: string) => { + if (key === FEATURE_COOKIE) { + value = undefined; + deletes += 1; + } + }, + }, + url: { protocol }, + stored: () => value, + writes, + deletes: () => deletes, + }; +}; + +/** stand in for `Astro` on a server-rendered page, whose url can be questioned */ +const request = (href: string, initial?: string) => { + const url = new URL(href); + + return { ...target(initial, url.protocol), url }; +}; + +/** `?feature=a&feature=b` for the given names */ +const query = (...values: string[]): URLSearchParams => + new URLSearchParams(values.map((value) => [FEATURE_PARAM, value])); + +describe("featureParseEnabled", () => { + test("an empty cookie enables nothing", (t) => { + t.expect(featureParseEnabled("")).toEqual(new Set()); + }); + + test("names what the cookie lists", (t) => { + t.expect(featureParseEnabled(name)).toEqual(new Set([name])); + }); + + test("drops names that are not features", (t) => { + t.expect(featureParseEnabled("gone|also-gone")).toEqual(new Set()); + t.expect(featureParseEnabled(`gone|${name}`)).toEqual(new Set([name])); + }); +}); + +describe("featureSerializeEnabled", () => { + test("nothing enabled is an empty cookie", (t) => { + t.expect(featureSerializeEnabled(new Set())).toBe(""); + }); + + test("round-trips every feature", (t) => { + const all = new Set(names); + + t.expect(featureParseEnabled(featureSerializeEnabled(all))).toEqual(all); + }); +}); + +describe("features", () => { + test("answers for every feature in the registry, not just the listed ones", (t) => { + t.expect(Object.keys(features(jar(name))).toSorted()).toEqual( + [...names].toSorted(), + ); + }); + + test("sources from a cookie jar", (t) => { + t.expect(features(jar(name))).toEqual(stateWith([name])); + t.expect(feature(name, jar(name))).toBe(true); + }); + + test("without a jar and without a document everything is off", (t) => { + t.expect(features()).toEqual(allOff); + }); + + test("a jar without our cookie leaves everything off", (t) => { + t.expect(features({ get: () => undefined })).toEqual(allOff); + }); +}); + +describe("featureDataset", () => { + test("one data attribute per feature", (t) => { + const attributes = featureDataset(stateWith([name])); + + t.expect(Object.keys(attributes)).toHaveLength(names.length); + t.expect(attributes[`data-feature-${name}`]).toBe("on"); + }); + + test("an unlisted feature reads as off", (t) => { + t.expect(featureDataset(allOff)[`data-feature-${name}`]).toBe("off"); + }); +}); + +describe("featureEnable", () => { + test("writes through the jar and answers with the new state", (t) => { + const astro = target(); + + t.expect(featureEnable(name, true, astro)).toEqual(stateWith([name])); + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual( + new Set([name]), + ); + }); + + test("sources the current set from the same jar it writes to", (t) => { + const astro = target(featureSerializeEnabled(new Set(names))); + const remaining = new Set(names); + remaining.delete(name); + + featureEnable(name, false, astro); + + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual(remaining); + }); + + test("enabling twice is idempotent", (t) => { + const astro = target(); + featureEnable(name, true, astro); + const second = featureEnable(name, true, astro); + + t.expect(second).toEqual(stateWith([name])); + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual( + new Set([name]), + ); + }); + + test("turning the last feature off drops the cookie instead of blanking it", (t) => { + const astro = target(name); + + t.expect(featureEnable(name, false, astro)).toEqual(allOff); + t.expect(astro.stored()).toBeUndefined(); + t.expect(astro.deletes()).toBe(1); + }); + + test("the cookie outlives the request and stays on top-level navigations", (t) => { + const astro = target(); + featureEnable(name, true, astro); + + t.expect(astro.writes.at(0)).toMatchObject({ + path: "/", + sameSite: "lax", + }); + t.expect(astro.writes.at(0)?.maxAge).toBeGreaterThan(0); + }); + + test("secure follows the scheme, so local http dev still gets the cookie", (t) => { + const secure = target(undefined, "https:"); + const plain = target(undefined, "http:"); + + featureEnable(name, true, secure); + featureEnable(name, true, plain); + + t.expect(secure.writes.at(0)?.secure).toBe(true); + t.expect(plain.writes.at(0)?.secure).toBe(false); + }); + + test("refuses to guess a sink when there is no document and no jar", (t) => { + t.expect(() => featureEnable(name, true)).toThrowError( + "cookie jar needed in ssr context", + ); + }); +}); + +describe("featuresReset", () => { + test("deletes the cookie and turns everything off", (t) => { + const astro = target(featureSerializeEnabled(new Set(names))); + + t.expect(featuresReset(astro)).toEqual(allOff); + t.expect(astro.stored()).toBeUndefined(); + t.expect(astro.deletes()).toBe(1); + }); +}); + +describe("featuresSet", () => { + test("replaces the set rather than merging into it", (t) => { + const astro = target(featureSerializeEnabled(new Set(names))); + + t.expect(featuresSet(new Set(), astro)).toEqual(allOff); + t.expect(astro.stored()).toBeUndefined(); + }); +}); + +describe("featureParamEnabled", () => { + test("a url that does not ask leaves the cookie in charge", (t) => { + t.expect(featureParamEnabled(new URLSearchParams())).toBeUndefined(); + t.expect( + featureParamEnabled(new URLSearchParams("something=else")), + ).toBeUndefined(); + }); + + test("names what the query lists", (t) => { + t.expect(featureParamEnabled(query(name))).toEqual(new Set([name])); + }); + + test("asking for nothing is an ask, not a shrug", (t) => { + t.expect(featureParamEnabled(query(""))).toEqual(new Set()); + }); + + test("drops names that are not features", (t) => { + t.expect(featureParamEnabled(query("gone", name))).toEqual(new Set([name])); + }); +}); + +describe("featureParamUrl", () => { + test("spells out every enabled name", (t) => { + const url = new URL(featureParamUrl("https://example.com/a", names)); + + t.expect(url.searchParams.getAll(FEATURE_PARAM)).toEqual([...names]); + }); + + test("leaves the rest of the url alone", (t) => { + const url = new URL( + featureParamUrl("https://example.com/a?q=1#top", [name]), + ); + + t.expect(url.pathname).toBe("/a"); + t.expect(url.searchParams.get("q")).toBe("1"); + t.expect(url.hash).toBe("#top"); + }); + + test("replaces whatever the url already asked for", (t) => { + const before = featureParamUrl("https://example.com/", names); + const after = new URL(featureParamUrl(before, [])); + + t.expect(featureParamEnabled(after.searchParams)).toEqual(new Set()); + }); + + test("round-trips through featureParamEnabled", (t) => { + const url = new URL(featureParamUrl("https://example.com/", [name])); + + t.expect(featureParamEnabled(url.searchParams)).toEqual(new Set([name])); + }); +}); + +describe("featureParamApply", () => { + test("without a query the cookie decides, and nothing is written", (t) => { + const astro = request( + "https://example.com/", + featureSerializeEnabled(new Set([name])), + ); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(stateWith([name])); + t.expect(astro.writes).toHaveLength(0); + t.expect(astro.deletes()).toBe(0); + }); + + test("a query outranks the cookie", (t) => { + const astro = request( + `https://example.com/?${FEATURE_PARAM}=`, + featureSerializeEnabled(new Set([name])), + ); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(allOff); + }); + + test("writes back, so the state survives a navigation that drops the query", (t) => { + const astro = request(`https://example.com/?${FEATURE_PARAM}=${name}`); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(stateWith([name])); + t.expect(featureParseEnabled(astro.stored() ?? "")).toEqual( + new Set([name]), + ); + }); + + test("an empty ask drops the cookie rather than blanking it", (t) => { + const astro = request( + `https://example.com/?${FEATURE_PARAM}=`, + featureSerializeEnabled(new Set([name])), + ); + featureParamApply(astro); + + t.expect(astro.stored()).toBeUndefined(); + t.expect(astro.deletes()).toBe(1); + }); + + test("a link built by the menu is understood by the server", (t) => { + const astro = request( + featureParamUrl("https://example.com/some/page", [name]), + ); + featureParamApply(astro); + + t.expect(features(astro.cookies)).toEqual(stateWith([name])); + t.expect(feature(name, astro.cookies)).toBe(true); + }); +}); + +describe("render context propagation", () => { + test("a bare read finds the request being rendered", async (t) => { + const astro = request( + "https://example.com/", + featureSerializeEnabled(new Set([name])), + ); + + // awaits inside, the way a render does, to show the scope outlives them + const state = await scopeFeature.run(astro.cookies, async () => { + await Promise.resolve(); + + return features(); + }); + + t.expect(state).toEqual(stateWith([name])); + t.expect(feature(name)).toBe(false); + }); + + test("concurrent renders do not see each other", async (t) => { + const one = request( + "https://example.com/", + featureSerializeEnabled(new Set()), + ); + const two = request( + "https://example.com/", + featureSerializeEnabled(new Set([name])), + ); + + const read = (astro: ReturnType) => + scopeFeature.run(astro.cookies, async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + + return features(); + }); + + t.expect(await Promise.all([read(one), read(two)])).toEqual([ + allOff, + stateWith([name]), + ]); + }); +});