diff --git a/integration/base.ts b/integration/base.ts index dc9a2d442..9617da478 100644 --- a/integration/base.ts +++ b/integration/base.ts @@ -53,6 +53,7 @@ import type { import * as FeatureFlags from '../src/feature-flags.js' import { GhostApi } from '../src/ghost.js' import { rawHtml } from '../src/html.js' +import * as HttpCache from '../src/HttpCache.js' import type { AuthorInviteStoreEnv, ContactEmailAddressStoreEnv, @@ -1326,6 +1327,7 @@ const appFixtures: Fixtures, PlaywrightTestArg Effect.provideService(PublicUrl, new URL(`http://localhost:${port}`)), Effect.provideService(SessionSecret, Redacted.make('')), Effect.provide(NodeHttpClient.layer), + Effect.provide(HttpCache.layer), Effect.provideService(FetchHttpClient.Fetch, fetch as unknown as typeof globalThis.fetch), Effect.provide(LibsqlClient.layer({ url: `file:${testInfo.outputPath('database.db')}` })), Effect.provide(TemplatePage.optionsLayer({ fathomId: Option.none(), environmentLabel: Option.none() })), diff --git a/package-lock.json b/package-lock.json index 8c40d1896..8d0959793 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "cookie-signature": "^1.2.2", "crossref-ts": "^0.1.5", "datacite-ts": "^0.1.4", + "deep-object-diff": "^1.1.9", "doi-ts": "^0.1.10", "effect": "^3.7.2", "express": "^4.21.2", @@ -11760,6 +11761,12 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==", + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", @@ -33132,6 +33139,11 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "deep-object-diff": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/deep-object-diff/-/deep-object-diff-1.1.9.tgz", + "integrity": "sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==" + }, "deepmerge": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz", diff --git a/package.json b/package.json index 9847a3ef1..23b9d282d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "cookie-signature": "^1.2.2", "crossref-ts": "^0.1.5", "datacite-ts": "^0.1.4", + "deep-object-diff": "^1.1.9", "doi-ts": "^0.1.10", "effect": "^3.7.2", "express": "^4.21.2", diff --git a/src/CachingHttpClient.ts b/src/CachingHttpClient.ts new file mode 100644 index 000000000..b9649f8e8 --- /dev/null +++ b/src/CachingHttpClient.ts @@ -0,0 +1,106 @@ +import { + Headers, + HttpClient, + HttpClientResponse, + UrlParams, + type HttpClientError, + type HttpClientRequest, +} from '@effect/platform' +import { diff } from 'deep-object-diff' +import { DateTime, Effect, pipe, type Scope } from 'effect' +import * as HttpCache from './HttpCache.js' + +export const CachingHttpClient: Effect.Effect< + HttpClient.HttpClient, + never, + HttpCache.HttpCache | HttpClient.HttpClient +> = Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient + const cache = yield* HttpCache.HttpCache + + const cachingBehaviour = ( + request: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const timestamp = yield* DateTime.now + const req = yield* request + const key = keyForRequest(req) + const response = yield* cache.get(key) + + if (response) { + if (DateTime.lessThan(timestamp, response.staleAt)) { + yield* Effect.logDebug('Cache hit') + } else { + yield* Effect.logDebug('Cache stale') + yield* Effect.forkDaemon( + Effect.gen(function* () { + yield* pipe( + req, + httpClient.execute, + Effect.tap(response => + cache.set(key, { staleAt: DateTime.addDuration(timestamp, '10 seconds'), response }), + ), + Effect.scoped, + ) + }), + ) + } + return HttpClientResponse.fromWeb( + req, + new Response(response.response.body, { + status: response.response.status, + headers: Headers.fromInput(response.response.headers), + }), + ) + } else { + yield* Effect.logDebug('Cache miss') + } + + return yield* pipe( + req, + httpClient.execute, + Effect.tap(response => + pipe(cache.set(key, { staleAt: DateTime.addDuration(timestamp, '10 seconds'), response }), Effect.ignore), + ), + Effect.tap(response => + Effect.gen(function* () { + const cachedValue = yield* cache.get(key) + if (cachedValue === undefined) { + return yield* Effect.logError('cache entry not found') + } + const origResponse = { + status: response.status, + headers: { ...response.headers }, + body: yield* response.text, + } + const cachedResponse = { + status: cachedValue.response.status, + headers: cachedValue.response.headers, + body: cachedValue.response.body, + } + const difference = diff(origResponse, cachedResponse) + function replacer(_: unknown, value: unknown) { + if (value == undefined) { + return null + } + return value + } + if (Object.keys(difference).length !== 0) { + return yield* Effect.logError('cached response does not equal original').pipe( + Effect.annotateLogs({ diff: JSON.parse(JSON.stringify(difference, replacer)) }), + ) + } + }), + ), + ) + }) + + return HttpClient.makeWith(cachingBehaviour, Effect.succeed) +}) + +const keyForRequest = (request: HttpClientRequest.HttpClientRequest): HttpCache.CacheKey => { + const url = new URL(request.url) + url.search = UrlParams.toString(request.urlParams) + + return url +} diff --git a/src/GhostPage.ts b/src/GhostPage.ts index 4f1ee4cbc..894f2e911 100644 --- a/src/GhostPage.ts +++ b/src/GhostPage.ts @@ -2,6 +2,7 @@ import { FetchHttpClient, Headers, HttpClient, HttpClientRequest, UrlParams } fr import { Context, Data, Effect, flow, Layer, Match, pipe } from 'effect' import * as R from 'fp-ts/lib/Reader.js' import type * as TE from 'fp-ts/lib/TaskEither.js' +import { CachingHttpClient } from './CachingHttpClient.js' import * as FptsToEffect from './FptsToEffect.js' import { getPage, getPageWithEffect, GhostApi } from './ghost.js' import type { Html } from './html.js' @@ -90,7 +91,7 @@ const loggingHttpClient = (client: HttpClient.HttpClient) => export const layer = Layer.effect( GetPageFromGhost, Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient + const httpClient = yield* CachingHttpClient const fetch = yield* FetchHttpClient.Fetch const ghostApi = yield* GhostApi return id => diff --git a/src/HttpCache.ts b/src/HttpCache.ts new file mode 100644 index 000000000..d0444a98a --- /dev/null +++ b/src/HttpCache.ts @@ -0,0 +1,53 @@ +import { Headers, type HttpClientResponse } from '@effect/platform' +import { Context, type DateTime, Effect, Layer, pipe, Schema } from 'effect' + +interface CacheValue { + staleAt: DateTime.DateTime + response: StoredResponse +} + +interface CacheInput { + staleAt: DateTime.DateTime + response: HttpClientResponse.HttpClientResponse +} + +export type CacheKey = URL + +type StoredResponse = typeof StoredResponseSchema.Encoded + +const StoredResponseSchema = Schema.Struct({ + status: Schema.Number, + headers: Headers.schema, + body: Schema.String, +}) + +export class HttpCache extends Context.Tag('HttpCache')< + HttpCache, + { + get: (key: CacheKey) => Effect.Effect + set: (key: CacheKey, value: CacheInput) => Effect.Effect + delete: (key: CacheKey) => Effect.Effect + } +>() {} + +export const layer = Layer.sync(HttpCache, () => { + const cache = new Map() + return { + get: key => Effect.succeed(cache.get(key.href)), + set: (key, input) => + pipe( + Effect.gen(function* () { + return { + status: input.response.status, + headers: input.response.headers, + body: yield* input.response.text, + } + }), + Effect.andThen(Schema.encode(StoredResponseSchema)), + Effect.andThen(storedResponse => { + cache.set(key.href, { staleAt: input.staleAt, response: storedResponse }) + }), + ), + delete: key => Effect.succeed(cache.delete(key.href)), + } +}) diff --git a/src/index.ts b/src/index.ts index 7a092c3b4..721a2eb6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { ExpressConfigLive } from './ExpressServer.js' import * as FeatureFlags from './feature-flags.js' import * as FptsToEffect from './FptsToEffect.js' import { GhostApi } from './ghost.js' +import * as HttpCache from './HttpCache.js' import * as Nodemailer from './nodemailer.js' import { Program } from './Program.js' import { PublicUrl } from './public-url.js' @@ -27,6 +28,7 @@ pipe( NodeHttpServer.layerConfig(() => createServer(), { port: Config.succeed(3000) }), Layer.effect(ExpressConfig, ExpressConfigLive), NodeHttpClient.layer, + HttpCache.layer, Layer.effect( FetchHttpClient.Fetch, Effect.gen(function* () {