TL;DR De broncode voor de functie vind je onderaan dit blog.
Als je een URL plakt in een bericht op sociale media als Facebook, Twitter en LinkedIn zie je vaak een afbeelding tevoorschijn komen als preview. Soms is die afbeelding toegespitst op een specifieke blog die je deelt en soms enkel op een logo van de website. De afbeelding die wordt getoond, kun je vooraf instellen. Wij als engineers doen dit in de HTML van de website. Hiervoor verwerken we de <meta property="og:image"
of de <meta name="twitter:image:src"
. Per pagina is deze afbeelding te configureren.
Serverless functies
Het blijft echter lastig om voor elke pagina op je website een afbeelding te maken die past bij de inhoud. En daarnaast is deze handeling enorm tijdrovend. Het zou daarom mooi zijn om dynamisch een afbeelding te kunnen genereren. Bijvoorbeeld de blogtitel en je logo. Het is bij verschillende diensten mogelijk om op deze manier dynamisch een afbeelding te genereren. Maar zo ambitieus als we zijn, bouwen wij dit graag zelf. Dit bouwen we als een serverless functie.
Netlify of AWS lambda
Serverless functies bouwen en draaien is mogelijk bij verschillende cloud providers. In dit blog focus ik me op cloud provider Netlify. Netlify maakt het makkelijk om functies te deployen, samen met de front-end van je website. Je kunt de functies ook zonder front-end deployen in Netlify. In de titel van dit blog benoem ik ook AWS Lambda. In dit blog ga ik niet in op hoe je het script op AWS kunt deployen, maar de manier waarop we de functie gaan schrijven. Dit is overigens compatibel met AWS Lambda.
Applicatie maken op Netlify
Om een dynamische afbeelding te genereren met Netfily, moet je een applicatie importeren. Nadat je een account hebt aangemaakt en bent ingelogd, kies je voor "Add new site". Daarbij kies je voor "Import an existing project". Je krijgt verschillende opties om een project te selecteren op verschillende GIT providers. Wij hebben gekozen voor een repository op GitHub. In de repository die we gekoppeld hebben aan Netlify hebben we de volgende structuur aangemaakt:
/netlify /functions social-image.ts
In de map /netlify/functions
kun je zoeken naar functies. Deze map is standaard beschikbaar in Netlify. Je kunt de map wijzigen in de netlify.toml
of de Netlify interface. Als je de wijzigingen pushed naar je GIT branch wordt het in Netlify automatisch gedeployed.
Ik ben me ervan bewust dat ik een aantal stappen oversla, bijvoorbeeld het maken van een GIT repository, instellen van build steps etc. Dit doe ik bewust, omdat ik niet teveel wil afleiden van waar dit blog over gaat — de dynamische functie maken. Mocht je echter vragen hebben, bekijk dan de uitgebreide Netlify documentatie, of stuur me een berichtje.
Functie opbouw
De structuur van de Netlify functie is als volgt:
// /.netlify/functions/social-image.ts import { Handler, HandlerContext, HandlerEvent } from '@netlify/functions'; const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => { return { statusCode: 200, body: 'Hello world!', }; }; export { handler };
De handler
functie wordt aangeroepen als je de URL bezoekt.
Mijn voorbeelden zijn geschreven in TypeScript. Dit is optioneel. Door de extensie te wijzigen in .js en de TypeScript hints te verwijderen, kun je het in plain-JS gebruiken.
Om de functies lokaal te kunnen testen, is er de Netlify CLI. De installatie daarvan kun je hier vinden. Als Netlify CLI geïnstalleerd is, kun je deze gebruiken met:
netlify dev
De omgeving wordt toegankelijk gemaakt op localhost:8888
. Je kunt je functie bezoeken door de volgende URL te bezoeken:
http://localhost:8888/.netlify/functions/social-image
Een afbeelding genereren
Er zijn verschillende opties om afbeeldingen te genereren in een Node.JS omgeving. Ik ben zelf huiverig voor packages met C/C++ bindings. Al zijn die vaak wel het beste om afbeeldingen te creëren. Mocht je de angstgevoelens voor native bindings niet met mij delen, dan raad ik je aan om te kijken naar node canvas.
Voor nu gebruiken we pureimage . Pureimage is een NPM-package die de Canvas API grotendeels heeft nagemaakt in pure JavaScript. Voor de meeste use-cases is dit voldoende, maar er zijn beperkingen en limitaties. Mijn tests laten bijvoorbeeld bij transparante PNG's lelijke lijnen zien. Je zult dus af en toe wat dingen moeten aanpassen als de uitkomst niet ideaal is. Ik heb bijvoorbeeld een JPG gebruikt in plaats van een transparante PNG.
De eerste stap is een afbeelding tekenen. Voor nu maak ik een groot rood vlak:
// ... import PImage from 'pureimage'; const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => { // We maken een afbeelding van 1000px x 600px. De laatste parameter zijn // opties, maar die zijn er niet bij pureimage. Ik geef hem mee, omdat // TypeScript er anders een error op geeft... const image = PImage.make(1000, 600, {}); const ctx = image.getContext('2d'); // Teken een rood rechthoek van 1000px x 600px ctx.fillStyle = '#f00'; ctx.fillRect(0, 0, 1000, 600); // ... };
Met bovenstaande code hebben we een rode afbeelding getekend. Nu komt echter de uitdaging, het wegschrijven naar de response, zodat we de afbeelding kunnen zien. Dit is mijn oplossing:
// ... import { Bitmap } from 'pureimage/types/bitmap'; import { PassThrough } from 'stream'; const imageToBuffer = (image: Bitmap): Promise=> { return new Promise((resolve) => { const stream = new PassThrough(); const imageData: Uint8Array[] = []; stream.on('data', (chunk) => { imageData.push(chunk); }); stream.on('end', () => { resolve(Buffer.concat(imageData)); }); PImage.encodeJPEGToStream(image, stream); }); }; const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => { const image = PImage.make(1000, 600, {}); const ctx = image.getContext('2d'); ctx.fillStyle = '#f00'; ctx.fillRect(0, 0, 1000, 600); const buffer = await imageToBuffer(image); return { statusCode: 200, headers: { 'Content-Type': 'image/jpeg', }, body: buffer.toString('base64'), isBase64Encoded: true, }; };
ℹ️ Ik laat steeds delen van code weg, die ik in eerdere voorbeelden heb laten zien. Om vooral te focussen op de code waar het nu om gaat. Aan het einde van het artikel deel ik een Gist met de hele functie.
De uitdaging is vooral dat pureimage zich toespitst op het wegschrijven naar een bestand. Ik heb liever dat onze functie puur blijft. Door een PassThrough stream te maken, schrijven we de afbeelding daarnaartoe en sturen we de afbeelding naar je browser als base64. Zo blijft de functie puur.
Toevoegen van een logo en tekst
Het belangrijkste gedeelte is uitgevoerd. We kunnen nu gaan spelen met wat we willen laten zien in onze afbeeldingen. Daarbij houd ik het voor nu simpel, een logo en tekst:
// ... const loadFont = (fontPath: string, fontName: string): Promise=> { return new Promise((resolve, reject) => { // 3e, 4e en 5e parameter worden alleen meegegeven om TypeScript // errors te voorkomen. const font = PImage.registerFont(fontPath, fontName, 400, 'normal', 'normal'); font.load(() => { resolve(); }); }); }; const image = PImage.make(1000, 600, {}); const ctx = image.getContext('2d'); // Maak een wit rechthoek ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 1000, 600); // Laad het logo uit de public map const logo = await PImage.decodeJPEGFromStream(fs.createReadStream('public/logo.jpg')); const logoX = (1000 / 2) - (300 / 2); const logoY = (600 / 2) - (300 / 2); const logoWidth = 300; const logoHeight = 300; // Teken het logo ctx.drawImage(logo, logoX, logoY, logoWidth, logoHeight); // Laad een custom font in await loadFont('public/source-sans-pro.ttf', 'Source Sans Pro'); // Tekstkleur ctx.fillStyle = '#111'; // @ts-ignore De type hint in pureimage voor .font is incorrect. ctx.font = `48pt Source Sans Pro`; ctx.textAlign = 'center'; // Teken de tekst op X en Y coordinaat. ctx.fillText('Dit is mijn tekst', 1000 / 2, 400); // ...
Als je deze code samenvoegt met de code uit vorige voorbeelden, dan heb je een afbeelding met je logo en tekst eronder. De rest laat ik aan jou en je creativiteit over.
⚠️ In het voorbeeld hierboven maken we gebruik van statische bestanden (logo en font), die aanwezig moeten zijn. Als je bovenstaande deployed op Netlify, kan het zijn dat de bestanden niet aanwezig zijn. Netlify probeert zelf te analyseren welke statische bestanden nodig zijn, maar dit gaat niet altijd goed. Daarom kun je in het netlify.toml
bestand aangeven welke statische bestanden mee gedeployed moeten worden:
[functions]
included_files = ["public/source-sans-pro.ttf", "public/logo.jpg"]
Broncode Github
In dit blog heb ik uitgelegd hoe je een basis kan maken voor het genereren van afbeeldingen in een serverless functie. Om meer dan een logo en tekst toe te passen is er meer werk aan de winkel. De tekst kan bijvoorbeeld dynamisch worden. Voor nu valt dat buiten de scope van dit blog. In de broncode van onderstaande link, is een dynamische tekst toegepast met query parameters. Je vindt in de broncode ook meer fout afhandeling, optionele PNG output en het toepassen van cache headers.
Maak mooie dingen en laat ons weten waar je trots op bent!
Broncode: https://gist.github.com/wiljanslofstra/9331469d9a7c5cb8bc263b92829495d3