diff --git a/backend/src/config/env.config.ts b/backend/src/config/env.config.ts index efc5685..802a19e 100644 --- a/backend/src/config/env.config.ts +++ b/backend/src/config/env.config.ts @@ -51,6 +51,7 @@ class EnvironmentConfig { // Cache Configuration readonly cache: { TTL: number; + RESOURCE_TTL: number; }; // Documentation Configuration @@ -105,9 +106,19 @@ class EnvironmentConfig { ? 0 : Math.floor(rawCacheTTL); + // Server-side resource cache freshness window (seconds). + // Within this window, resources are served from the in-memory cache without + // contacting GitHub at all. Helps absorb upstream rate limits/outages. + const rawResourceTTL = this.getNumber('RESOURCE_CACHE_TTL', 60); + const resourceTTL = + !Number.isFinite(rawResourceTTL) || rawResourceTTL < 0 + ? 0 + : Math.floor(rawResourceTTL); + // Cache Configuration (TTL in seconds, non-negative integer) this.cache = { TTL: cacheTTL, // 5 minutes by default when env var is not set + RESOURCE_TTL: resourceTTL, // 60 seconds server-side freshness by default }; // Documentation Configuration diff --git a/backend/src/routes/composable.routes.ts b/backend/src/routes/composable.routes.ts index 681ed51..bed205f 100644 --- a/backend/src/routes/composable.routes.ts +++ b/backend/src/routes/composable.routes.ts @@ -2,7 +2,7 @@ import { config } from '../config/env.config.js'; import { Request, Response } from 'express'; import { z } from 'zod'; import { ApiRouter } from '../utils/api.router.js'; -import { ResourceManager, logger, extractConditionalHeaders, setCacheHeaders, sendNotModified } from '../utils/index.js'; +import { ResourceManager, logger, checkClientCache, setCacheHeaders, sendNotModified } from '../utils/index.js'; import { sendErrorFormatted, sendFormatted } from '../utils/response.format.js'; import { ComposableImageDto, @@ -52,12 +52,11 @@ composableRouter.route({ }, handler: async (req: Request, res: Response) => { try { - // Load composable resource with metadata, supporting conditional requests - const conditionalHeaders = extractConditionalHeaders(req); - const { data: composableData, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/composable.json', conditionalHeaders); + // Load composable resource with metadata (server-side cache + stale-on-error) + const { data: composableData, metadata } = await resourceManager.getResourceWithMetadata('image/composable.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -132,12 +131,11 @@ composableRouter.route({ const requestedChannel = typeof req.query.channel === 'string' ? req.query.channel : undefined; const queryParams = requestedChannel ? { channel: requestedChannel } : undefined; - // Load composable resource with metadata, supporting conditional requests - const conditionalHeaders = extractConditionalHeaders(req); - const { data: composableData, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/composable.json', conditionalHeaders); + // Load composable resource with metadata (server-side cache + stale-on-error) + const { data: composableData, metadata } = await resourceManager.getResourceWithMetadata('image/composable.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata, queryParams)) { return sendNotModified(res, metadata, config.cache.TTL, queryParams); } diff --git a/backend/src/routes/extension.routes.ts b/backend/src/routes/extension.routes.ts index 13d9fc4..3936b5a 100644 --- a/backend/src/routes/extension.routes.ts +++ b/backend/src/routes/extension.routes.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { ApiRouter } from '../utils/api.router.js'; -import { ResourceManager, escapeHtml, logger, extractConditionalHeaders, setCacheHeaders, sendNotModified } from '../utils/index.js'; +import { ResourceManager, escapeHtml, logger, checkClientCache, setCacheHeaders, sendNotModified } from '../utils/index.js'; import { ErrorDetailsSchema, HeaderAcceptSchema } from '../schemas/api.schema.js'; import { RuntimeExtensionListSchema, @@ -88,11 +88,10 @@ extensionRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/php_extensions.json', conditionalHeaders); - - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/php_extensions.json'); + + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -152,11 +151,10 @@ extensionRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/php_extensions.json', conditionalHeaders); + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/php_extensions.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -228,12 +226,11 @@ extensionRouter.route({ const { id } = req.params as { id: string }; const imageId = escapeHtml(id); - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/php_extensions.json', conditionalHeaders); - - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { - return sendNotModified(res, metadata, config.cache.TTL); + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/php_extensions.json'); + + // Return 304 when the client's cached representation still matches (keyed by extension id) + if (checkClientCache(req, metadata, { id })) { + return sendNotModified(res, metadata, config.cache.TTL, { id }); } const extensionEntry = data?.cloud?.[id]; @@ -247,8 +244,8 @@ extensionRouter.route({ }); } - // Set cache headers - setCacheHeaders(res, metadata, config.cache.TTL); + // Set cache headers (keyed by extension id) + setCacheHeaders(res, metadata, config.cache.TTL, { id }); sendFormatted(res, extensionEntry); } catch (error: any) { @@ -295,11 +292,10 @@ extensionRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/postgresql_extensions.json', conditionalHeaders); + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/postgresql_extensions.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -371,12 +367,11 @@ extensionRouter.route({ const { id } = req.params as { id: string }; const imageId = escapeHtml(id); - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/postgresql_extensions.json', conditionalHeaders); - - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { - return sendNotModified(res, metadata, config.cache.TTL); + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/postgresql_extensions.json'); + + // Return 304 when the client's cached representation still matches (keyed by extension id) + if (checkClientCache(req, metadata, { id })) { + return sendNotModified(res, metadata, config.cache.TTL, { id }); } const extensionEntry = data?.cloud?.[id]; @@ -390,8 +385,8 @@ extensionRouter.route({ }); } - // Set cache headers - setCacheHeaders(res, metadata, config.cache.TTL); + // Set cache headers (keyed by extension id) + setCacheHeaders(res, metadata, config.cache.TTL, { id }); sendFormatted(res, extensionEntry); } catch (error: any) { @@ -438,11 +433,10 @@ extensionRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/solr_extensions.json', conditionalHeaders); + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/solr_extensions.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -514,12 +508,11 @@ extensionRouter.route({ const { id } = req.params as { id: string }; const imageId = escapeHtml(id); - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceWithMetadata('extension/solr_extensions.json', conditionalHeaders); - - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { - return sendNotModified(res, metadata, config.cache.TTL); + const { data, metadata } = await resourceManager.getResourceWithMetadata('extension/solr_extensions.json'); + + // Return 304 when the client's cached representation still matches (keyed by extension id) + if (checkClientCache(req, metadata, { id })) { + return sendNotModified(res, metadata, config.cache.TTL, { id }); } const extensionEntry = data?.cloud?.[id]; @@ -533,8 +526,8 @@ extensionRouter.route({ }); } - // Set cache headers - setCacheHeaders(res, metadata, config.cache.TTL); + // Set cache headers (keyed by extension id) + setCacheHeaders(res, metadata, config.cache.TTL, { id }); sendFormatted(res, extensionEntry); } catch (error: any) { diff --git a/backend/src/routes/image.routes.ts b/backend/src/routes/image.routes.ts index 696b38f..015fe9e 100644 --- a/backend/src/routes/image.routes.ts +++ b/backend/src/routes/image.routes.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { ApiRouter } from '../utils/api.router.js'; import { withSelfLink } from '../utils/api.schema.js'; -import { ResourceManager, escapeHtml, logger, extractConditionalHeaders, setCacheHeaders, sendNotModified } from '../utils/index.js'; +import { ResourceManager, escapeHtml, logger, checkClientCache, setCacheHeaders, sendNotModified } from '../utils/index.js'; import { sendErrorFormatted, sendFormatted } from '../utils/response.format.js'; import { DeployImageListDto, @@ -53,12 +53,11 @@ imageRouter.route({ }, handler: async (req: Request, res: Response) => { try { - // Load registry from resources with metadata, supporting conditional requests - const conditionalHeaders = extractConditionalHeaders(req); - const { data: registryRaw, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/registry.json', conditionalHeaders); + // Load registry from resources with metadata (server-side cache + stale-on-error) + const { data: registryRaw, metadata } = await resourceManager.getResourceWithMetadata('image/registry.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -123,13 +122,12 @@ imageRouter.route({ const { id } = req.params as { id: string }; const imageId = escapeHtml(id); - // Load registry from resources with metadata, supporting conditional requests - const conditionalHeaders = extractConditionalHeaders(req); - const { data: registryRaw, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/registry.json', conditionalHeaders); + // Load registry from resources with metadata (server-side cache + stale-on-error) + const { data: registryRaw, metadata } = await resourceManager.getResourceWithMetadata('image/registry.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { - return sendNotModified(res, metadata, config.cache.TTL); + // Return 304 when the client's cached representation still matches (keyed by image id) + if (checkClientCache(req, metadata, { id })) { + return sendNotModified(res, metadata, config.cache.TTL, { id }); } // Check if image exists @@ -151,8 +149,8 @@ imageRouter.route({ ? DeployImageSchemaDtoInternal.parse(imageRaw) : DeployImageSchemaDtoPublic.parse(imageRaw); - // Set cache headers - setCacheHeaders(res, metadata, config.cache.TTL); + // Set cache headers (keyed by image id) + setCacheHeaders(res, metadata, config.cache.TTL, { id }); // Send formatted response sendFormatted(res, imageParsed); diff --git a/backend/src/routes/openapi.upsun.routes.ts b/backend/src/routes/openapi.upsun.routes.ts index cbfd0c4..468402c 100644 --- a/backend/src/routes/openapi.upsun.routes.ts +++ b/backend/src/routes/openapi.upsun.routes.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { config } from '../config/env.config.js'; import { ApiRouter } from '../utils/api.router.js'; -import { ResourceManager, logger, extractConditionalHeaders, setCacheHeaders, sendNotModified } from '../utils/index.js'; +import { ResourceManager, logger, checkClientCache, setCacheHeaders, sendNotModified } from '../utils/index.js'; import { HeaderAcceptSchema, ErrorDetailsSchema } from '../schemas/api.schema.js'; const TAG = 'OpenAPI Specification'; @@ -63,17 +63,17 @@ openapiRouter.route({ fileName = format === 'yaml' ? 'openapispec-upsun.yaml' : 'openapispec-upsun.json'; } try { - // serving the raw file according to the format with metadata - const conditionalHeaders = extractConditionalHeaders(req); - const { data, metadata, notModified } = await resourceManager.getResourceRawWithMetadata(`openapi/${fileName}`, conditionalHeaders); - - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { - return sendNotModified(res, metadata, config.cache.TTL); + // serving the raw file according to the format (server-side cache + stale-on-error) + const cacheParams = { sdks: sdks ? 'true' : 'false' }; + const { data, metadata } = await resourceManager.getResourceRawWithMetadata(`openapi/${fileName}`); + + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata, cacheParams)) { + return sendNotModified(res, metadata, config.cache.TTL, cacheParams); } - + // Set cache headers - setCacheHeaders(res, metadata, config.cache.TTL); + setCacheHeaders(res, metadata, config.cache.TTL, cacheParams); if (format === 'yaml' && !sdks) { res.type('text/plain; charset=utf-8').send(data); diff --git a/backend/src/routes/validation.routes.ts b/backend/src/routes/validation.routes.ts index 48918fe..84b4001 100644 --- a/backend/src/routes/validation.routes.ts +++ b/backend/src/routes/validation.routes.ts @@ -2,7 +2,7 @@ import { Request, Response } from 'express'; import { z } from 'zod'; import { config } from '../config/env.config.js'; import { ApiRouter } from '../utils/api.router.js'; -import { ResourceManager, logger, extractConditionalHeaders, setCacheHeaders, sendNotModified } from '../utils/index.js'; +import { ResourceManager, logger, checkClientCache, setCacheHeaders, sendNotModified } from '../utils/index.js'; import { ErrorDetailsSchema } from '../schemas/api.schema.js'; import { sendErrorFormatted, sendFormatted } from '../utils/response.format.js'; import { Validation, ValidationSchema } from '../schemas/validation.schema.js'; @@ -46,11 +46,10 @@ This file is used to validate Upsun configuration files .upsun/config.yaml. }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data: schema, metadata, notModified } = await resourceManager.getResourceWithMetadata('validation/upsun.json', conditionalHeaders); + const { data: schema, metadata } = await resourceManager.getResourceWithMetadata('validation/upsun.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -90,11 +89,10 @@ validationRouter.route({ }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data: schema, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/registry.schema.json', conditionalHeaders); + const { data: schema, metadata } = await resourceManager.getResourceWithMetadata('image/registry.schema.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -150,11 +148,10 @@ The result is a JSON Schema snippet: }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data: registry, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/registry.json', conditionalHeaders); + const { data: registry, metadata } = await resourceManager.getResourceWithMetadata('image/registry.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } @@ -235,11 +232,10 @@ for example: \`["php:7.2", "php:7.3", "nodejs:24"]\`. }, handler: async (req: Request, res: Response) => { try { - const conditionalHeaders = extractConditionalHeaders(req); - const { data: registry, metadata, notModified } = await resourceManager.getResourceWithMetadata('image/registry.json', conditionalHeaders); + const { data: registry, metadata } = await resourceManager.getResourceWithMetadata('image/registry.json'); - // If upstream returned 304, respond with 304 (avoids unnecessary parsing) - if (notModified) { + // Return 304 when the client's cached representation still matches + if (checkClientCache(req, metadata)) { return sendNotModified(res, metadata, config.cache.TTL); } diff --git a/backend/src/utils/cache.manager.ts b/backend/src/utils/cache.manager.ts index 082e6ae..5ca75de 100644 --- a/backend/src/utils/cache.manager.ts +++ b/backend/src/utils/cache.manager.ts @@ -65,11 +65,19 @@ function getAcceptVariant(req: Request | undefined): 'json' | 'yaml' { /** * Build ETag context that always includes negotiated representation variant. + * + * The context captures every request input that changes the response body so + * that the generated ETag is unique per variant: + * - the negotiated representation format (Accept: json/yaml) + * - the `internal` request header (public vs internal payloads) + * - any route-specific params (path/query) provided by the caller */ function buildEtagContext(req: Request | undefined, queryParams?: Record): EtagContext { + const internal = req?.headers['internal'] === 'true' ? 'true' : undefined; return { ...(queryParams || {}), - accept: getAcceptVariant(req) + accept: getAcceptVariant(req), + ...(internal ? { internal } : {}) }; } @@ -166,53 +174,51 @@ export function checkClientCache(req: Request, metadata: ResourceMetadata, query * @param queryParams - Optional query parameters to include in ETag */ export function setCacheHeaders(res: Response, metadata: ResourceMetadata, maxAge: number = 300, queryParams?: Record): void { - // Disable caching for now while we iterate on ETag logic and monitor cache behavior in production. We will re-enable with proper ETag handling once we are confident in the implementation. - - // // Generate ETag with request-sensitive context (Accept variant + optional query params) - // const etagContext = buildEtagContext(res.req, queryParams); - // const etag = generateEtagWithParams(metadata.etag, etagContext); - - // if (etag) { - // res.setHeader('ETag', etag); - // } - - // if (metadata.lastModified) { - // res.setHeader('Last-Modified', metadata.lastModified); - // } - - // // Set Cache-Control header - // // - public: can be cached by browsers and CDNs - // // - max-age: how long the cache is fresh - // // - must-revalidate: must check with server after max-age expires - // res.setHeader('Cache-Control', `public, max-age=${maxAge}, must-revalidate`); - - // // Ensure Vary: Accept is present so caches know the response varies by Accept header - // const existingVary = res.getHeader('Vary'); - // if (existingVary === undefined) { - // res.setHeader('Vary', 'Accept'); - // } else { - // const headerValue = Array.isArray(existingVary) - // ? existingVary.join(',') - // : String(existingVary); - // const varySet = new Set(); - // headerValue.split(',').forEach(value => { - // const trimmed = value.trim(); - // if (trimmed) { - // varySet.add(trimmed); - // } - // }); - // let hasAccept = false; - // for (const v of varySet) { - // if (v.toLowerCase() === 'accept') { - // hasAccept = true; - // break; - // } - // } - // if (!hasAccept) { - // varySet.add('Accept'); - // } - // res.setHeader('Vary', Array.from(varySet).join(', ')); - // } + // Generate ETag with request-sensitive context (Accept variant + internal header + optional params) + const etagContext = buildEtagContext(res.req, queryParams); + const etag = generateEtagWithParams(metadata.etag, etagContext); + + if (etag) { + res.setHeader('ETag', etag); + } + + if (metadata.lastModified) { + res.setHeader('Last-Modified', metadata.lastModified); + } + + // Set Cache-Control header + // - public: can be cached by browsers and CDNs + // - max-age: how long the cache is fresh + // - must-revalidate: must check with server after max-age expires + res.setHeader('Cache-Control', `public, max-age=${maxAge}, must-revalidate`); + + // Ensure Vary: Accept is present so caches know the response varies by Accept header + const existingVary = res.getHeader('Vary'); + if (existingVary === undefined) { + res.setHeader('Vary', 'Accept'); + } else { + const headerValue = Array.isArray(existingVary) + ? existingVary.join(',') + : String(existingVary); + const varySet = new Set(); + headerValue.split(',').forEach(value => { + const trimmed = value.trim(); + if (trimmed) { + varySet.add(trimmed); + } + }); + let hasAccept = false; + for (const v of varySet) { + if (v.toLowerCase() === 'accept') { + hasAccept = true; + break; + } + } + if (!hasAccept) { + varySet.add('Accept'); + } + res.setHeader('Vary', Array.from(varySet).join(', ')); + } } /** diff --git a/backend/src/utils/resource.manager.ts b/backend/src/utils/resource.manager.ts index 597382c..647b693 100644 --- a/backend/src/utils/resource.manager.ts +++ b/backend/src/utils/resource.manager.ts @@ -41,9 +41,26 @@ export interface ConditionalHeaders { ifModifiedSince?: string; } +/** + * In-memory cache entry for a GitHub resource. + * Stores the upstream (GitHub) ETag/Last-Modified so we can revalidate with + * conditional requests, and the timestamp of the last successful fetch. + */ +interface ResourceCacheEntry { + data: T; + etag?: string; + lastModified?: string; + fetchedAt: number; // epoch ms of last successful fetch/revalidation +} + export class ResourceManager { private config: ResourceConfig; + // Server-side in-memory caches keyed by filePath. + // Parsed (JSON/YAML) and raw (string) representations are cached separately. + private parsedCache = new Map(); + private rawCache = new Map>(); + constructor() { this.config = { mode: config.resources.MODE, @@ -52,6 +69,19 @@ export class ResourceManager { }; } + /** + * Whether a cache entry is still within the server-side freshness window. + * When RESOURCE_TTL is 0, entries are never considered fresh and GitHub is + * always revalidated (conditionally), but stale-on-error still applies. + */ + private isCacheFresh(entry: ResourceCacheEntry | undefined, now: number): entry is ResourceCacheEntry { + if (!entry) { + return false; + } + const freshMs = config.cache.RESOURCE_TTL * 1000; + return freshMs > 0 && now - entry.fetchedAt < freshMs; + } + /** * Get the content of a resource file * @param filePath - Relative path to the file (e.g., 'image/registry.json') @@ -66,18 +96,24 @@ export class ResourceManager { /** * Get the content of a resource file with metadata (etag, last-modified). - * Supports conditional requests (If-None-Match / If-Modified-Since, 304/notModified) - * for GitHub mode only to avoid unnecessary downloads. In local mode, any - * provided conditionalHeaders are ignored and notModified will never be set. + * + * In GitHub mode this is backed by an in-memory server-side cache: + * - Within the freshness window (RESOURCE_TTL) data is served from cache with + * no upstream request. + * - Otherwise GitHub is revalidated with a conditional request using the + * cached upstream ETag; a 304 refreshes the cache without re-downloading. + * - If GitHub is unreachable or errors, the last known good (stale) copy is + * served when available (stale-on-error), so transient upstream failures do + * not break the API. + * + * In local mode the file is read fresh on every call. * @param filePath - Relative path to the file (e.g., 'image/registry.json') - * @param conditionalHeaders - Optional If-None-Match/If-Modified-Since headers for conditional requests (GitHub mode only) */ - async getResourceWithMetadata(filePath: string, conditionalHeaders?: ConditionalHeaders): Promise { + async getResourceWithMetadata(filePath: string): Promise { if (this.config.mode === 'local') { return this.getLocalResourceWithMetadata(filePath); - } else { - return this.getGithubResourceWithMetadata(filePath, conditionalHeaders); } + return this.getGithubParsedCached(filePath); } /** @@ -94,17 +130,126 @@ export class ResourceManager { /** * Get raw content of a resource file with metadata (no parsing). - * Supports conditional requests (If-None-Match / If-Modified-Since, 304/notModified) - * for GitHub mode only to avoid unnecessary downloads. In local mode, any - * provided conditionalHeaders are ignored and notModified will never be set. + * + * In GitHub mode this is backed by the same in-memory server-side cache and + * stale-on-error behaviour as {@link getResourceWithMetadata}. In local mode + * the file is read fresh on every call. * @param filePath - Relative path to the file (e.g., 'image/registry.json') - * @param conditionalHeaders - Optional If-None-Match/If-Modified-Since headers for conditional requests (GitHub mode only) */ - async getResourceRawWithMetadata(filePath: string, conditionalHeaders?: ConditionalHeaders): Promise> { + async getResourceRawWithMetadata(filePath: string): Promise> { if (this.config.mode === 'local') { return this.getLocalResourceRawWithMetadata(filePath); - } else { - return this.getGithubResourceRawWithMetadata(filePath, conditionalHeaders); + } + return this.getGithubRawCached(filePath); + } + + /** + * Serve a parsed GitHub resource through the server-side cache. + * Handles freshness, conditional revalidation and stale-on-error fallback. + */ + private async getGithubParsedCached(filePath: string): Promise { + const now = Date.now(); + const cached = this.parsedCache.get(filePath); + + if (this.isCacheFresh(cached, now)) { + resourceLogger.debug({ filePath }, 'Serving parsed resource from fresh server cache'); + return { + data: cached.data, + metadata: { etag: cached.etag, lastModified: cached.lastModified, source: 'github' } + }; + } + + try { + const conditional: ConditionalHeaders | undefined = cached + ? { ifNoneMatch: cached.etag, ifModifiedSince: cached.lastModified } + : undefined; + const result = await this.getGithubResourceWithMetadata(filePath, conditional); + + if (result.notModified && cached) { + cached.fetchedAt = now; + if (result.metadata.etag) cached.etag = result.metadata.etag; + if (result.metadata.lastModified) cached.lastModified = result.metadata.lastModified; + resourceLogger.debug({ filePath }, 'Revalidated parsed resource (304), serving cached copy'); + return { + data: cached.data, + metadata: { etag: cached.etag, lastModified: cached.lastModified, source: 'github' } + }; + } + + this.parsedCache.set(filePath, { + data: result.data, + etag: result.metadata.etag, + lastModified: result.metadata.lastModified, + fetchedAt: now + }); + return result; + } catch (error: any) { + if (cached) { + resourceLogger.warn( + { filePath, error: error?.message }, + 'GitHub fetch failed; serving stale cached resource (stale-on-error)' + ); + return { + data: cached.data, + metadata: { etag: cached.etag, lastModified: cached.lastModified, source: 'github' } + }; + } + throw error; + } + } + + /** + * Serve a raw GitHub resource through the server-side cache. + * Handles freshness, conditional revalidation and stale-on-error fallback. + */ + private async getGithubRawCached(filePath: string): Promise> { + const now = Date.now(); + const cached = this.rawCache.get(filePath); + + if (this.isCacheFresh(cached, now)) { + resourceLogger.debug({ filePath }, 'Serving raw resource from fresh server cache'); + return { + data: cached.data, + metadata: { etag: cached.etag, lastModified: cached.lastModified, source: 'github' } + }; + } + + try { + const conditional: ConditionalHeaders | undefined = cached + ? { ifNoneMatch: cached.etag, ifModifiedSince: cached.lastModified } + : undefined; + const result = await this.getGithubResourceRawWithMetadata(filePath, conditional); + + if (result.notModified && cached) { + cached.fetchedAt = now; + if (result.metadata.etag) cached.etag = result.metadata.etag; + if (result.metadata.lastModified) cached.lastModified = result.metadata.lastModified; + resourceLogger.debug({ filePath }, 'Revalidated raw resource (304), serving cached copy'); + return { + data: cached.data, + metadata: { etag: cached.etag, lastModified: cached.lastModified, source: 'github' } + }; + } + + this.rawCache.set(filePath, { + data: result.data as string, + etag: result.metadata.etag, + lastModified: result.metadata.lastModified, + fetchedAt: now + }); + return result; + } catch (error: any) { + if (cached) { + resourceLogger.warn( + { filePath, error: error?.message }, + 'GitHub fetch failed; serving stale cached raw resource (stale-on-error)' + ); + return { + data: cached.data, + metadata: { etag: cached.etag, lastModified: cached.lastModified, source: 'github' } + }; + } + throw error; } }