UNPKG

@graphql-hive/core

Version:
267 lines (266 loc) • 11.5 kB
import { lru } from 'tiny-lru'; import CircuitBreaker from '../circuit-breaker/circuit.js'; import { defaultCircuitBreakerConfiguration } from './circuit-breaker.js'; import { http } from './http-client.js'; import { PERSISTED_DOCUMENT_NOT_FOUND, } from './types.js'; function isRequestOk(response) { return response.status === 200 || response.status === 404; } /** * Validates the format of a persisted document ID. * Expected format: "name~version~hash" (e.g., "client-name~client-version~hash") * @param documentId The document ID to validate * @returns Validation result with error message if invalid, null if valid */ function validateDocumentId(documentId) { if (!documentId || typeof documentId !== 'string') { return { error: 'Expected format: "name~version~hash" (e.g., "client-name~client-version~hash")', }; } const parts = documentId.split('~'); if (parts.length !== 3) { return { error: 'Expected format: "name~version~hash" (e.g., "client-name~client-version~hash")', }; } const [name, version, hash] = parts; // Validate each part if (!name || name.trim() === '') { return { error: 'Name cannot be empty. Expected format: "name~version~hash"', }; } if (!version || version.trim() === '') { return { error: 'Version cannot be empty. Expected format: "name~version~hash"', }; } if (!hash || hash.trim() === '') { return { error: 'Hash cannot be empty. Expected format: "name~version~hash" (e.g., "client-name~client-version~hash")', }; } return null; } /** * Error class for validation errors that will result in HTTP 400 status */ class PersistedDocumentValidationError extends Error { constructor(documentId, error) { super(`Invalid document ID "${documentId}": ${error}`); this.code = 'INVALID_DOCUMENT_ID'; this.status = 400; this.name = 'PersistedDocumentValidationError'; } } /** * Creates a validation error that will result in HTTP 400 status * @param documentId The invalid document ID * @param error The validation error */ function createValidationError(documentId, error) { return new PersistedDocumentValidationError(documentId, error); } export function createPersistedDocuments(config) { var _a, _b, _c, _d, _e, _f, _g, _h; // L2 const layer2Cache = (_a = config.layer2Cache) === null || _a === void 0 ? void 0 : _a.cache; let layer2TtlSeconds = (_b = config.layer2Cache) === null || _b === void 0 ? void 0 : _b.ttlSeconds; let layer2NotFoundTtlSeconds = (_d = (_c = config.layer2Cache) === null || _c === void 0 ? void 0 : _c.notFoundTtlSeconds) !== null && _d !== void 0 ? _d : 60; const layer2KeyPrefix = (_f = (_e = config.layer2Cache) === null || _e === void 0 ? void 0 : _e.keyPrefix) !== null && _f !== void 0 ? _f : ''; const layer2WaitUntil = (_g = config.layer2Cache) === null || _g === void 0 ? void 0 : _g.waitUntil; // Validate L2 cache options if (layer2TtlSeconds !== undefined && layer2TtlSeconds < 0) { config.logger.warn('Negative ttlSeconds (%d) provided for L2 cache; treating as no expiration', layer2TtlSeconds); layer2TtlSeconds = undefined; } if (layer2NotFoundTtlSeconds !== undefined && layer2NotFoundTtlSeconds < 0) { config.logger.warn('Negative notFoundTtlSeconds (%d) provided for L2 cache; treating as no expiration', layer2NotFoundTtlSeconds); layer2NotFoundTtlSeconds = undefined; } let allowArbitraryDocuments; if (typeof config.allowArbitraryDocuments === 'boolean') { let value = config.allowArbitraryDocuments; allowArbitraryDocuments = () => value; } else if (typeof config.allowArbitraryDocuments === 'function') { allowArbitraryDocuments = config.allowArbitraryDocuments; } else { allowArbitraryDocuments = () => false; } /** if there is already a in-flight request for a document or manifest, we re-use it. */ const cdnCache = lru((_h = config.cache) !== null && _h !== void 0 ? _h : 10000); const fetchCache = new Map(); const endpoints = Array.isArray(config.cdn.endpoint) ? config.cdn.endpoint : [config.cdn.endpoint]; const circuitBreakers = endpoints.map(endpoint => { var _a; const circuitBreaker = new CircuitBreaker(async function doFetch(pathname) { const signal = circuitBreaker.getSignal(); return await http .get(endpoint + '/apps/' + pathname, { headers: { 'X-Hive-CDN-Key': config.cdn.accessToken, }, logger: config.logger, isRequestOk, fetchImplementation: config.fetch, signal, retry: config.retry, }) .then(async (response) => { if (response.status !== 200) { return null; } return response.text(); }); }, Object.assign(Object.assign({}, ((_a = config.circuitBreaker) !== null && _a !== void 0 ? _a : defaultCircuitBreakerConfiguration)), { timeout: false, autoRenewAbortController: true })); return circuitBreaker; }); function fetchFromCDN(documentIdOrPathname, context) { const cached = cdnCache.get(documentIdOrPathname); if (cached !== undefined) { return Promise.resolve(cached); } let promise = fetchCache.get(documentIdOrPathname); if (promise) { return promise; } promise = Promise.resolve() .then(async () => { // L2 cache check const l2Result = await getFromLayer2Cache(documentIdOrPathname); if (l2Result.hit) { // L2 cache hit, store in L1 for faster subsequent access cdnCache.set(documentIdOrPathname, l2Result.value); return { value: l2Result.value, fromL2: true }; } // CDN fetch const pathname = documentIdOrPathname.replaceAll('~', '/'); let lastError = null; for (const breaker of circuitBreakers) { try { const result = await breaker.fire(pathname); return { value: result, fromL2: false }; } catch (error) { config.logger.debug({ error }); lastError = error; } } if (lastError) { config.logger.error({ error: lastError }); } if (pathname === documentIdOrPathname) { // manifest throw new Error('Failed to look up persisted operations manifest.'); } else { // persisted documents (because ~ was replaced) throw new Error('Failed to look up persisted operation.'); } }) .then(({ value, fromL2 }) => { // Store in L1 cache (in-memory), only if not already stored from L2 hit if (!fromL2) { cdnCache.set(documentIdOrPathname, value); // Store in L2 cache (async, non-blocking), only for CDN fetched data setInLayer2Cache(documentIdOrPathname, value, context === null || context === void 0 ? void 0 : context.waitUntil); } return value; }) .finally(() => { fetchCache.delete(documentIdOrPathname); }); fetchCache.set(documentIdOrPathname, promise); return promise; } // Attempt to get document from L2 cache, returns: { hit: true, value: string | null } or { hit: false } async function getFromLayer2Cache(documentId) { if (!layer2Cache) { return { hit: false }; } let cached; try { cached = await layer2Cache.get(layer2KeyPrefix + documentId); } catch (error) { // L2 cache failure should not break the request config.logger.warn('L2 cache get failed for document %s: %O', documentId, error); return { hit: false }; } if (cached === null) { // Cache miss return { hit: false }; } if (cached === PERSISTED_DOCUMENT_NOT_FOUND) { // Negative cache hit, document was previously not found config.logger.debug('L2 cache negative hit for document %s', documentId); return { hit: true, value: null }; } // Cache hit with document config.logger.debug('L2 cache hit for document %s', documentId); return { hit: true, value: cached }; } // store document in L2 cache (fire-and-forget, non-blocking) function setInLayer2Cache(documentId, value, waitUntil) { if (!(layer2Cache === null || layer2Cache === void 0 ? void 0 : layer2Cache.set)) { return; } // Skip negative caching if TTL is 0 if (value === null && layer2NotFoundTtlSeconds === 0) { return; } const cacheValue = value === null ? PERSISTED_DOCUMENT_NOT_FOUND : value; const ttl = value === null ? layer2NotFoundTtlSeconds : layer2TtlSeconds; // Fire-and-forget. don't await, don't block const setPromise = layer2Cache.set(layer2KeyPrefix + documentId, cacheValue, ttl ? { ttl } : undefined); if (setPromise) { const handledPromise = Promise.resolve(setPromise).then(() => { config.logger.debug('L2 cache set succeeded for document %s', documentId); }, error => { config.logger.warn('L2 cache set failed for document %s: %O', documentId, error); }); // Register with waitUntil for serverless environments // Config waitUntil takes precedence over context waitUntil const effectiveWaitUntil = layer2WaitUntil !== null && layer2WaitUntil !== void 0 ? layer2WaitUntil : waitUntil; if (effectiveWaitUntil) { try { effectiveWaitUntil(handledPromise); } catch (error) { config.logger.warn('Failed to register L2 cache write with waitUntil: %O', error); } } } } /** Load a persisted document with validation and L1 -> L2 -> CDN fallback */ function loadPersistedDocument(documentId, context) { const validationError = validateDocumentId(documentId); if (validationError) { return Promise.reject(createValidationError(documentId, validationError.error)); } return fetchFromCDN(documentId, context); } function loadManifest(deployment, context) { return fetchFromCDN(`${deployment.appName}/${deployment.appVersion}`, context) .then(result => (result ? JSON.parse(result) : null)) .catch(err => { config.logger.error({ err, deployment }, 'Failed to load persisted documents manifest'); throw err; }); } return { allowArbitraryDocuments, resolve: loadPersistedDocument, manifest: loadManifest, dispose() { circuitBreakers.map(breaker => breaker.shutdown()); cdnCache.clear(); fetchCache.clear(); }, }; }