feat: correctly cache images with redis

This commit is contained in:
max_richter 2023-07-30 18:27:45 +02:00
parent 1917fc7d8f
commit af8adf9ce7
17 changed files with 321 additions and 121 deletions

29
components/Card.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Recipe } from "../lib/recipes.ts";
export function Card(
{ link, title, image }: { link?: string; title?: string; image?: string },
) {
return (
<a
href={link}
style={{
backgroundImage: `url(${image})`,
backgroundSize: "cover",
}}
class="bg-gray-900 text-white rounded-3xl shadow-md p-4 relative overflow-hidden
lg:w-56 lg:h-56
sm:w-40 sm:h-40
w-32 h-32"
>
<div class="h-full flex flex-col justify-between relative z-10">
<div>
{/* Recipe Card content */}
</div>
<div class="mt-2">
{title}
</div>
</div>
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black to-transparent" />
</a>
);
}

View File

@ -1,29 +1,18 @@
import { Recipe } from "../lib/recipes.ts";
import { Card } from "@components/Card.tsx";
import { Recipe } from "@lib/recipes.ts";
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const { meta: { image = "Recipes/images/placeholder.jpg" } = {} } = recipe;
const imageUrl = image.startsWith("Recipes/images/")
? `/api/images?image=${image}&width=200&height=200`
: image;
return (
<a
href={`/recipes/${recipe.id}`}
style={{
backgroundImage: `url(${
recipe?.meta?.image || "/api/recipes/images/placeholder.jpg"
}?width=200&height=200)`,
backgroundSize: "cover",
}}
class="bg-gray-900 text-white rounded-3xl shadow-md p-4 relative overflow-hidden
lg:w-56 lg:h-56
sm:w-40 sm:h-40
w-32 h-32"
>
<div class="h-full flex flex-col justify-between relative z-10">
<div>
{/* Recipe Card content */}
</div>
<div class="mt-2">
{recipe.name}
</div>
</div>
<div class="absolute inset-x-0 bottom-0 h-3/4 bg-gradient-to-t from-black to-transparent" />
</a>
<Card
title={recipe.name}
image={imageUrl}
link={`/recipes/${recipe.id}`}
/>
);
}

View File

@ -1,10 +1,16 @@
import { Recipe } from "../lib/recipes.ts";
import { Recipe } from "@lib/recipes.ts";
export function RecipeHero({ recipe }: { recipe: Recipe }) {
const { meta: { image = "Recipes/images/placeholder.jpg" } = {} } = recipe;
const imageUrl = image.startsWith("Recipes/images/")
? `/api/images?image=${image}&width=800`
: image;
return (
<div class="relative w-full h-[400px] rounded-3xl overflow-hidden bg-black">
<img
src={recipe?.meta?.image + "?width=800"}
src={imageUrl}
alt="Recipe Banner"
class="object-cover w-full h-full"
/>
@ -18,11 +24,11 @@ export function RecipeHero({ recipe }: { recipe: Recipe }) {
</a>
</div>
<div
class="absolute inset-x-0 bottom-0 py-4 px-8 py-8"
style={{ background: "linear-gradient(#0000, #fff9)" }}
>
<h2 class="text-4xl font-bold mt-4" style={{ color: "#1F1F1F" }}>
<div class="absolute inset-x-0 bottom-0 py-4 px-8 py-8 noisy-gradient">
<h2
class="relative text-4xl font-bold mt-4 z-10"
style={{ color: "#1F1F1F" }}
>
{recipe.name}
</h2>
</div>

View File

@ -2,6 +2,7 @@
"lock": false,
"tasks": {
"start": "deno run -A --watch=static/,routes/ dev.ts",
"debug": "deno run --inspect-wait -A main.ts",
"update": "deno run -A -r https://fresh.deno.dev/update ."
},
"lint": {

View File

@ -4,9 +4,9 @@
import * as $0 from "./routes/_404.tsx";
import * as $1 from "./routes/_app.tsx";
import * as $2 from "./routes/api/index.ts";
import * as $3 from "./routes/api/recipes/[name].ts";
import * as $4 from "./routes/api/recipes/images/[image].ts";
import * as $2 from "./routes/api/images/index.ts";
import * as $3 from "./routes/api/index.ts";
import * as $4 from "./routes/api/recipes/[name].ts";
import * as $5 from "./routes/api/recipes/index.ts";
import * as $6 from "./routes/index.tsx";
import * as $7 from "./routes/recipes/[name].tsx";
@ -17,9 +17,9 @@ const manifest = {
routes: {
"./routes/_404.tsx": $0,
"./routes/_app.tsx": $1,
"./routes/api/index.ts": $2,
"./routes/api/recipes/[name].ts": $3,
"./routes/api/recipes/images/[image].ts": $4,
"./routes/api/images/index.ts": $2,
"./routes/api/index.ts": $3,
"./routes/api/recipes/[name].ts": $4,
"./routes/api/recipes/index.ts": $5,
"./routes/index.tsx": $6,
"./routes/recipes/[name].tsx": $7,

View File

@ -1,29 +0,0 @@
import { connect } from "https://deno.land/x/redis/mod.ts";
const REDIS_HOST = Deno.env.get("REDIS_HOST");
const REDIS_PASS = Deno.env.get("REDIS_PASS");
const REDIS_PORT = Deno.env.get("REDIS_PORT");
async function createCache() {
if (REDIS_HOST && REDIS_PASS) {
const client = await connect({
password: REDIS_PASS,
hostname: REDIS_HOST,
port: REDIS_PORT || 6379,
});
console.log("COnnected to redis");
return client;
}
return new Map<string, any>();
}
const cache = await createCache();
export async function get(id: string) {
return await cache.get(id);
}
export async function set(id: string, content: any) {
return await cache.set(id, content);
}

42
lib/cache/cache.ts vendored Normal file
View File

@ -0,0 +1,42 @@
import {
connect,
Redis,
RedisConnectOptions,
RedisValue,
} from "https://deno.land/x/redis@v0.31.0/mod.ts";
const REDIS_HOST = Deno.env.get("REDIS_HOST");
const REDIS_PASS = Deno.env.get("REDIS_PASS") || "";
const REDIS_PORT = Deno.env.get("REDIS_PORT");
async function createCache<T>(): Promise<Map<string, T> | Redis> {
if (REDIS_HOST) {
const conf: RedisConnectOptions = {
hostname: REDIS_HOST,
port: REDIS_PORT || 6379,
};
if (REDIS_PASS) {
conf.password = REDIS_PASS;
}
const client = await connect(conf);
console.log("Connected to redis");
return client;
}
return new Map<string, T>();
}
const cache = await createCache();
export async function get<T>(id: string, binary = false) {
if (binary && !(cache instanceof Map)) {
return await cache.sendCommand("GET", [id], {
returnUint8Arrays: true,
}) as T;
}
return await cache.get(id) as T;
}
export async function set<T extends RedisValue>(id: string, content: T) {
return await cache.set(id, content);
}

57
lib/cache/documents.ts vendored Normal file
View File

@ -0,0 +1,57 @@
import { Document } from "@lib/documents.ts";
import * as cache from "@lib/cache/cache.ts";
type DocumentsCache = {
lastUpdated: number;
documents: Document[];
};
const CACHE_INTERVAL = 5000; // 5 seconds;
const CACHE_KEY = "documents";
export async function getDocuments() {
const docs = await cache.get<DocumentsCache>(CACHE_KEY);
if (!docs) return;
if (Date.now() > docs.lastUpdated + CACHE_INTERVAL) {
return;
}
return docs.documents;
}
export function setDocuments(documents: Document[]) {
return cache.set(
CACHE_KEY,
JSON.stringify({
lastUpdated: Date.now(),
documents,
}),
);
}
type DocumentCache = {
lastUpdated: number;
content: string;
};
export async function getDocument(id: string) {
const doc = await cache.get<DocumentCache>(CACHE_KEY + "/" + id);
if (!doc) return;
if (Date.now() > doc.lastUpdated + CACHE_INTERVAL) {
return;
}
return doc.content;
}
export async function setDocument(id: string, content: string) {
await cache.set(
CACHE_KEY + "/" + id,
JSON.stringify({
lastUpdated: Date.now(),
content,
}),
);
}

63
lib/cache/image.ts vendored Normal file
View File

@ -0,0 +1,63 @@
import { hash } from "@lib/hash.ts";
import * as cache from "@lib/cache/cache.ts";
type ImageCacheOptions = {
url: string;
width: number;
height: number;
mediaType?: string;
};
const CACHE_KEY = "images";
function getCacheKey({ url: _url, width, height }: ImageCacheOptions) {
const url = new URL(_url);
return `${CACHE_KEY}/${url.hostname}/${url.pathname}/${width}/${height}`
.replace(
"//",
"/",
);
}
export async function getImage({ url, width, height }: ImageCacheOptions) {
const cacheKey = getCacheKey({ url, width, height });
const pointerCacheRaw = await cache.get<string>(cacheKey);
if (!pointerCacheRaw) return;
const pointerCache = typeof pointerCacheRaw === "string"
? JSON.parse(pointerCacheRaw)
: pointerCacheRaw;
const imageContent = await cache.get(pointerCache.id, true);
if (!imageContent) return;
return {
...pointerCache,
buffer: imageContent,
};
}
export async function setImage(
buffer: Uint8Array,
{ url, width, height, mediaType }: ImageCacheOptions,
) {
const clone = new Uint8Array(buffer);
const cacheKey = getCacheKey({ url, width, height });
const pointerId = await hash(cacheKey);
await cache.set(pointerId, clone);
await cache.set(
cacheKey,
JSON.stringify({
id: pointerId,
url,
width,
height,
mediaType,
}),
);
}

View File

@ -2,6 +2,7 @@ import { unified } from "npm:unified";
import remarkParse from "npm:remark-parse";
import remarkFrontmatter from "https://esm.sh/remark-frontmatter@4";
import { parse } from "https://deno.land/std@0.194.0/yaml/mod.ts";
import * as cache from "@lib/cache/documents.ts";
const SILVERBULLET_SERVER = Deno.env.get("SILVERBULLET_SERVER");
@ -18,6 +19,9 @@ export function parseFrontmatter(yaml: string) {
}
export async function getDocuments(): Promise<Document[]> {
const cachedDocuments = await cache.getDocuments();
if (cachedDocuments) return cachedDocuments;
const headers = new Headers();
headers.append("Accept", "application/json");
@ -25,12 +29,22 @@ export async function getDocuments(): Promise<Document[]> {
headers: headers,
});
return response.json();
const documents = await response.json();
cache.setDocuments(documents);
return documents;
}
export async function getDocument(name: string): Promise<string> {
const cachedDocument = await cache.getDocument(name);
if (cachedDocument) return cachedDocument;
const response = await fetch(SILVERBULLET_SERVER + "/" + name);
return await response.text();
const text = await response.text();
cache.setDocument(name, text);
return text;
}
export function parseDocument(doc: string) {

9
lib/hash.ts Normal file
View File

@ -0,0 +1,9 @@
export async function hash(message: string) {
const data = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join(
"",
);
return hashHex;
}

View File

@ -4,7 +4,7 @@ import {
getTextOfRange,
parseDocument,
parseFrontmatter,
} from "./documents.ts";
} from "@lib/documents.ts";
import { parseIngredient } from "npm:parse-ingredient";
@ -161,10 +161,7 @@ export function parseRecipe(original: string, id: string): Recipe {
return {
id,
meta: {
...meta,
image: meta?.image?.replace(/^Recipes\/images/, "/api/recipes/images"),
},
meta,
name,
description,
ingredients,

View File

@ -5,7 +5,7 @@ import {
MagickGeometry,
} from "https://deno.land/x/imagemagick_deno@0.0.14/mod.ts";
import { parseMediaType } from "https://deno.land/std@0.175.0/media_types/parse_media_type.ts";
import * as cache from "@lib/cache.ts";
import * as cache from "@lib/cache/image.ts";
await initializeImageMagick();
@ -56,6 +56,11 @@ function modifyImage(
}
function parseParams(reqUrl: URL) {
const image = reqUrl.searchParams.get("image")?.replace(/^\//, "");
if (image == null) {
return "Missing 'image' query parameter.";
}
const height = Number(reqUrl.searchParams.get("height")) || 0;
const width = Number(reqUrl.searchParams.get("width")) || 0;
if (height === 0 && width === 0) {
@ -69,50 +74,36 @@ function parseParams(reqUrl: URL) {
return `Width and height cannot exceed ${maxDimension}.`;
}
return {
image,
height,
width,
};
}
async function getImageResponse(
remoteImage: { buffer: Uint8Array; mediaType: string },
params: { width: number; height: number },
): Promise<Response> {
const modifiedImage = await modifyImage(remoteImage.buffer, {
...params,
mode: "resize",
});
const response = new Response(modifiedImage, {
headers: {
"Content-Type": remoteImage.mediaType,
},
});
return response;
}
export const handler = async (
_req: Request,
_ctx: HandlerContext,
): Promise<Response> => {
const imageUrl = Deno.env.get("SILVERBULLET_SERVER") + "/Recipes/images/" +
_ctx.params.image;
const url = new URL(_req.url);
const params = parseParams(url);
if (typeof params === "string") {
return new Response(params, { status: 400 });
}
const imageId = `${imageUrl}.${params.width}.${params.height}`;
const cachedResponse = await cache.get(imageId);
const imageUrl = Deno.env.get("SILVERBULLET_SERVER") + "/" + params.image;
const cachedResponse = await cache.getImage({
url: imageUrl,
width: params.width,
height: params.height,
});
if (cachedResponse) {
const _r = await cachedResponse;
console.log({ cachedResponse, _r });
return (await cachedResponse).clone();
return new Response(cachedResponse.buffer.slice(), {
headers: {
"Content-Type": cachedResponse.mediaType,
},
});
}
const remoteImage = await getRemoteImage(imageUrl);
@ -120,9 +111,21 @@ export const handler = async (
return new Response(remoteImage, { status: 400 });
}
const response = getImageResponse(remoteImage, params);
const modifiedImage = await modifyImage(remoteImage.buffer, {
...params,
mode: "resize",
});
await cache.set(imageId, response);
cache.setImage(modifiedImage, {
url: imageUrl,
width: params.width,
height: params.height,
mediaType: remoteImage.mediaType,
});
return response;
return new Response(modifiedImage, {
headers: {
"Content-Type": remoteImage.mediaType,
},
});
};

View File

@ -1,6 +1,8 @@
import { Head } from "$fresh/runtime.ts";
import { useSignal } from "@preact/signals";
import Counter from "@islands/Counter.tsx";
import { MainLayout } from "@components/layouts/main.tsx";
import { Card } from "@components/Card.tsx";
export default function Home() {
const count = useSignal(3);
@ -9,23 +11,15 @@ export default function Home() {
<Head>
<title>app</title>
</Head>
<div class="px-4 py-8 mx-auto bg-[#86efac]">
<div class="max-w-screen-md mx-auto flex flex-col items-center justify-center">
<img
class="my-6"
src="/logo.svg"
width="128"
height="128"
alt="the fresh logo: a sliced lemon dripping with juice"
<MainLayout>
<div class="flex flex-wrap justify-center items-center gap-4 px-4">
<Card
title="Recipes"
image="/api/images?image=Recipes/images/placeholder.jpg&width=200&height=200"
link="/recipes"
/>
<h1 class="text-4xl font-bold">Welcome to fresh</h1>
<p class="my-4">
Try updating this message in the
<code class="mx-2">./routes/index.tsx</code> file, and refresh.
</p>
<Counter count={count} />
</div>
</div>
</MainLayout>
</>
);
}

View File

@ -3,7 +3,7 @@ import { RecipeCard } from "@components/RecipeCard.tsx";
import { MainLayout } from "@components/layouts/main.tsx";
import { Recipe } from "@lib/recipes.ts";
import { getRecipes } from "../api/recipes/index.ts";
import IconArrowLeft from "https://deno.land/x/tabler_icons_tsx@0.0.3/tsx/arrow-left.tsx";
export const handler: Handlers<Recipe[] | null> = {
async GET(_, ctx) {
const recipes = await getRecipes();
@ -14,6 +14,17 @@ export const handler: Handlers<Recipe[] | null> = {
export default function Greet(props: PageProps<Recipe[] | null>) {
return (
<MainLayout>
<header class="flex gap-4 items-center mb-5">
<a
class="px-4 ml-4 py-2 bg-gray-300 text-gray-800 rounded-lg flex items-center gap-1"
href="/"
>
<IconArrowLeft class="w-5 h-5" />
Back
</a>
<h3 class="text-2xl text-white font-light">Recipes</h3>
</header>
<div class="flex flex-wrap justify-center items-center gap-4 px-4">
{props.data?.map((doc) => {
return <RecipeCard recipe={doc} />;

View File

@ -1,3 +1,17 @@
body {
background: #1F1F1F
background: #1F1F1F;
padding: 0px 20px;
}
.noisy-gradient::after {
content: "";
top: 0;
left: 0;
z-index:0;
position: absolute;
opacity: 0.7;
height: 100%;
width: 100%;
background: url(/grainy-gradient.png);
background-size: contain;
}

BIN
static/grainy-gradient.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB