Hello! I am Ahmed Atwa, a Software Engineer and OpenSauced contributor, and today I'll be showcasing a backend project I worked on when I was working for OpenSauced, and open-source it by the end of the blog!
Origin of the idea
It all started when we noticed that many social media platforms have boring and generic profile preview images for their users. We wanted to spice things up and make them more personalized and fun. So we decided to create a service that generates social images based on your name, repositories, used languages, and achievements. You can use these images to showcase your personality and skills on your profile or on your highlights when sharing them through social media.
It was my task to create and manage this new backend service from Proof of Concept (POC) to production & connecting with the frontend.
Architecture
This is the tech stack we used:
- Satori (for the image generation),
- satori-html (to replace the need of React),
- Resvg (to convert SVG into image)
- NestJs & TypeScript
Proof of Concept
Before officially starting on it, we needed a proof of concept to determine what's needed and the potential problems we could face. I threw in a quick nodejs/express app that should just create a dummy image.
During the process, we found two key challenges:
The first problem we faced was that Satori library was made for React environments and required React elements as input. Luckily, we discovered
satori-html
which would allow the input to be a string (in the form of HTML) instead.The second problem was with
satori-html
itself. The project is onCommonJS
module mode and the package was onECMAScript
module mode. This was pretty tiresome to solve, but in the end the final solution was:
const html = async (...args: string[]) => {
const { html } = await import('satori-html');
// @ts-ignore
return html(...args);
}
and setting "moduleResolution": "node16"
.
After solving these 2 problems, the proof of concept was done and it was time for the next step.
import fs from 'fs';
// @ts-ignore
import satori from 'satori'
import { Resvg } from '@resvg/resvg-js'
import HTMLTemplate from './HTMLTemplate';
const html = async (...args: string[]) => {
const { html } = await import('satori-html');
// @ts-ignore
return html(...args);
}
export default async function createImage(name: string) {
const template = await html(HTMLTemplate(name))
let robotoArrayBuffer = fs.readFileSync('public/Roboto-Regular.ttf');
const svg = await satori(template , {
width: 600,
height: 400,
fonts: [
{
name: 'Roboto',
data: robotoArrayBuffer,
weight: 400,
style: 'normal',
},
],
},
)
const resvg = new Resvg(svg, {
background: "rgba(238, 235, 230, .9)",
});
const pngData = resvg.render()
const pngBuffer = pngData.asPng()
return pngBuffer
}
More technical context behind the decisions made is available here
Opengraph Repo
With the POC tested and approved, it was the moment for this creation to rise and shine. I worked with @0vortex on the Opengraph repo, I focused on the frontend side of the service, i.e. the card generation process, the card design, testing, etc., while he focused on the backend side of the service, basically setting up the infrastructure, fetching data from GitHub with GraphQL, the caching and storage of the cards, etc.
User Cards
The first card we made displayed a user's name, image, top repos, and top languages. The card was used on Insights user profile page.
Highlight Cards
The second card we developed was the highlight card, which provided information about a specific highlight, including the owner's name, image, highlight's body, project details, languages used, and reactions count.
The Process from Start to Finish
I'll be talking specifically about User cards process, which involved two endpoints: one for generation and another for status checking.
The status endpoint is the initial step, allowing us to determine whether the card is up-to-date, out-dated, or non-existent. In all cases, a URL is provided to locate the card.
The generation endpoint internally checks the status first. If the card is up-to-date, it redirects to the card URL. If not, the card is re-generated and uploaded to the cloud.
Problem with Testing
We focused on getting the project ready for production first, and there was a little problem; we had no way to locally test the cards, without activating the whole process of generating and uploading to the cloud.
I thought of making some checks on the environment whether it's production or local and branched the behavior depending on the environment, but this would have turned the straight-forward code into a big complex spaghetti, and would've been very hard to maintain.
To address this challenge, I devised a solution that involved separate node scripts that would activate only the generation functions and generate images in a local folder. Now I wouldn't have to mess with the production process and keep everything simple and elegant. These new node scripts could be called separately without running the server.
This is the node script for testing user profiles: local-dev/UserCards.ts
const testUsernames = [
"bdougie", "deadreyo", "defunkt", "0-vortex", "Anush008", "diivi"
];
const folderPath = "dist";
async function testUserCards () {
const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleFixture.createNestApplication();
await app.init();
const instance = app.get(UserCardService);
const promises = testUsernames.map(async username => {
const { svg } = await instance.generateCardBuffer(username);
if (!existsSync(folderPath)) {
await mkdir(folderPath);
}
await writeFile(`${folderPath}/${username}.svg`, svg);
});
// generating sequential: 10.5 seconds, parallel: 4.5 seconds
await Promise.all(promises);
}
testUserCards();
A common pattern I made in testing and when fetching the image on Insight frontend was to run promises in parallel, to speed up the process by 100%.
And with this, the Opengraph was done and it was time to integrate the whole thing into the User Profiles on Insights!
Insights User Profiles
By default, Next.js uses SSG (Static Site Generation), and this was simply not suitable for our purposes. If we wanted dynamic user profiles with dynamic social cards, then it was a must to turn User Profiles into SSR (Server Side Rendering). For a detailed explanation, please refer to this link.
The process involved two main steps:
The first step was turning it SSR. This activated the meta-tags for the profiles which were installed but not running due to SSG.
The second step was fetching the image from Opengraph. First we hit the metadata endpoint to check the status of the image, if OK, get the card URL (sent in the response headers) and insert it into the meta-tags. If not, generate it and still insert the location to meta-tags. (Cards URLs are static, so even if the image isn't there, we know where it will be.)
Note: An issue with this is that the URL preview may be displayed before the card finishes generation, leading to a broken preview image. We chose to ignore this issue for the time being. (It would break only on the first time anyways.)
This is the SSR code:
export const getServerSideProps = async (context: UserSSRPropsContext) => {
return await handleUserSSR(context);
};
export type UserSSRPropsContext = GetServerSidePropsContext<{ username: string }>;
export async function handleUserSSR({ params }: GetServerSidePropsContext<{ username: string }>) {
const { username } = params!;
const sessionResponse = await supabase.auth.getSession();
const sessionToken = sessionResponse?.data.session?.access_token;
let user;
let ogImage;
async function fetchUserData() {
const req = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/users/${username}`, {
headers: {
accept: "application/json",
Authorization: `Bearer ${sessionToken}`
}
});
user = await req.json() as DbUser;
}
async function fetchSocialCardURL() {
const socialCardUrl = `${String(process.env.NEXT_PUBLIC_OPENGRAPH_URL ?? "")}/users/${username}`;
const ogReq = await fetch(`${socialCardUrl}/metadata`); //status returned: 204 or 304 or 404
if(ogReq.status !== 204) {
fetch(socialCardUrl, {
method: "HEAD"
}); // trigger the generation of the social card
}
ogImage = ogReq.headers.get("x-amz-meta-location");
}
// Runs the data fetching in parallel. Decreases the loading time by 50%.
await Promise.allSettled([fetchUserData(), fetchSocialCardURL()]);
return {
props: { username, user, ogImage }
};
}
Separated the contents of the getServerSideProps
into another function & created UserSSRPropsContext
as I would need to duplicate the code into 2 more pages/links that basically point to the same page. Now I don't need to touch the other 2 ever.
Another thing was running data fetching in parallel, since each one is independent of the other, then we can save time. (Saving time is critical since this is Server-side rendering, i.e. user waits for the server.)
Conclusion
This was the whole story behind getting Opengraph repo from ground till production & integrating with the frontend!
I really enjoyed working on this project very much and it's only the beginning of my journey! I'm looking forward to making future contributions ✨
The project is about to start a new adventure, as an Open Source project!
Top comments (2)
Awesome post @deadreyo it was a pleasure working with you on this 🙏🤝
Will do my best to document the backend journey as thorough as you did! 🔥❤️👍
Definitely my pleasure working with and learning from you! 💖