← 中文资源

简体中文版本

Vary 响应头

Vary 头声明哪些请求头会影响响应表示,使共享缓存按键存储多个变体,避免压缩版或移动版响应被错误发给不匹配的客户端。

阅读英文原文

概念定义

当同一 URL 依据请求头返回不同响应体时,缓存必须知道哪些维度参与区分。Vary 就是这些维度的清单。声明 Vary: Accept-Encoding 与 Accept-Language 的响应会按观察到的组合各存一份,查找时要求精确匹配同一组合。

工作机制

缓存在存储与查找时都根据列出的请求头计算次级键;未命中就回源并新增变体。维度过多会让键空间爆炸、命中率骤降。实用的 Vary 是显式头部名列表,通配行为仅存在于缓存控制扩展。

使用场景

任何按头部个性化的端点:语言协商、编码协商、按 Cookie 分流的实验、设备分级。应使用粗粒度桶而非原始值——把移动与桌面归一化,而不是对完整 User-Agent 字符串做 Vary。

常见误区

开启压缩却忘加 Vary: Accept-Encoding 是经典事故:brotli 响应会抵达只支持 gzip 的客户端。各缓存层的头部归一化必须完全一致,否则请求在变体之间反复抖动。头部维度失控时,支持 surrogate-key 的 CDN 可把分组与 Vary 解耦。

代码示例

export function parseAcceptHeader(header) {
  if (typeof header !== "string" || header.trim() === "") {
    return [{ type: "*", subtype: "*", q: 1, params: {}, order: 0 }];
  }
  const ranges = [];
  for (const [order, part] of header.split(",").entries()) {
    const segments = part.split(";").map((segment) => segment.trim());
    const mediaRange = segments.shift();
    if (!mediaRange) continue;
    const slash = mediaRange.indexOf("/");
    if (slash <= 0 || slash === mediaRange.length - 1) {
      throw new SyntaxError("invalid media range: " + mediaRange);
    }
    const type = mediaRange.slice(0, slash).toLowerCase();
    const subtype = mediaRange.slice(slash + 1).toLowerCase();
    const params = {};
    let q = 1;
    for (const segment of segments) {
      if (!segment) continue;
      const equals = segment.indexOf("=");
      if (equals <= 0) throw new SyntaxError("invalid parameter: " + segment);
      const name = segment.slice(0, equals).trim().toLowerCase();
      const raw = segment.slice(equals + 1).trim().replace(/^"|"$/g, "");
      if (name === "q") {
        q = Number(raw);
        if (!Number.isFinite(q) || q < 0 || q > 1) {
          throw new RangeError("invalid q value: " + raw);
        }
      } else {
        params[name] = raw;
      }
    }
    ranges.push({ type, subtype, q, params, order });
  }
  if (ranges.length === 0) throw new SyntaxError("Accept header has no media ranges");
  return ranges;
}

export function negotiateContentType(header, supported) {
  if (!Array.isArray(supported) || supported.length === 0) {
    throw new TypeError("supported must be a non-empty array");
  }
  const ranges = parseAcceptHeader(header);
  const candidates = [];
  for (const [order, mediaType] of supported.entries()) {
    const slash = mediaType.indexOf("/");
    if (slash <= 0) throw new TypeError("invalid supported media type: " + mediaType);
    const type = mediaType.slice(0, slash).toLowerCase();
    const subtype = mediaType.slice(slash + 1).toLowerCase();
    let best;
    for (const range of ranges) {
      const typeMatches = range.type === "*" || range.type === type;
      const subtypeMatches = range.subtype === "*" || range.subtype === subtype;
      if (!typeMatches || !subtypeMatches) continue;
      const specificity = (range.type === "*" ? 0 : 1) + (range.subtype === "*" ? 0 : 1);
      if (!best || specificity > best.specificity ||
        (specificity === best.specificity && range.order < best.order)) {
        best = { q: range.q, specificity, order: range.order };
      }
    }
    if (best && best.q > 0) {
      candidates.push({ mediaType, q: best.q, specificity: best.specificity, order });
    }
  }
  candidates.sort((a, b) => b.q - a.q || b.specificity - a.specificity || a.order - b.order);
  return candidates.length > 0 ? candidates[0].mediaType : null;
}
import { createHash } from "node:crypto";

function getHeader(headers, name) {
  const key = Object.keys(headers).find(
    (candidate) => candidate.toLowerCase() === name,
  );
  return key === undefined ? undefined : headers[key];
}

function requireNonNegativeInteger(value, label) {
  if (!Number.isInteger(value) || value < 0) {
    throw new RangeError(`${label} must be a non-negative integer`);
  }
}

export function createEtag(body, options = {}) {
  if (typeof body !== "string" && !Buffer.isBuffer(body)) {
    throw new TypeError("body must be a string or Buffer");
  }
  const { weak = false } = options;
  const digest = createHash("sha256")
    .update(body)
    .digest("base64url")
    .slice(0, 27);
  return `${weak ? "W/" : ""}"${digest}"`;
}

export function buildCacheControl(options = {}) {
  const {
    visibility = "public",
    maxAge = 0,
    sharedMaxAge,
    staleWhileRevalidate,
    immutable = false,
  } = options;
  if (visibility !== "public" && visibility !== "private") {
    throw new RangeError("visibility must be public or private");
  }
  requireNonNegativeInteger(maxAge, "maxAge");

  const directives = [visibility, `max-age=${maxAge}`];
  if (sharedMaxAge !== undefined) {
    requireNonNegativeInteger(sharedMaxAge, "sharedMaxAge");
    directives.push(`s-maxage=${sharedMaxAge}`);
  }
  if (staleWhileRevalidate !== undefined) {
    requireNonNegativeInteger(staleWhileRevalidate, "staleWhileRevalidate");
    directives.push(`stale-while-revalidate=${staleWhileRevalidate}`);
  }
  if (immutable) {
    if (maxAge === 0) {
      throw new RangeError("immutable requires maxAge greater than zero");
    }
    directives.push("immutable");
  }
  return directives.join(", ");
}

export function cacheHeaders(options = {}) {
  const {
    body,
    maxAge = 0,
    visibility = "public",
    lastModified,
    weak = false,
    staleWhileRevalidate,
  } = options;
  if (body === undefined) {
    throw new TypeError("body is required");
  }

  const headers = {
    "Cache-Control": buildCacheControl({
      visibility,
      maxAge,
      staleWhileRevalidate,
    }),
    ETag: createEtag(body, { weak }),
  };
  if (lastModified !== undefined) {
    const date = new Date(lastModified);
    if (Number.isNaN(date.getTime())) {
      throw new RangeError("lastModified must be a valid date");
    }
    headers["Last-Modified"] = date.toUTCString();
  }
  return headers;
}

function opaqueTag(tag) {
  return tag.trim().replace(/^W\//i, "");
}

export function evaluateConditionalRequest(
  method,
  requestHeaders = {},
  responseHeaders = {},
) {
  const normalizedMethod =
    typeof method === "string" ? method.toUpperCase() : "";
  if (normalizedMethod !== "GET" && normalizedMethod !== "HEAD") {
    return { status: 200, headers: { ...responseHeaders } };
  }

  const etag = getHeader(responseHeaders, "etag");
  const lastModified = getHeader(responseHeaders, "last-modified");
  const cacheControl = getHeader(responseHeaders, "cache-control");
  const ifNoneMatch = getHeader(requestHeaders, "if-none-match");
  let notModified = false;

  if (ifNoneMatch !== undefined && etag !== undefined) {
    const candidates = ifNoneMatch.split(",").map((tag) => tag.trim());
    notModified =
      candidates.includes("*") ||
      candidates.some((tag) => opaqueTag(tag) === opaqueTag(etag));
  } else {
    const ifModifiedSince = getHeader(requestHeaders, "if-modified-since");
    if (ifModifiedSince !== undefined && lastModified !== undefined) {
      const since = Date.parse(ifModifiedSince);
      const modified = Date.parse(lastModified);
      if (!Number.isNaN(since) && !Number.isNaN(modified)) {
        notModified =
          Math.floor(modified / 1000) <= Math.floor(since / 1000);
      }
    }
  }

  if (!notModified) {
    return { status: 200, headers: { ...responseHeaders } };
  }

  const headers = {};
  if (etag !== undefined) headers.ETag = etag;
  if (cacheControl !== undefined) headers["Cache-Control"] = cacheControl;
  if (lastModified !== undefined) headers["Last-Modified"] = lastModified;
  return { status: 304, headers };
}

const body = JSON.stringify({ items: [1, 2, 3] });
const headers = cacheHeaders({
  body,
  maxAge: 60,
  staleWhileRevalidate: 30,
  lastModified: "2026-10-21T10:00:00Z",
});
console.log(headers);
console.log(evaluateConditionalRequest("GET", {}, headers).status);
console.log(
  evaluateConditionalRequest("GET", { "if-none-match": headers.ETag }, headers)
    .status,
);
console.log(
  evaluateConditionalRequest(
    "GET",
    { "if-modified-since": headers["Last-Modified"] },
    headers,
  ).status,
);
console.log(
  evaluateConditionalRequest("POST", { "if-none-match": headers.ETag }, headers)
    .status,
);