import { createKeyedFingerprint } from "@homarr/common/server"; import type { Modify } from "@homarr/common/types"; import type { Integration, IntegrationSecret } from "@homarr/db/schema"; import type { IntegrationKind } from "@homarr/definitions"; import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis"; import { createIntegrationSharedCacheAsync, hashSharedCacheInput } from "./shared-cache"; import { createRequestHandler } from "./request-handler"; import type { SharedCacheAdapter } from "./request-handler"; type IntegrationOfKind = Omit & { kind: TKind; decryptedSecrets: Modify< Pick & Partial>, { value: string } >[]; externalUrl: string | null; }; interface Options> { requestAsync: (integration: IntegrationOfKind, input: TInput, signal: AbortSignal) => Promise; cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; requestTimeoutMs?: number; cacheNamespace?: string; cacheVersion?: string; } const getIntegrationCacheIdentity = >({ integration, options, }: { integration: IntegrationOfKind; options: TInput; }) => { const secretRevisions = (integration.decryptedSecrets ?? []) .map((secret) => ({ kind: secret.kind, fingerprint: createKeyedFingerprint(JSON.stringify([secret.kind, secret.value])), updatedAt: secret.updatedAt?.toISOString(), })) .toSorted((left, right) => { const kindComparison = left.kind.localeCompare(right.kind); if (kindComparison !== 0) return kindComparison; return left.fingerprint.localeCompare(right.fingerprint); }); return hashSharedCacheInput({ integration: { id: integration.id, kind: integration.kind, url: integration.url, externalUrl: integration.externalUrl, secretRevisions, }, options, }); }; export const createIntegrationRequestHandler = < TData, TKind extends IntegrationKind, TInput extends Record, >( options: Options, ) => { const cacheNamespace = options.cacheNamespace; let getSharedCacheAsync: | (( input: { integration: IntegrationOfKind; options: TInput }, cacheIdentity: string, ) => Promise>) | undefined; if (cacheNamespace) { getSharedCacheAsync = async ({ integration }, cacheIdentity) => await createIntegrationSharedCacheAsync({ namespace: cacheNamespace, integrationId: integration.id, cacheIdentity, cacheVersion: options.cacheVersion, }); } const inner = createRequestHandler; options: TInput }>({ requestAsync: async ({ integration, options: itemOptions }, signal) => options.requestAsync(integration, itemOptions, signal), getCacheKey: getIntegrationCacheIdentity, cacheTtlMs: options.cacheTtlMs, fallbackToStaleOnError: options.fallbackToStaleOnError, staleIfErrorTtlMs: options.staleIfErrorTtlMs, requestTimeoutMs: options.requestTimeoutMs, getSharedCacheAsync, }); return { invalidateCache: inner.invalidateCache, /** Clears this handler's L1 and every shared response-cache namespace for these integrations. */ invalidateCacheAsync: async (integrationIds: readonly string[]) => { inner.invalidateCache(); if (!cacheNamespace) return; await Promise.all( [...new Set(integrationIds)].map(async (integrationId) => { await invalidateIntegrationResponseCacheAsync(integrationId); }), ); }, handler: (integration: IntegrationOfKind, itemOptions: TInput) => inner.handler({ integration, options: itemOptions }), }; };