Cloudflare Workers предоставляет глобально распределенную бессерверную среду с высокой доступностью и масштабируемостью. В этой статье мы воспользуемся возможностями Cloudflare Workers и KV, распределенного хранилища ключей и значений с низкой задержкой, для создания простого инструмента Link in Bio, аналогичного Linktree и Lnk.Bio.

Предварительный просмотр

Вот так будет выглядеть готовый проект:

Инициализация нового проекта

Начнем с инициализации нового проекта с помощью Wrangler CLI. Идите вперед и запустите эту команду:

$  npx wrangler init link-in-bio

В этом уроке мы будем использовать Typescript, поэтому при появлении запроса ответьте yes.

Создание KV-магазина

Выполните следующие команды, чтобы создать новый магазин KV:

$ npx wrangler kv:namespace create "LINK_IN_BIO"
$ npx wrangler kv:namespace create "LINK_IN_BIO" --previews

Нам также потребуется обновить файл wrangler.toml, чтобы включить привязки пространств имен.

...
kv_namespaces = [{ binding = "LINK_IN_BIO", id = "xxxxxxxxx", preview_id = "xxxxxxxxx"}]

Запуск локально

Мы запустим сервер разработки для сборки и запуска проекта локально, выполнив следующую команду:

$ npx wrangler dev

Создание веб-шаблонов

Нам нужно три страницы для проекта. На главной странице будет форма. На странице профиля будут отображаться ссылки и информация о профиле пользователя. Наконец, нам понадобится страница успеха, которая будет отображаться после того, как пользователь успешно отправит форму. Он будет содержать ссылку, которая будет опубликована в профиле социальной сети.

Шаблоны представляют собой стандартные HTML-документы, обернутые вокруг функции. Они довольно длинные, чтобы включать их сюда, поэтому проверьте их на Github. В profile_page.ts также есть некоторые интерфейсы TypeScript. ProfileContent — это тип ввода для шаблона страницы профиля.

export interface Link {
  name: string;
  url: string;
}
export interface ProfileContent {
  name: string;
  picture: string;
  description?: string;
  links: Link[];
}

После создания этих файлов импортируйте их в index.js.

import profilePage, { ProfileContent, Link } from "./profile_page";
import homePage from "./home_page";
import successPage from "./success_page";

Мы также деструктурируем LINK_IN_BIO, который является нашим хранилищем KV, из объекта env. Наша функция fetch теперь выглядит примерно так:

export default {
  async fetch(request: Request, { LINK_IN_BIO }: Record<string, any>): Promise<Response> {
     ...
  }
}

Обработка HTTP-запросов

Нам нужно обработать два запроса GET. Если пути нет, мы просто отрендерим homePage.

// handle get request
if (request.method.toLowerCase() === "get") {
   const url = new URL(request.url);
   // home page route
   if (url.pathname === "/") {
      return new Response(homePage, { headers: { 'Content-Type': 'text/html' } });
    }
}

Если в URL-адресе есть путь, мы отобразим profilePage с содержимым страницы, полученным из нашего хранилища KV. Сначала мы извлекаем ключ из URL-адреса, а затем получаем значение из KV, вызывая метод get для LINK_IN_BIO.

// handle get request
if (request.method.toLowerCase() === "get") {
   const url = new URL(request.url);
   // home page route
   if (url.pathname === "/") {
      return new Response(homePage, { headers: { 'Content-Type': 'text/html' } });
    }
/**
     * Additional code to handle route with a path 
     **/
    // extract key from url
    const key = url.pathname.replaceAll("/", "");
    // render profile page
    const content = await LINK_IN_BIO.get(key);
    if (content) {
       return new Response(profilePage(JSON.parse(content)), { headers: { 'Content-Type': 'text/html' } });
     }
     // key is not in the store
     return new Response("Page does not exist", { status: 404 });
}

Следующее, что нам нужно обработать, это запрос POST. Это вызывается при отправке формы на домашней странице. Первое, что нужно сделать, это получить данные формы из запроса. Мы также создадим переменную namedcontent для хранения содержимого профиля, которое мы поместим в KV. Он будет иметь тип ProfileContent, который совпадает с вводом для шаблона profilePage.

// handle post request
if (request.method.toLowerCase() === "post") {
   const formData = await request.formData();
   const content: ProfileContent = { name: '', picture: '', links: [] };
}

Нам также нужны две вспомогательные функции. toBase64 будет использоваться для преобразования изображения профиля в строку base64 и generateId простой генератор уникальных идентификаторов для генерации ключей для нашего хранилища KV.

// convert file to base64
const toBase64 = async (file: File) => {
  const buffer = await file.arrayBuffer();
  var binary = '';
  var bytes = new Uint8Array(buffer);
  var len = bytes.byteLength;
  for (var i = 0; i < len; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  const base64 = btoa(binary);
  return `data:${file.type};base64,${base64}`
};

// generate unique id
const generateId = () => {
  return btoa((Date.now() * Math.random() + ""));
}

Форма на главной странице позволяет пользователям добавлять в форму бесконечное количество ссылок. С каждым новым вводом ссылки будет связан номер, например link_name_1 и link_url_1. Посмотреть код можно на Github.

Мы напишем здесь некоторый код, чтобы получить все имена ссылок и URL-адреса из формы и сгруппировать их на основе их количества. Сначала мы отображаем их в объект, а затем сохраняем список ссылок в переменной content.

// handle post request
if (request.method.toLowerCase() === "post") {
   ...
   // map to store all links in the form
   const linkMap: Record<number, Link> = {}
   // iterate over all entries in the form data 
   for (const [key, value] of formData) {
      // extract number from the field name
      const numberMatch = key.match(/\d+/)
      const number = numberMatch ? parseInt(numberMatch[0]) : undefined;
      // add link name to linkMap
      if (key.includes("link_name")) {
         if (number && typeof value === "string")
            linkMap[number] = { ...linkMap[number], name: value }
      }
      // add link url to linkMap
      else if (key.includes("link_url")) {
         if (number && typeof value === "string")
             linkMap[number] = { ...linkMap[number], url: value }
       }
   }
   // add links to content from linkMap
   content.links = Object.values(linkMap);
}

Далее мы получим name, description и picture из запроса, а также добавим их в нашу переменную content.

// handle post request
if (request.method.toLowerCase() === "post") {
   ...
   // map to store all links in the form
   const linkMap: Record<number, Link> = {}
   // iterate over all entries in the form data 
   for (const [key, value] of formData) {
       ...
       // add name and description to content
       else if ((key === "name" || key === "description") && typeof value === "string") {
          content[key] = value;
        }
        // add profile picture to content
        else if (key === "picture" && typeof value !== "string") {
           content[key] = await toBase64(value);
        }
   }
   ...
}

Хорошо, последнее, что нам нужно сделать, это добавить контент в наш магазин KV и отобразить страницу успеха.

// handle post request
if (request.method.toLowerCase() === "post") {
   ...
   // generate a unique ID
   const id = generateId();
   // add content to KV
   await LINK_IN_BIO.put(id, JSON.stringify(content));
   // display success page
   return new Response(successPage(id), { headers: { 'Content-Type': 'text/html' } });
}

Издательский

Мы закончили с нашей реализацией, так что давайте развернем ее. Чтобы опубликовать наш Cloudflare Worker, нам нужно выполнить следующую команду:

$ npx wrangler publish

Теперь наши работники будут бегать по всему миру. Потрясающий!

Заключение

Есть еще некоторые модификации, которые мы могли бы сделать, такие как добавление аутентификации, настраиваемых идентификаторов и настраиваемых шаблонов профилей. Но я надеюсь, что это руководство дало вам представление о типах приложений, которые вы можете создавать с помощью Cloudflare Workers и KV.

Надеюсь, вам понравилось читать эту статью. Вы можете найти исходный код проекта на Github, а также ознакомиться с живой демонстрацией.