diff --git a/CLAUDE.md b/CLAUDE.md index f5a094b..5496e39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,7 @@ npm run commitlint # Validate the latest commit message - **`index.js`**: Runtime configuration with environment variable overrides - `TIMEOUT_SECONDS`: Request timeout (default: 30s) - `MAX_RETRIES`: Retry attempts (default: 3) - - `CACHE_DURATION`: Cache TTL (default: 1800s = 30 minutes) + - `CACHE_DURATION`: Fallback mutable cache TTL (default: 300s = 5 minutes) - `SECURITY.ALLOWED_METHODS`: HTTP methods (default: GET, HEAD) - **`platform-catalog.js`**: Platform base URL definitions @@ -143,7 +143,7 @@ npm run commitlint # Validate the latest commit message ### Caching Strategy - Uses Cloudflare Cache API for GET requests (200 OK only) -- Cache TTL controlled by `CACHE_DURATION` config +- Fallback mutable cache TTL controlled by `CACHE_DURATION` config - Skips cache for: Git operations, Docker operations, AI inference requests - Range requests: First checks for range-specific cache, falls back to full content cache @@ -315,7 +315,7 @@ Configure in Cloudflare Workers dashboard or via `wrangler.toml`: - `TIMEOUT_SECONDS`: Override default timeout - `MAX_RETRIES`: Override retry count -- `CACHE_DURATION`: Override cache TTL +- `CACHE_DURATION`: Override fallback mutable cache TTL - `ALLOWED_METHODS`: Override allowed HTTP methods (comma-separated) - `ALLOWED_ORIGINS`: Override CORS origins (comma-separated) diff --git a/README.md b/README.md index 070b281..9ae9a6d 100644 --- a/README.md +++ b/README.md @@ -156,10 +156,11 @@ supported platform URL to Xget's acceleration format with one click - Automatic error recovery, improved download success rate - Timeout detection and interruption handling - **Efficient Caching Strategy**: - - 1800 seconds (30 minutes) default cache duration, significantly reduces - origin server pressure + - Strategy-based cache durations keep mutable metadata fresh while caching + immutable artifacts longer - Git operations skip caching to ensure real-time data - - Edge caching based on Cloudflare Cache API + - Edge caching based on Cloudflare Cache API and Cloudflare fetch cache + controls - **Performance Monitoring System**: - Built-in `PerformanceMonitor` class for real-time tracking of request stage durations @@ -2892,7 +2893,7 @@ export const CONFIG = { TIMEOUT_SECONDS: 30, // Request timeout (seconds) MAX_RETRIES: 3, // Maximum retry count RETRY_DELAY_MS: 1000, // Retry delay (milliseconds) - CACHE_DURATION: 1800, // Cache duration (1800 seconds = 30 minutes) + CACHE_DURATION: 300, // Fallback mutable cache duration (300 seconds = 5 minutes) SECURITY: { ALLOWED_METHODS: ['GET', 'HEAD'], // Base allowlist for regular requests; protocol traffic has broader built-in allowances ALLOWED_ORIGINS: ['*'], // Allowed CORS origins @@ -2903,8 +2904,8 @@ export const CONFIG = { ### Performance Tuning Recommendations -- **Cache Optimization**: Adjust `CACHE_DURATION` based on usage patterns, - reduce appropriately for frequently updated repositories +- **Cache Optimization**: Adjust the `CACHE_DURATION` fallback value based on + usage patterns; metadata and immutable artifacts use built-in policy TTLs - **Timeout Settings**: Increase `TIMEOUT_SECONDS` appropriately for poor network conditions - **Retry Strategy**: Increase `MAX_RETRIES` and `RETRY_DELAY_MS` in diff --git a/README.zh-Hans.md b/README.zh-Hans.md index 46933f0..7007e76 100644 --- a/README.zh-Hans.md +++ b/README.zh-Hans.md @@ -128,9 +128,9 @@ - 自动错误恢复,提高下载成功率 - 超时检测和中断处理 - **高效缓存策略**: - - 1800 秒(30 分钟)默认缓存时长,显著减少源站压力 + - 基于策略的缓存时长,让可变元数据保持新鲜,同时对不可变制品使用更长缓存 - Git 操作跳过缓存,确保实时性 - - 基于 Cloudflare Cache API 的边缘缓存 + - 基于 Cloudflare Cache API 和 Cloudflare fetch 缓存控制的边缘缓存 - **性能监控系统**: - 内置 `PerformanceMonitor` 类,实时追踪请求各阶段耗时 - 通过 `X-Performance-Metrics` 响应头提供详细性能数据 @@ -2808,7 +2808,7 @@ export const CONFIG = { TIMEOUT_SECONDS: 30, // 请求超时时间(秒) MAX_RETRIES: 3, // 最大重试次数 RETRY_DELAY_MS: 1000, // 重试延迟时间(毫秒) - CACHE_DURATION: 1800, // 缓存持续时间(1800秒 = 30分钟) + CACHE_DURATION: 300, // 可变资源兜底缓存时长(300秒 = 5分钟) SECURITY: { ALLOWED_METHODS: ['GET', 'HEAD'], // 常规请求的基础允许列表;协议流量内置了更宽的允许范围 ALLOWED_ORIGINS: ['*'], // 允许的 CORS 源 @@ -2819,7 +2819,7 @@ export const CONFIG = { ### 性能调优建议 -- **缓存优化**:根据使用模式调整 `CACHE_DURATION`,频繁更新的存储库可适当降低 +- **缓存优化**:根据使用模式调整 `CACHE_DURATION` 兜底值;元数据和不可变制品会使用内置策略化 TTL - **超时设置**:网络条件较差时可适当增加 `TIMEOUT_SECONDS` - **重试策略**:高延迟环境下可增加 `MAX_RETRIES` 和 `RETRY_DELAY_MS` diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 7f9a2eb..29315ac 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -129,9 +129,9 @@ - 自動錯誤恢復,提高下載成功率 - 逾時檢測和中斷處理 - **高效快取策略**: - - 1800 秒(30 分鐘)預設快取時長,顯著減少原始伺服器壓力 + - 基於策略的快取時長,讓可變中繼資料保持新鮮,同時對不可變製品使用更長快取 - Git 操作跳過快取,確保即時性 - - 基於 Cloudflare Cache API 的邊緣快取 + - 基於 Cloudflare Cache API 和 Cloudflare fetch 快取控制的邊緣快取 - **效能監控系統**: - 內建 `PerformanceMonitor` 類別,即時追蹤請求各階段耗時 - 透過 `X-Performance-Metrics` 回應標頭提供詳細效能數據 @@ -2808,7 +2808,7 @@ export const CONFIG = { TIMEOUT_SECONDS: 30, // 請求逾時時間(秒) MAX_RETRIES: 3, // 最大重試次數 RETRY_DELAY_MS: 1000, // 重試延遲時間(毫秒) - CACHE_DURATION: 1800, // 快取持續時間(1800秒 = 30分鐘) + CACHE_DURATION: 300, // 可變資源兜底快取時長(300秒 = 5分鐘) SECURITY: { ALLOWED_METHODS: ['GET', 'HEAD'], // 常規請求的基礎允許清單;協定流量內建了更寬的允許範圍 ALLOWED_ORIGINS: ['*'], // 允許的 CORS 來源 @@ -2819,7 +2819,7 @@ export const CONFIG = { ### 效能調優建議 -- **快取最佳化**:根據使用模式調整 `CACHE_DURATION`,頻繁更新的儲存庫可適當降低 +- **快取最佳化**:根據使用模式調整 `CACHE_DURATION` 兜底值;中繼資料和不可變製品會使用內建策略化 TTL - **逾時設定**:網路條件較差時可適當增加 `TIMEOUT_SECONDS` - **重試策略**:高延遲環境下可增加 `MAX_RETRIES` 和 `RETRY_DELAY_MS` diff --git a/src/app/handle-request.js b/src/app/handle-request.js index 5c7f78a..b189d72 100644 --- a/src/app/handle-request.js +++ b/src/app/handle-request.js @@ -16,6 +16,7 @@ import { resolveTarget } from '../routing/resolve-target.js'; import { getDefaultCache, tryReadCachedResponse } from '../upstream/cache.js'; +import { resolveCachePolicy } from '../upstream/cache-policy.js'; import { fetchUpstreamResponse } from '../upstream/fetch-upstream.js'; import { PerformanceMonitor, addPerformanceHeaders } from '../utils/performance.js'; import { addCorsHeaders, addSecurityHeaders, createErrorResponse } from '../utils/security.js'; @@ -113,11 +114,22 @@ export async function handleRequest(request, env, ctx) { ); const canUseCache = request.method === 'GET' || request.method === 'HEAD'; const shouldPassthroughRequest = isProtocolRequest(requestContext) || !canUseCache; + const cachePolicy = resolveCachePolicy({ + canUseCache, + config, + effectivePath, + hasSensitiveHeaders, + platform, + request, + requestContext, + targetUrl + }); const cache = getDefaultCache(); response = await tryReadCachedResponse({ cache, cacheTargetUrl, + cachePolicy, canUseCache, hasSensitiveHeaders, monitor, @@ -131,6 +143,7 @@ export async function handleRequest(request, env, ctx) { responseGeneratedLocally: upstreamResponseGeneratedLocally } = await fetchUpstreamResponse({ authorization, + cachePolicy, canUseCache, config, effectivePath, @@ -155,6 +168,7 @@ export async function handleRequest(request, env, ctx) { requestContext, response: upstreamResponse, responseGeneratedLocally: upstreamResponseGeneratedLocally, + targetUrl, url }); } diff --git a/src/config/index.js b/src/config/index.js index 2789798..9434f80 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -50,7 +50,7 @@ import { PLATFORMS } from './platform-catalog.js'; * @property {number} TIMEOUT_SECONDS - Request timeout in seconds (default: 30) * @property {number} MAX_RETRIES - Maximum number of retry attempts for failed requests (default: 3) * @property {number} RETRY_DELAY_MS - Delay between retry attempts in milliseconds (default: 1000) - * @property {number} CACHE_DURATION - Cache duration in seconds for successful responses (default: 1800) + * @property {number} CACHE_DURATION - Fallback cache duration in seconds for mutable successful responses (default: 300) * @property {SecurityConfig} SECURITY - Security-related configurations * @property {{ [key: string]: string }} PLATFORMS - Platform-specific base URL mappings * @example @@ -59,7 +59,7 @@ import { PLATFORMS } from './platform-catalog.js'; * TIMEOUT_SECONDS: 30, * MAX_RETRIES: 3, * RETRY_DELAY_MS: 1000, - * CACHE_DURATION: 1800, + * CACHE_DURATION: 300, * SECURITY: { * ALLOWED_METHODS: ['GET', 'HEAD'], * ALLOWED_ORIGINS: ['*'], @@ -89,7 +89,7 @@ import { PLATFORMS } from './platform-catalog.js'; * - `TIMEOUT_SECONDS` - Override default timeout (default: 30) * - `MAX_RETRIES` - Override max retry attempts (default: 3) * - `RETRY_DELAY_MS` - Override retry delay (default: 1000) - * - `CACHE_DURATION` - Override cache TTL (default: 1800 = 30 minutes) + * - `CACHE_DURATION` - Override fallback mutable cache TTL (default: 300 = 5 minutes) * - `ALLOWED_METHODS` - Comma-separated HTTP methods (default: 'GET,HEAD') * - `ALLOWED_ORIGINS` - Comma-separated CORS origins (default: '*') * - `MAX_PATH_LENGTH` - Override max path length (default: 2048) @@ -99,7 +99,7 @@ import { PLATFORMS } from './platform-catalog.js'; * // Create config with defaults (no environment variables) * const config = createConfig(); * console.log(config.TIMEOUT_SECONDS); // 30 - * console.log(config.CACHE_DURATION); // 1800 + * console.log(config.CACHE_DURATION); // 300 * @example * // Create config with environment overrides * const env = { @@ -149,7 +149,7 @@ export function createConfig(env = {}) { TIMEOUT_SECONDS: parseInt(String(env.TIMEOUT_SECONDS), 10) || 30, MAX_RETRIES: parseInt(String(env.MAX_RETRIES), 10) || 3, RETRY_DELAY_MS: parseInt(String(env.RETRY_DELAY_MS), 10) || 1000, - CACHE_DURATION: parseInt(String(env.CACHE_DURATION), 10) || 1800, // 30 minutes + CACHE_DURATION: parseInt(String(env.CACHE_DURATION), 10) || 300, // 5 minutes SECURITY: { ALLOWED_METHODS: allowedMethods.length ? allowedMethods : ['GET', 'HEAD'], ALLOWED_ORIGINS: allowedOrigins.length ? allowedOrigins : ['*'], diff --git a/src/response/finalize-response.js b/src/response/finalize-response.js index 7b403a5..fb3d48d 100644 --- a/src/response/finalize-response.js +++ b/src/response/finalize-response.js @@ -21,6 +21,7 @@ import { rewriteTextResponse, shouldRewriteTextResponse } from '../utils/rewrite.js'; +import { resolveCachePolicy } from '../upstream/cache-policy.js'; import { addSecurityHeaders, createErrorResponse } from '../utils/security.js'; /** @@ -95,6 +96,7 @@ async function finalizeErrorResponse({ requestContext, response, responseGenerat * isHF: boolean * }, * response: Response, + * targetUrl?: string, * url: URL * }} options * @returns {Promise} Final proxied response. @@ -112,6 +114,7 @@ async function finalizeSuccessfulResponse({ request, requestContext, response, + targetUrl = cacheTargetUrl, url }) { const { isAI, isDocker, isGit, isGitLFS, isHF } = requestContext; @@ -140,18 +143,27 @@ async function finalizeSuccessfulResponse({ headers.set('Content-Length', String(rewrittenContentLength)); } + const cachePolicy = resolveCachePolicy({ + canUseCache, + config, + effectivePath, + hasOriginBoundRewrite, + hasSensitiveHeaders, + platform, + request, + requestContext, + targetUrl + }); + if (!isGit && !isGitLFS && !isDocker && !isAI && !isHF) { - if (!canUseCache || hasOriginBoundRewrite) { - headers.set('Cache-Control', 'no-store'); - } else if (hasSensitiveHeaders) { - headers.set('Cache-Control', 'private, no-store'); + headers.set('Cache-Control', cachePolicy.cacheControl); + + if (cachePolicy.mode === 'private') { const existingVary = headers.get('Vary'); headers.set( 'Vary', existingVary ? `${existingVary}, Authorization, Cookie` : 'Authorization, Cookie' ); - } else { - headers.set('Cache-Control', `public, max-age=${config.CACHE_DURATION}`); } headers.set('X-Content-Type-Options', 'nosniff'); @@ -178,13 +190,7 @@ async function finalizeSuccessfulResponse({ if ( cache && - !isGit && - !isGitLFS && - !isDocker && - !isAI && - !isHF && - !hasOriginBoundRewrite && - !hasSensitiveHeaders && + cachePolicy.allowCacheApi && request.method === 'GET' && finalizedResponse.ok && finalizedResponse.status === 200 @@ -250,6 +256,7 @@ async function finalizeSuccessfulResponse({ * }, * response: Response, * responseGeneratedLocally: boolean, + * targetUrl?: string, * url: URL * }} options * @returns {Promise} Final response returned to the client. @@ -268,6 +275,7 @@ export async function finalizeResponse({ requestContext, response, responseGeneratedLocally, + targetUrl = cacheTargetUrl, url }) { const errorResponse = await finalizeErrorResponse({ @@ -297,6 +305,7 @@ export async function finalizeResponse({ request, requestContext, response: errorResponse, + targetUrl, url }); } diff --git a/src/routing/resolve-target.js b/src/routing/resolve-target.js index 80d2134..91e87d8 100644 --- a/src/routing/resolve-target.js +++ b/src/routing/resolve-target.js @@ -19,7 +19,7 @@ import { SORTED_PLATFORMS } from './platform-index.js'; import { transformPath } from './platform-transformers.js'; import { normalizeRegistryApiPath } from '../protocols/docker.js'; -import { isFlatpakReferenceFilePath } from '../utils/rewrite.js'; +import { shouldVaryCacheByOrigin } from '../upstream/cache-policy.js'; import { createErrorResponse } from '../utils/security.js'; export const HOME_PAGE_URL = 'https://github.com/xixu-me/Xget'; @@ -98,16 +98,15 @@ export function resolveTarget(url, effectivePath, platforms) { ? normalizeRegistryApiPath(platform, transformedPath) : transformedPath; const targetUrl = `${platforms[platform]}${targetPath}${url.search}`; - const shouldVaryCacheByOrigin = - platform === 'flathub' && isFlatpakReferenceFilePath(effectivePath); - const cacheTargetUrl = shouldVaryCacheByOrigin + const varyCacheByOrigin = shouldVaryCacheByOrigin(platform, effectivePath); + const cacheTargetUrl = varyCacheByOrigin ? `${targetUrl}${targetUrl.includes('?') ? '&' : '?'}__xget_origin=${encodeURIComponent(url.origin)}` : targetUrl; return { cacheTargetUrl, platform, - shouldVaryCacheByOrigin, + shouldVaryCacheByOrigin: varyCacheByOrigin, targetPath, targetUrl }; diff --git a/src/upstream/cache-policy.js b/src/upstream/cache-policy.js new file mode 100644 index 0000000..e6e7a78 --- /dev/null +++ b/src/upstream/cache-policy.js @@ -0,0 +1,219 @@ +/** + * Xget - High-performance acceleration engine for developer resources + * Copyright (C) Xi Xu + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { isFlatpakReferenceFilePath } from '../utils/rewrite.js'; + +const METADATA_EDGE_TTL_SECONDS = 60; +const MUTABLE_EDGE_TTL_SECONDS = 300; +const IMMUTABLE_EDGE_TTL_SECONDS = 86400; +const IMMUTABLE_BROWSER_TTL_SECONDS = 3600; + +const IMMUTABLE_ARTIFACT_PATTERN = + /\.(?:tgz|whl|jar|zip|gem|crate|deb|rpm|nupkg|tar\.gz|tar\.bz2|tar\.xz)(?:$|[?#])/i; + +/** + * Checks whether a request path points to versioned or content-addressed package artifacts. + * @param {string} value Request path or target URL. + * @returns {boolean} True when the resource can use long-lived immutable caching. + */ +export function isImmutableArtifactPath(value) { + return IMMUTABLE_ARTIFACT_PATTERN.test(value); +} + +/** + * Checks whether an npm path points to rewritten package metadata instead of tarball content. + * @param {string} effectivePath Normalized request path. + * @returns {boolean} True when npm response rewriting can bind content to request origin. + */ +function isNpmMetadataPath(effectivePath) { + return effectivePath.startsWith('/npm/') && !isImmutableArtifactPath(effectivePath); +} + +/** + * Checks whether the cache key must include the request origin because response rewriting embeds it. + * @param {string} platform Platform key. + * @param {string} effectivePath Normalized request path. + * @returns {boolean} True when the generated response varies by request origin. + */ +export function shouldVaryCacheByOrigin(platform, effectivePath) { + return ( + (platform === 'flathub' && isFlatpakReferenceFilePath(effectivePath)) || + (platform === 'npm' && isNpmMetadataPath(effectivePath)) + ); +} + +/** + * Checks whether a request targets mutable metadata or package index resources. + * @param {string} platform Platform key. + * @param {string} effectivePath Normalized request path. + * @returns {boolean} True when freshness should be preferred over hit ratio. + */ +function isMetadataOrIndexPath(platform, effectivePath) { + if (platform === 'npm') { + return isNpmMetadataPath(effectivePath); + } + + if (platform === 'pypi') { + return effectivePath.startsWith('/pypi/simple/') || effectivePath === '/pypi/simple'; + } + + if (platform === 'maven') { + return effectivePath.endsWith('/maven-metadata.xml'); + } + + if (platform === 'flathub') { + return ( + effectivePath === '/flathub/repo/summary' || effectivePath === '/flathub/repo/summary.sig' + ); + } + + return false; +} + +/** + * Builds a shared-cache Cache-Control value. + * @param {number} browserTtl Browser max-age in seconds. + * @param {number} edgeTtl Shared cache max-age in seconds. + * @param {boolean} immutable Whether the response is immutable. + * @returns {string} Cache-Control header value. + */ +function buildPublicCacheControl(browserTtl, edgeTtl, immutable) { + const directives = ['public', `max-age=${browserTtl}`, `s-maxage=${edgeTtl}`]; + + if (immutable) { + directives.push('immutable'); + } else { + directives.push('must-revalidate'); + } + + return directives.join(', '); +} + +/** + * Resolves cache behavior for a proxied request/response. + * @param {{ + * canUseCache: boolean, + * config: import('../config/index.js').ApplicationConfig, + * effectivePath: string, + * hasOriginBoundRewrite?: boolean, + * hasSensitiveHeaders: boolean, + * platform: string, + * request: Request, + * requestContext: { + * isAI: boolean, + * isDocker: boolean, + * isGit: boolean, + * isGitLFS: boolean, + * isHF: boolean + * }, + * targetUrl: string + * }} options + * @returns {{ + * allowCacheApi: boolean, + * allowFetchCache: boolean, + * browserTtl: number, + * cacheControl: string, + * edgeTtl: number, + * mode: 'bypass' | 'edge' | 'private', + * varyByOrigin: boolean + * }} Cache policy. + */ +export function resolveCachePolicy({ + canUseCache, + config, + effectivePath, + hasOriginBoundRewrite = false, + hasSensitiveHeaders, + platform, + request, + requestContext, + targetUrl +}) { + const isProtocolRequest = + requestContext.isGit || + requestContext.isGitLFS || + requestContext.isDocker || + requestContext.isAI || + requestContext.isHF; + + if (hasSensitiveHeaders) { + return { + allowCacheApi: false, + allowFetchCache: false, + browserTtl: 0, + cacheControl: 'private, no-store', + edgeTtl: 0, + mode: 'private', + varyByOrigin: false + }; + } + + if (!canUseCache || isProtocolRequest || hasOriginBoundRewrite) { + return { + allowCacheApi: false, + allowFetchCache: false, + browserTtl: 0, + cacheControl: 'no-store', + edgeTtl: 0, + mode: 'bypass', + varyByOrigin: false + }; + } + + if (isImmutableArtifactPath(effectivePath) || isImmutableArtifactPath(targetUrl)) { + return { + allowCacheApi: request.method === 'GET', + allowFetchCache: true, + browserTtl: IMMUTABLE_BROWSER_TTL_SECONDS, + cacheControl: buildPublicCacheControl( + IMMUTABLE_BROWSER_TTL_SECONDS, + IMMUTABLE_EDGE_TTL_SECONDS, + true + ), + edgeTtl: IMMUTABLE_EDGE_TTL_SECONDS, + mode: 'edge', + varyByOrigin: shouldVaryCacheByOrigin(platform, effectivePath) + }; + } + + if (isMetadataOrIndexPath(platform, effectivePath)) { + return { + allowCacheApi: request.method === 'GET', + allowFetchCache: true, + browserTtl: 0, + cacheControl: buildPublicCacheControl(0, METADATA_EDGE_TTL_SECONDS, false), + edgeTtl: METADATA_EDGE_TTL_SECONDS, + mode: 'edge', + varyByOrigin: shouldVaryCacheByOrigin(platform, effectivePath) + }; + } + + const fallbackEdgeTtl = Number.isFinite(config.CACHE_DURATION) + ? config.CACHE_DURATION + : MUTABLE_EDGE_TTL_SECONDS; + + return { + allowCacheApi: request.method === 'GET', + allowFetchCache: true, + browserTtl: 0, + cacheControl: buildPublicCacheControl(0, fallbackEdgeTtl, false), + edgeTtl: fallbackEdgeTtl, + mode: 'edge', + varyByOrigin: shouldVaryCacheByOrigin(platform, effectivePath) + }; +} diff --git a/src/upstream/cache.js b/src/upstream/cache.js index 0fdaba9..8f14773 100644 --- a/src/upstream/cache.js +++ b/src/upstream/cache.js @@ -37,6 +37,7 @@ export function getDefaultCache() { * @param {{ * cache: Cache | null, * cacheTargetUrl: string, + * cachePolicy?: { allowCacheApi: boolean }, * canUseCache: boolean, * hasSensitiveHeaders: boolean, * monitor: import('../utils/performance.js').PerformanceMonitor, @@ -54,6 +55,7 @@ export function getDefaultCache() { export async function tryReadCachedResponse({ cache, cacheTargetUrl, + cachePolicy, canUseCache, hasSensitiveHeaders, monitor, @@ -64,6 +66,7 @@ export async function tryReadCachedResponse({ if ( !cache || + (cachePolicy && !cachePolicy.allowCacheApi) || !canUseCache || isGit || isGitLFS || diff --git a/src/upstream/fetch-upstream.js b/src/upstream/fetch-upstream.js index d3c303f..7e2ab71 100644 --- a/src/upstream/fetch-upstream.js +++ b/src/upstream/fetch-upstream.js @@ -35,6 +35,7 @@ const MEDIA_FILE_PATTERN = * Creates upstream fetch options for the current request. * @param {{ * authorization: string | null, + * cachePolicy?: { allowFetchCache: boolean, edgeTtl: number }, * canUseCache: boolean, * config: import('../config/index.js').ApplicationConfig, * request: Request, @@ -53,6 +54,7 @@ const MEDIA_FILE_PATTERN = */ function createFetchOptions({ authorization, + cachePolicy, canUseCache, config, request, @@ -97,14 +99,16 @@ function createFetchOptions({ return { fetchOptions, requestHeaders }; } - Object.assign(fetchOptions, { - cf: { - http3: true, - cacheTtl: config.CACHE_DURATION, - cacheEverything: true, - preconnect: true - } - }); + if (!cachePolicy || cachePolicy.allowFetchCache) { + Object.assign(fetchOptions, { + cf: { + http3: true, + cacheTtl: cachePolicy ? cachePolicy.edgeTtl : config.CACHE_DURATION, + cacheEverything: true, + preconnect: true + } + }); + } requestHeaders.set('Accept-Encoding', 'gzip, deflate, br'); requestHeaders.set('Connection', 'keep-alive'); @@ -307,6 +311,7 @@ async function retryDockerWithAnonymousToken({ * Fetches an upstream resource with retries and protocol-specific handling. * @param {{ * authorization: string | null, + * cachePolicy?: { allowFetchCache: boolean, edgeTtl: number }, * canUseCache: boolean, * config: import('../config/index.js').ApplicationConfig, * effectivePath: string, @@ -328,6 +333,7 @@ async function retryDockerWithAnonymousToken({ */ export async function fetchUpstreamResponse({ authorization, + cachePolicy, canUseCache, config, effectivePath, @@ -342,6 +348,7 @@ export async function fetchUpstreamResponse({ let responseGeneratedLocally = false; const { fetchOptions, requestHeaders } = createFetchOptions({ authorization, + cachePolicy, canUseCache, config, request, diff --git a/test/features/auth.test.js b/test/features/auth.test.js index 09d716c..7f44c15 100644 --- a/test/features/auth.test.js +++ b/test/features/auth.test.js @@ -103,7 +103,7 @@ describe('Authentication Header Forwarding', () => { expect(new Headers(fetchSpy.mock.calls[0][1]?.headers).get('Authorization')).toBe(authToken); expect(cacheDefault.match).not.toHaveBeenCalled(); expect(cacheDefault.put).not.toHaveBeenCalled(); - expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); }); it('forwards Authorization for gated Hugging Face model downloads', async () => { diff --git a/test/unit/cache-privacy.test.js b/test/unit/cache-privacy.test.js index 46fc55f..336d221 100644 --- a/test/unit/cache-privacy.test.js +++ b/test/unit/cache-privacy.test.js @@ -47,6 +47,27 @@ describe('Cache Privacy', () => { expect(response.status).toBe(200); expect(cacheDefault.match).not.toHaveBeenCalled(); expect(cacheDefault.put).not.toHaveBeenCalled(); + expect(fetchStub).toHaveBeenCalled(); + expect(fetchStub.mock.calls[0][1]?.cf).toBeUndefined(); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); + }); + + it('should not enable Cloudflare fetch caching for requests with Cookie', async () => { + const request = new Request('https://example.com/gh/test/repo/file.txt', { + method: 'GET', + headers: { + Cookie: 'session=secret' + } + }); + + const ctx = { waitUntil: () => {}, passThroughOnException: () => {} }; + const response = await worker.fetch(request, {}, ctx); + + expect(response.status).toBe(200); + expect(cacheDefault.match).not.toHaveBeenCalled(); + expect(cacheDefault.put).not.toHaveBeenCalled(); + expect(fetchStub).toHaveBeenCalled(); + expect(fetchStub.mock.calls[0][1]?.cf).toBeUndefined(); expect(response.headers.get('Cache-Control')).toBe('private, no-store'); }); diff --git a/test/unit/pipeline-modules.test.js b/test/unit/pipeline-modules.test.js index c44022a..e254e09 100644 --- a/test/unit/pipeline-modules.test.js +++ b/test/unit/pipeline-modules.test.js @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createRequestContext } from '../../src/app/request-context.js'; import { CONFIG } from '../../src/config/index.js'; import { finalizeResponse } from '../../src/response/finalize-response.js'; +import { resolveTarget } from '../../src/routing/resolve-target.js'; import { tryReadCachedResponse } from '../../src/upstream/cache.js'; import { fetchUpstreamResponse } from '../../src/upstream/fetch-upstream.js'; import { PerformanceMonitor } from '../../src/utils/performance.js'; @@ -112,5 +113,89 @@ describe('Pipeline modules', () => { expect(response.headers.get('Content-Length')).toBe( String(new TextEncoder().encode(body).byteLength) ); + expect(response.headers.get('Cache-Control')).toBe( + 'public, max-age=0, s-maxage=60, must-revalidate' + ); + }); + + it('uses long-lived caching for immutable package artifacts', async () => { + const artifactCases = [ + { + cacheTargetUrl: 'https://registry.npmjs.org/pkg/-/pkg-1.0.0.tgz', + effectivePath: '/npm/pkg/-/pkg-1.0.0.tgz', + platform: 'npm', + requestUrl: 'https://example.com/npm/pkg/-/pkg-1.0.0.tgz' + }, + { + cacheTargetUrl: + 'https://files.pythonhosted.org/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl', + effectivePath: '/pypi/files/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl', + platform: 'pypi-files', + requestUrl: + 'https://example.com/pypi/files/packages/py3/r/requests/requests-2.31.0-py3-none-any.whl' + }, + { + cacheTargetUrl: 'https://repo1.maven.org/maven2/org/example/demo/1.0.0/demo-1.0.0.jar', + effectivePath: '/maven/maven2/org/example/demo/1.0.0/demo-1.0.0.jar', + platform: 'maven', + requestUrl: 'https://example.com/maven/maven2/org/example/demo/1.0.0/demo-1.0.0.jar' + } + ]; + + for (const artifactCase of artifactCases) { + const request = new Request(artifactCase.requestUrl); + const requestContext = createRequestContext(request, {}); + + const response = await finalizeResponse({ + cache: null, + cacheTargetUrl: artifactCase.cacheTargetUrl, + canUseCache: true, + config: CONFIG, + ctx: /** @type {ExecutionContext} */ ({ waitUntil() {}, passThroughOnException() {} }), + effectivePath: artifactCase.effectivePath, + hasSensitiveHeaders: false, + monitor: new PerformanceMonitor(), + platform: artifactCase.platform, + request, + requestContext, + response: new Response('artifact-data', { + status: 200, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '13' + } + }), + responseGeneratedLocally: false, + url: new URL(request.url) + }); + + expect(response.headers.get('Cache-Control')).toBe( + 'public, max-age=3600, s-maxage=86400, immutable' + ); + } + }); + + it('varies npm metadata cache keys by request origin after rewriting', () => { + const targetA = resolveTarget( + new URL('https://mirror-a.example/npm/pkg'), + '/npm/pkg', + CONFIG.PLATFORMS + ); + const targetB = resolveTarget( + new URL('https://mirror-b.example/npm/pkg'), + '/npm/pkg', + CONFIG.PLATFORMS + ); + + expect('cacheTargetUrl' in targetA && targetA.cacheTargetUrl).toContain( + '__xget_origin=https%3A%2F%2Fmirror-a.example' + ); + expect('cacheTargetUrl' in targetB && targetB.cacheTargetUrl).toContain( + '__xget_origin=https%3A%2F%2Fmirror-b.example' + ); + expect('cacheTargetUrl' in targetA && 'cacheTargetUrl' in targetB).toBe(true); + if ('cacheTargetUrl' in targetA && 'cacheTargetUrl' in targetB) { + expect(targetA.cacheTargetUrl).not.toBe(targetB.cacheTargetUrl); + } }); });