Merged in feat/common-package (pull request #2333)
feat: Add common package * Add isEdge, safeTry and dataCache to new common package * Add eslint and move prettier config * Fix yarn lock * Clean up tests * Add lint-staged config to common * Add missing dependencies Approved-by: Joakim Jäderberg
This commit is contained in:
50
packages/common/dataCache/DistributedCache/cacheOrGet.ts
Normal file
50
packages/common/dataCache/DistributedCache/cacheOrGet.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
type CacheOrGetOptions,
|
||||
shouldGetFromCache,
|
||||
} from "../cacheOrGetOptions"
|
||||
import { cacheLogger } from "../logger"
|
||||
import { generateCacheKey } from "./generateCacheKey"
|
||||
import { get } from "./get"
|
||||
import { set } from "./set"
|
||||
|
||||
import type { CacheTime, DataCache } from "../Cache"
|
||||
|
||||
export const cacheOrGet: DataCache["cacheOrGet"] = async <T>(
|
||||
key: string | string[],
|
||||
callback: (overrideTTL: (cacheTime: CacheTime) => void) => Promise<T>,
|
||||
ttl: CacheTime,
|
||||
opts?: CacheOrGetOptions
|
||||
) => {
|
||||
const cacheKey = generateCacheKey(key, {
|
||||
includeGitHashInKey: opts?.includeGitHashInKey ?? true,
|
||||
})
|
||||
|
||||
let cachedValue: Awaited<T> | undefined = undefined
|
||||
if (shouldGetFromCache(opts)) {
|
||||
cachedValue = await get<T>(cacheKey)
|
||||
}
|
||||
|
||||
let realTTL = ttl
|
||||
|
||||
const overrideTTL = function (cacheTime: CacheTime) {
|
||||
realTTL = cacheTime
|
||||
}
|
||||
|
||||
if (!cachedValue) {
|
||||
const perf = performance.now()
|
||||
const data = await callback(overrideTTL)
|
||||
|
||||
const size = JSON.stringify(data).length / (1024 * 1024)
|
||||
if (size >= 5) {
|
||||
cacheLogger.warn(`'${key}' is larger than 5MB!`)
|
||||
}
|
||||
cacheLogger.debug(
|
||||
`Fetching data took ${(performance.now() - perf).toFixed(2)}ms ${size.toFixed(4)}MB for '${key}'`
|
||||
)
|
||||
|
||||
await set<T>(cacheKey, data, realTTL)
|
||||
return data
|
||||
}
|
||||
|
||||
return cachedValue
|
||||
}
|
||||
18
packages/common/dataCache/DistributedCache/client.ts
Normal file
18
packages/common/dataCache/DistributedCache/client.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { env } from "../../env/server"
|
||||
import { cacheOrGet } from "./cacheOrGet"
|
||||
import { deleteKey } from "./deleteKey"
|
||||
import { get } from "./get"
|
||||
import { set } from "./set"
|
||||
|
||||
import type { DataCache } from "../Cache"
|
||||
|
||||
export const API_KEY = env.REDIS_API_KEY ?? ""
|
||||
export async function createDistributedCache(): Promise<DataCache> {
|
||||
return {
|
||||
type: "redis",
|
||||
get,
|
||||
set,
|
||||
cacheOrGet,
|
||||
deleteKey,
|
||||
} satisfies DataCache
|
||||
}
|
||||
45
packages/common/dataCache/DistributedCache/deleteKey.ts
Normal file
45
packages/common/dataCache/DistributedCache/deleteKey.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
|
||||
import { cacheLogger } from "../logger"
|
||||
import { API_KEY } from "./client"
|
||||
import { getCacheEndpoint } from "./endpoints"
|
||||
|
||||
export async function deleteKey(key: string, opts?: { fuzzy?: boolean }) {
|
||||
const perf = performance.now()
|
||||
const endpoint = getCacheEndpoint(key)
|
||||
|
||||
if (opts?.fuzzy) {
|
||||
endpoint.searchParams.set("fuzzy", "true")
|
||||
}
|
||||
|
||||
const [response, error] = await safeTry(
|
||||
fetch(endpoint, {
|
||||
method: "DELETE",
|
||||
cache: "no-cache",
|
||||
headers: {
|
||||
"x-api-key": API_KEY,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
)
|
||||
|
||||
if (!response || !response.ok || error) {
|
||||
if (response?.status !== 404) {
|
||||
Sentry.captureException(error ?? new Error("Unable to DELETE cachekey"), {
|
||||
extra: {
|
||||
cacheKey: key,
|
||||
statusCode: response?.status,
|
||||
statusText: response?.statusText,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
cacheLogger.debug(
|
||||
`Delete '${key}' took ${(performance.now() - perf).toFixed(2)}ms`
|
||||
)
|
||||
}
|
||||
12
packages/common/dataCache/DistributedCache/endpoints.ts
Normal file
12
packages/common/dataCache/DistributedCache/endpoints.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { env } from "../../env/server"
|
||||
|
||||
export function getCacheEndpoint(key: string) {
|
||||
if (!env.REDIS_API_HOST) {
|
||||
throw new Error("REDIS_API_HOST is not set")
|
||||
}
|
||||
|
||||
const url = new URL(`/api/cache`, env.REDIS_API_HOST)
|
||||
url.searchParams.set("key", encodeURIComponent(key))
|
||||
|
||||
return url
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getBranchPrefix } from "./getBranchPrefix"
|
||||
|
||||
describe("getBranchPrefix", () => {
|
||||
it("should return empty string for production branches", () => {
|
||||
expect(getBranchPrefix("production")).toBe("")
|
||||
expect(getBranchPrefix("prod")).toBe("")
|
||||
expect(getBranchPrefix("release")).toBe("")
|
||||
expect(getBranchPrefix("release-v1")).toBe("")
|
||||
expect(getBranchPrefix("release-v1.2")).toBe("")
|
||||
expect(getBranchPrefix("release-v1.2.3")).toBe("")
|
||||
expect(getBranchPrefix("release-v1.2.3-rc1")).toBe("")
|
||||
expect(getBranchPrefix("release-v1.2-beta")).toBe("")
|
||||
expect(getBranchPrefix("release-v1-preview")).toBe("")
|
||||
})
|
||||
|
||||
it("should return branch name for non-production branches", () => {
|
||||
expect(getBranchPrefix("feature/hello")).toBe("feature/hello")
|
||||
expect(getBranchPrefix("fix/stuff")).toBe("fix/stuff")
|
||||
expect(getBranchPrefix("releasee")).toBe("releasee")
|
||||
expect(getBranchPrefix("release-vA")).toBe("release-vA")
|
||||
expect(getBranchPrefix("release-v1.A")).toBe("release-v1.A")
|
||||
expect(getBranchPrefix("release-v1.2.A")).toBe("release-v1.2.A")
|
||||
expect(getBranchPrefix("release-v1.2.A-rc1")).toBe("release-v1.2.A-rc1")
|
||||
expect(getBranchPrefix("release-v1.A-beta")).toBe("release-v1.A-beta")
|
||||
expect(getBranchPrefix("release-vA-preview")).toBe("release-vA-preview")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* This will match release branches
|
||||
* @example
|
||||
* release-v1.2.3
|
||||
* release-v1.2
|
||||
* release-v1
|
||||
* release-v1.2.3-alpha
|
||||
* release-v1.2-beta
|
||||
* release-v1-preview
|
||||
*/
|
||||
const releaseRegex = /^release-v\d+(?:\.\d+){0,2}(?:-\w+)?$/
|
||||
|
||||
/**
|
||||
* If the branch is a production branch reuse the same prefix so that we can reuse the cache between pre-prod and prod
|
||||
* @param branch
|
||||
* @returns
|
||||
*/
|
||||
export const getBranchPrefix = (branch: string) => {
|
||||
const isProdBranch =
|
||||
branch === "production" ||
|
||||
branch === "prod" ||
|
||||
branch === "release" ||
|
||||
releaseRegex.test(branch)
|
||||
|
||||
return isProdBranch ? "" : branch
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("@/env/server", () => ({
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}))
|
||||
|
||||
import { env } from "../../../env/server"
|
||||
import { getPrefix } from "./getPrefix"
|
||||
|
||||
const mockedEnv = env as { BRANCH: string; GIT_SHA: string }
|
||||
|
||||
describe("getPrefix", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it.each([
|
||||
"prod",
|
||||
"production",
|
||||
"release",
|
||||
"release-v1",
|
||||
"release-v2",
|
||||
"release-v2-alpha",
|
||||
"release-v2.1-alpha",
|
||||
"release-v2.1.2-alpha",
|
||||
])(
|
||||
"should return gitsha for production branches when name is '%s'",
|
||||
(branchName: string) => {
|
||||
mockedEnv.BRANCH = branchName
|
||||
mockedEnv.GIT_SHA = "gitsha"
|
||||
|
||||
const result = getPrefix({
|
||||
includeGitHashInKey: true,
|
||||
includeBranchPrefix: true,
|
||||
})
|
||||
|
||||
expect(result).toBe("gitsha")
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
"fix/stuff",
|
||||
"feat/my-feature",
|
||||
"feature/my-feature",
|
||||
"releasee",
|
||||
"release-vA",
|
||||
"FEAT",
|
||||
])(
|
||||
"should return branch name and gitsha for non-production branches when name is '%s'",
|
||||
(branchName: string) => {
|
||||
mockedEnv.BRANCH = branchName
|
||||
mockedEnv.GIT_SHA = "gitsha"
|
||||
|
||||
const result = getPrefix({
|
||||
includeGitHashInKey: true,
|
||||
includeBranchPrefix: true,
|
||||
})
|
||||
|
||||
expect(result).toBe(`${"gitsha"}:${branchName}`)
|
||||
}
|
||||
)
|
||||
|
||||
it("should throw if BRANCH and/or GIT_SHA is not set", () => {
|
||||
mockedEnv.BRANCH = "hasBranch"
|
||||
mockedEnv.GIT_SHA = ""
|
||||
|
||||
expect(() =>
|
||||
getPrefix({
|
||||
includeBranchPrefix: true,
|
||||
includeGitHashInKey: true,
|
||||
})
|
||||
).toThrow("Unable to getPrefix, GIT_SHA must be set")
|
||||
|
||||
mockedEnv.BRANCH = ""
|
||||
mockedEnv.GIT_SHA = "hasGitSha"
|
||||
|
||||
expect(() =>
|
||||
getPrefix({
|
||||
includeBranchPrefix: true,
|
||||
includeGitHashInKey: true,
|
||||
})
|
||||
).toThrow("Unable to getPrefix, BRANCH must be set")
|
||||
})
|
||||
|
||||
it("should return dev or local user if running locally", () => {
|
||||
vi.stubEnv("NODE_ENV", "development")
|
||||
vi.stubEnv("USER", "test_user")
|
||||
vi.stubEnv("USERNAME", "test_username")
|
||||
|
||||
mockedEnv.BRANCH = ""
|
||||
mockedEnv.GIT_SHA = ""
|
||||
|
||||
expect(
|
||||
getPrefix({
|
||||
includeGitHashInKey: false,
|
||||
includeBranchPrefix: false,
|
||||
})
|
||||
).toBe("test_user")
|
||||
|
||||
process.env.USER = ""
|
||||
|
||||
expect(
|
||||
getPrefix({
|
||||
includeGitHashInKey: false,
|
||||
includeBranchPrefix: false,
|
||||
})
|
||||
).toBe("test_username")
|
||||
|
||||
process.env.USERNAME = ""
|
||||
expect(
|
||||
getPrefix({
|
||||
includeGitHashInKey: false,
|
||||
includeBranchPrefix: false,
|
||||
})
|
||||
).toBe("dev")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { env } from "../../../env/server"
|
||||
import { getBranchPrefix } from "./getBranchPrefix"
|
||||
|
||||
export function getPrefix(options: {
|
||||
includeGitHashInKey: boolean
|
||||
includeBranchPrefix: boolean
|
||||
}): string {
|
||||
const prefixTokens = []
|
||||
|
||||
const includeGitHashInKey = options.includeGitHashInKey
|
||||
const includeBranchPrefix = options.includeBranchPrefix
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
const devPrefix = process.env.USER || process.env.USERNAME || "dev"
|
||||
return `${devPrefix}`
|
||||
}
|
||||
|
||||
if (includeGitHashInKey) {
|
||||
const gitSha = env.GIT_SHA?.trim().substring(0, 7)
|
||||
|
||||
if (!gitSha) {
|
||||
throw new Error("Unable to getPrefix, GIT_SHA must be set")
|
||||
}
|
||||
|
||||
prefixTokens.push(gitSha)
|
||||
}
|
||||
|
||||
if (includeBranchPrefix) {
|
||||
const branch = env.BRANCH?.trim()
|
||||
|
||||
if (!branch) {
|
||||
throw new Error("Unable to getPrefix, BRANCH must be set")
|
||||
}
|
||||
const branchPrefix = getBranchPrefix(branch)
|
||||
if (branchPrefix) {
|
||||
prefixTokens.push(branchPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
return prefixTokens.join(":")
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("./getPrefix", () => ({
|
||||
getPrefix: vi.fn(() => "gitsha"),
|
||||
}))
|
||||
|
||||
import { generateCacheKey } from "./index"
|
||||
|
||||
describe("generateCacheKey", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it("generates cachekey with prefix and key using string", () => {
|
||||
expect(generateCacheKey("key1")).toBe("gitsha:key1")
|
||||
})
|
||||
|
||||
it("generates cachekey with prefix and key using array", () => {
|
||||
expect(generateCacheKey(["key1"])).toBe("gitsha:key1")
|
||||
})
|
||||
|
||||
it("generates cachekey with prefix and keys", () => {
|
||||
const actual = generateCacheKey(["key1", "key2"])
|
||||
expect(actual).toBe("gitsha:key1_key2")
|
||||
})
|
||||
|
||||
it("should throw an error if no keys are provided", () => {
|
||||
expect(() => generateCacheKey([])).toThrow("No keys provided")
|
||||
})
|
||||
|
||||
it("should throw an error if only invalid keys are provided", () => {
|
||||
expect(() => generateCacheKey(["", undefined, null] as string[])).toThrow(
|
||||
"No keys provided"
|
||||
)
|
||||
expect(() => generateCacheKey("")).toThrow("No keys provided")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { getPrefix } from "./getPrefix"
|
||||
|
||||
export function generateCacheKey(
|
||||
key: string | string[],
|
||||
options?: { includeGitHashInKey?: boolean }
|
||||
): string {
|
||||
const includeGitHashInKey = options?.includeGitHashInKey ?? true
|
||||
const keyArray = (Array.isArray(key) ? key : [key]).filter(Boolean)
|
||||
|
||||
if (keyArray.length === 0) {
|
||||
throw new Error("No keys provided")
|
||||
}
|
||||
|
||||
const prefix = getPrefix({
|
||||
includeGitHashInKey,
|
||||
includeBranchPrefix: true,
|
||||
})
|
||||
|
||||
const keyTokens = [prefix, keyArray.join("_")].filter(Boolean).join(":")
|
||||
|
||||
return keyTokens
|
||||
}
|
||||
61
packages/common/dataCache/DistributedCache/get.ts
Normal file
61
packages/common/dataCache/DistributedCache/get.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
|
||||
import { cacheLogger } from "../logger"
|
||||
import { API_KEY } from "./client"
|
||||
import { deleteKey } from "./deleteKey"
|
||||
import { getCacheEndpoint } from "./endpoints"
|
||||
|
||||
export async function get<T>(key: string) {
|
||||
const perf = performance.now()
|
||||
|
||||
const [response, error] = await safeTry(
|
||||
fetch(getCacheEndpoint(key), {
|
||||
method: "GET",
|
||||
cache: "no-cache",
|
||||
headers: {
|
||||
"x-api-key": API_KEY,
|
||||
},
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
})
|
||||
)
|
||||
|
||||
if (!response || !response.ok || error) {
|
||||
if (response?.status === 404) {
|
||||
cacheLogger.debug(
|
||||
`Miss '${key}' took ${(performance.now() - perf).toFixed(2)}ms`
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
|
||||
Sentry.captureException(error ?? new Error("Unable to GET cachekey"), {
|
||||
extra: {
|
||||
cacheKey: key,
|
||||
statusCode: response?.status,
|
||||
statusText: response?.statusText,
|
||||
},
|
||||
})
|
||||
return undefined
|
||||
}
|
||||
|
||||
const [data, jsonError] = await safeTry(
|
||||
response.json() as Promise<{ data: T }>
|
||||
)
|
||||
|
||||
if (jsonError) {
|
||||
cacheLogger.error("Failed to parse cache response", {
|
||||
key,
|
||||
error: jsonError,
|
||||
})
|
||||
|
||||
await deleteKey(key)
|
||||
return undefined
|
||||
}
|
||||
|
||||
cacheLogger.debug(
|
||||
`Hit '${key}' took ${(performance.now() - perf).toFixed(2)}ms`
|
||||
)
|
||||
|
||||
return data?.data
|
||||
}
|
||||
1
packages/common/dataCache/DistributedCache/index.ts
Normal file
1
packages/common/dataCache/DistributedCache/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createDistributedCache } from "./client"
|
||||
32
packages/common/dataCache/DistributedCache/set.ts
Normal file
32
packages/common/dataCache/DistributedCache/set.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import * as Sentry from "@sentry/nextjs"
|
||||
|
||||
import { safeTry } from "@scandic-hotels/common/utils/safeTry"
|
||||
|
||||
import { type CacheTime, getCacheTimeInSeconds } from "../Cache"
|
||||
import { API_KEY } from "./client"
|
||||
import { getCacheEndpoint } from "./endpoints"
|
||||
|
||||
export async function set<T>(key: string, value: T, ttl: CacheTime) {
|
||||
const [response, error] = await safeTry(
|
||||
fetch(getCacheEndpoint(key), {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": API_KEY,
|
||||
},
|
||||
body: JSON.stringify({ data: value, ttl: getCacheTimeInSeconds(ttl) }),
|
||||
cache: "no-cache",
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
})
|
||||
)
|
||||
|
||||
if (!response || !response.ok || error) {
|
||||
Sentry.captureException(error ?? new Error("Unable to SET cachekey"), {
|
||||
extra: {
|
||||
cacheKey: key,
|
||||
statusCode: response?.status,
|
||||
statusText: response?.statusText,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user