UNPKG

@graphql-hive/core

Version:
248 lines (247 loc) • 10.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createPersistedDocuments = createPersistedDocuments; const tslib_1 = require("tslib"); const tiny_lru_1 = tslib_1.__importDefault(require("tiny-lru")); const circuit_js_1 = tslib_1.__importDefault(require("../circuit-breaker/circuit.js")); const circuit_breaker_js_1 = require("./circuit-breaker.js"); const http_client_js_1 = require("./http-client.js"); const types_js_1 = require("./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); } function createPersistedDocuments(config) { var _a, _b, _c, _d, _e, _f; // L1 const persistedDocumentsCache = (0, tiny_lru_1.default)((_a = config.cache) !== null && _a !== void 0 ? _a : 10000); // L2 const layer2Cache = (_b = config.layer2Cache) === null || _b === void 0 ? void 0 : _b.cache; const layer2TtlSeconds = (_c = config.layer2Cache) === null || _c === void 0 ? void 0 : _c.ttlSeconds; const layer2NotFoundTtlSeconds = (_e = (_d = config.layer2Cache) === null || _d === void 0 ? void 0 : _d.notFoundTtlSeconds) !== null && _e !== void 0 ? _e : 60; const layer2WaitUntil = (_f = config.layer2Cache) === null || _f === void 0 ? void 0 : _f.waitUntil; 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, we re-use it. */ 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 circuit_js_1.default(async function doFetch(cdnDocumentId) { const signal = circuitBreaker.getSignal(); return await http_client_js_1.http .get(endpoint + '/apps/' + cdnDocumentId, { 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; } const text = await response.text(); return text; }); }, Object.assign(Object.assign({}, ((_a = config.circuitBreaker) !== null && _a !== void 0 ? _a : circuit_breaker_js_1.defaultCircuitBreakerConfiguration)), { timeout: false, autoRenewAbortController: true })); return circuitBreaker; }); // 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(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 === types_js_1.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 ? types_js_1.PERSISTED_DOCUMENT_NOT_FOUND : value; const ttl = value === null ? layer2NotFoundTtlSeconds : layer2TtlSeconds; // Fire-and-forget. don't await, don't block const setPromise = layer2Cache.set(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) { // Validate document ID format first const validationError = validateDocumentId(documentId); if (validationError) { // Return a promise that will be rejected with a proper error return Promise.reject(createValidationError(documentId, validationError.error)); } // L1 cache check (in-memory) // Note: We need to distinguish between "not in cache" (undefined) and "cached as not found" (null) const cachedDocument = persistedDocumentsCache.get(documentId); if (cachedDocument !== undefined) { // Cache hit, return the value return cachedDocument; } // Check in-flight requests let promise = fetchCache.get(documentId); if (promise) { return promise; } promise = Promise.resolve() .then(async () => { // L2 cache check const l2Result = await getFromLayer2Cache(documentId); if (l2Result.hit) { // L2 cache hit, store in L1 for faster subsequent access persistedDocumentsCache.set(documentId, l2Result.value); return { value: l2Result.value, fromL2: true }; } // CDN fetch const cdnDocumentId = documentId.replaceAll('~', '/'); let lastError = null; for (const breaker of circuitBreakers) { try { const result = await breaker.fire(cdnDocumentId); return { value: result, fromL2: false }; } catch (error) { config.logger.debug({ error }); lastError = error; } } if (lastError) { config.logger.error({ error: lastError }); } 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) { persistedDocumentsCache.set(documentId, value); // Store in L2 cache (async, non-blocking), only for CDN fetched data setInLayer2Cache(documentId, value, context === null || context === void 0 ? void 0 : context.waitUntil); } return value; }) .finally(() => { fetchCache.delete(documentId); }); fetchCache.set(documentId, promise); return promise; } return { allowArbitraryDocuments, resolve: loadPersistedDocument, dispose() { circuitBreakers.map(breaker => breaker.shutdown()); }, }; }