Skip to content

Commit

Permalink
Introduce in-memory HTTP cached copying from the spike branch
Browse files Browse the repository at this point in the history
Refs: #2186, spike/effect-http-client
  • Loading branch information
erkannt committed Jan 17, 2025
1 parent 6d8459b commit 861fb72
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 1 deletion.
2 changes: 2 additions & 0 deletions integration/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1326,6 +1327,7 @@ const appFixtures: Fixtures<AppFixtures, Record<never, never>, 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() })),
Expand Down
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
106 changes: 106 additions & 0 deletions src/CachingHttpClient.ts
Original file line number Diff line number Diff line change
@@ -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<HttpClientRequest.HttpClientRequest>,
): Effect.Effect<HttpClientResponse.HttpClientResponse, HttpClientError.HttpClientError, Scope.Scope> =>
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
}
3 changes: 2 additions & 1 deletion src/GhostPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 =>
Expand Down
53 changes: 53 additions & 0 deletions src/HttpCache.ts
Original file line number Diff line number Diff line change
@@ -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<CacheValue | undefined>
set: (key: CacheKey, value: CacheInput) => Effect.Effect<void, Error>
delete: (key: CacheKey) => Effect.Effect<void>
}
>() {}

export const layer = Layer.sync(HttpCache, () => {
const cache = new Map<string, CacheValue>()
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)),
}
})
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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* () {
Expand Down

0 comments on commit 861fb72

Please sign in to comment.