UNPKG

@graphql-hive/core

Version:
159 lines (158 loc) 5.89 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'; 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; const persistedDocumentsCache = LRU((_a = config.cache) !== null && _a !== void 0 ? _a : 10000); 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 CircuitBreaker(async function doFetch(cdnDocumentId) { const signal = circuitBreaker.getSignal(); return await 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 : defaultCircuitBreakerConfiguration)), { timeout: false, autoRenewAbortController: true })); return circuitBreaker; }); /** Batch load a persisted documents */ function loadPersistedDocument(documentId) { const validationError = validateDocumentId(documentId); if (validationError) { // Return a promise that will be rejected with a proper error return Promise.reject(createValidationError(documentId, validationError.error)); } const document = persistedDocumentsCache.get(documentId); if (document) { return document; } let promise = fetchCache.get(documentId); if (promise) { return promise; } promise = Promise.resolve() .then(async () => { const cdnDocumentId = documentId.replaceAll('~', '/'); let lastError = null; for (const breaker of circuitBreakers) { try { return await breaker.fire(cdnDocumentId); } 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(result => { persistedDocumentsCache.set(documentId, result); return result; }) .finally(() => { fetchCache.delete(documentId); }); fetchCache.set(documentId, promise); return promise; } return { allowArbitraryDocuments, resolve: loadPersistedDocument, dispose() { circuitBreakers.map(breaker => breaker.shutdown()); }, }; }