构建 Cloudflare 夏季挑战应用程序

233 阅读8分钟

构建 Cloudflare 夏季挑战应用程序

概述

该应用程序的核心是一系列静态 HTML 页面,其中大部分页面具有要提交的表单、用于处理这些提交的后端 API 以及用于持久保存数据的存储层。在 Cloudflare 镜头中,这将指向使用 Pages、Worker 和 Workers KV。虽然这应该是此类项目的首选堆栈,但说实话,这个“应用程序”最初旨在成为具有单个表单的单个 HTML 页面,但随着时间的推移,它的需求列表会随着时间的推移而增长。因此,该项目开始时仍然是一个 Workers Site 项目,由一个 Worker 和一个 Workers KV 命名空间组成。

Workers Sites 是我们Pages产品的前身,是一种模式,您的 Worker 处理对您网站资产的所有请求。在执行此操作时,您的工作站点仍然可以包含后端内容,例如提供一组 JSON API 端点。基本上,Workers Sites 是在 Worker 内部构建单体的一个创造术语,但没有“单体”这个词可能带来的负面关联。鉴于一个 Workers 站点仍然是一个 Worker,这意味着你的单体应用是全球部署的——很难被击败!

与所有工人站点一样,路由是主要关注点。为此,我使用了worktopWeb 框架,其中包括许多其他实用程序中的路由器。*(披露:我也是 worktop 的作者。)*这使我能够快速构建整个应用程序的布局:

import { Router } from 'worktop';
import * as Cache from 'worktop/cache';

const API = new Router;

API.add('GET', '/', (req, res) => {
  res.send(200, 'TODO: send HTML for landing page');
});

API.add('GET', '/rules', (req, res) => {
  res.send(200, 'TODO: send HTML for terms & conditions');
});

API.add('POST', '/signup', (req, res) => {
  res.send(201, 'TODO: parse & save initial registration');
});

API.add('GET', '/submit', (req, res) => {
  res.send(200, 'TODO: render the unique submission form');
});

API.add('POST', '/submit', (req, res) => {
  res.send(201, 'TODO: parse, validate, save submission data');
});

// init; w/ Cache API
Cache.listen(API.run);

在这一点上,没有任何有用的事情发生,但是像这样布局的应用程序框架是我首选的 TODO 列表格式。随着开发的进行,通过并填写处理程序主体是非常令人满意的。此外,Cache.listen文件底部的帮助程序将整个应用程序与缓存 API 集成在一起,我知道这是我想要的,因为无论如何大多数请求都是针对静态 HTML 页面的。

构建和优化客户端页面

从历史上看,部署一个 Workers 站点意味着将您的所有资产上传到 KV 命名空间。然后,您将@cloudflare/kv-asset-handler在您的 Worker 中包含类似的内容,以便传入的请求可以无缝路由到命名空间内的键。但是,我选择了不同的路线。

知道我的每个静态页面 - 最多 - 有一个CSS 样式表,有时只有一个JavaScript 文件,我认为包含一个构建系统将这些资产内联到构建的 HTML 页面中会非常漂亮。这意味着我的静态 HTML 页面对额外资源的网络请求绝对为零,这通常对性能来说是个好消息。

虽然我很想说我这样做纯粹是出于性能原因,但我也必须承认懒惰的我很欣赏我不必设置额外的 URL 路由、处理 KV 资产上传或处理额外的缓存寿命。在这种情况下双赢!

问题是:避免任何外部资产并不是一个共同的目标。事实上,这在很大程度上是我赋予自己的支线任务。由于没有任何框架(至少我知道)可以做到这一点,我不得不组装自己的微型工具包来满足我的需求。

最后,事实证明这是一个有趣的弯路,并没有花很长时间就组装好了。我合并了Stylus,我首选的 CSS 预处理器,并提出了一个相当简单的约定来在需要的地方内联 CSS 和/或 JS 文件。我没有使用花哨的 AST 解析器和转换器,而是选择简单地将 HTML 文件内容作为字符串读取并搜索与<!-- inject:(path) -->格式匹配的 HTML 注释:

<!-- submit/index.html -->
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8"/>
    <title>Submit Project | Cloudflare Developer Summer Challenge</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="icon" type="image/png" href="https://www.cloudflare.com/favicon-128.png">
    <!-- inject:submit/index.styl -->
    <!-- inject:index.js -->
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

在这个例子中,submit/index.html文件正在注入submit/index.styl,它是它自己的样式表,以及index.js脚本,它不在 submit 目录中,因为它被其他页面使用。该工具包查看两个资产路径,将 Stylus 转换为纯 CSS,然后将内容嵌入到适当的标签<script><style>HTML 标签中。

最后,对于生产构建,设置将通过压缩器传递最终的 HTML 源代码,压缩器压缩整个文档,包括注入的任何 CSS 或 JavaScript。这一步是可选的,但通过网络发送更少的字节永远不会有什么坏处。

构建这些页面后,我对加载主页时的网络活动面板感到满意:

您可以看到localhost文档是如何加载的,只分派对favicon-128.png外部托管的文件的单个请求。这三个data:image/*请求是Blob URL,实际上并不传输网络数据包。总而言之,这意味着 HTML 文档是完全自包含的。

将 HTML 包含到 Worker 中

工作人员可以在响应中发送任何内容。当然,这包括一个 HTML 字符串。如果我想让自己变得非常困难,我可以跳过/src使用它自己的构建系统的目录,而是将 HTML、CSS 和 JS 完全写在一个 JS 字符串中。这肯定会奏效,但维护起来将是一场噩梦,并且(至少对我而言)极易出错:

API.add('GET', '/', (req, res) => {
  // Note: Worktop APIs
  res.setHeader('Content-Type', 'text/html;charset=utf-8');
  res.send(200, `
    <!doctype html>
    <html lang="en">
      <head>
        <title>Demo | Insanity</title>
        <style>
          body {
            background: #fff;
            color: #424242;
          }
          /* more */
        </style>
        <script>
          $('form').onsubmit = function (ev) {
            ev.preventDefault();
            // ...
          });
        </script>
      </head>
      <body>
        <!-- my entire page content -->
      </body>
    </html>
  `);
});

值得庆幸的是,我提前计划并且已经有了一个生成更好的HTML 文件的构建系统。所以现在我只需要一种方法将这些构建的输出加载到我的 Worker 代码中。

现在是这个项目工具包的后半部分;我发现拥有两步构建管道是完全可以接受的。在这里,这意味着应该先构建静态站点,然后再构建 Worker。无论如何,我计划使用 TypeScript 来创作我的 Worker,这意味着我已经需要一个构建步骤——这里唯一的变化是这些构建步骤现在必须是顺序和有序的。

Worker 是使用esbuild 构建的,它是一个非常快速的 JavaScript 打包器编译器,也能够翻译 TypeScript。它还有自己的插件系统,这让我有机会添加我需要的“内联我的 HTML 文件”行为。Worker 的构建脚本实际上并不太吓人,它允许 Worker 直接“导入”HTML 文件。这允许上面的疯狂可以安全地替换为这种模式:

import { Router } from 'worktop';
import * as Cache from 'worktop/cache';

// loaded via esbuild plugin
import LANDING from 'index.html';
import RULES from 'rules/index.html';

API.add('GET', '/', (req, res) => {
  res.setHeader('Content-Type', 'text/html;charset=utf-8');
  res.setHeader('Cache-Control', 'public,max-age=60');
  res.send(200, LANDING);
});

API.add('GET', '/rulees', (req, res) => {
  res.setHeader('Content-Type', 'text/html;charset=utf-8');
  res.setHeader('Cache-Control', 'public,max-age=1800');
  res.send(200, RULES);
});

// ...

// init; w/ Cache API
Cache.listen(API.run);

当然,从长远来看,这更清洁、更明智。Clarity 可以更轻松地识别常见模式并将其提取到实用函数中。我借此机会介绍一个render函数,这个项目会遇到的许多可重用助手中的第一个:

// worker/utils.ts
import type { ServerResponse } from 'worktop/response';

export function render(res: ServerResponse, template: string) {
  res.setHeader('Content-Type', 'text/html;charset=UTF-8');
  res.send(200, template);
}

// worker/index.ts
import * as utils from './utils';

API.add('GET', '/', (req, res) => {
  res.setHeader('Cache-Control', 'public,max-age=60');
  return utils.render(res, LANDING);
});

API.add('GET', '/rulees', (req, res) => {
  res.setHeader('Cache-Control', 'public,max-age=1800');
  return utils.render(res, RULES);
});

最后,大多数页面需要动态地将值插入到 HTML 标记中。例如,提交表单应显示参与者的姓名和电子邮件地址,并且登录页面需要反映剩余奖品的当前价值。与任何其他单体应用程序非常相似,工作站点完全意识到并能够在需要时注入这些值。

为此,我{{ variable }}在项目的 HTML 中标准化了语法。在 Worker 请求期间,这些变量中的每一个都将被替换为适当的值。当然,它也需要每个端点实际提供正确的信息来进行替换。考虑到这一点,我修改了 render 实用程序并更新了登录页面的路由处理程序:

// worker/utils.ts
import type { KV } from 'worktop/kv';
import type { ServerResponse } from 'worktop/response';

// TypeScript placeholder
// Defines the `DATA` KV binding
declare const DATA: KV.Namespace;

export function render(res: ServerResponse, template: string, values: Record<string, string> = {}) {
  for (let key in values) {
    template = template.replace('{{ ' + key + ' }}', values[key]);
  }
  res.setHeader('Content-Type', 'text/html;charset=UTF-8');
  res.send(200, template);
}
  
export function toCount(): Promise<string> {
  return DATA.get('::remain', 'text').then(v => v || '300+');
}
  
// worker/index.ts
import * as utils from './utils';

API.add('GET', '/', async (req, res) => {
  // Get the "::remain" count from KV
  const count = await utils.toCount();
  
  // Short-term TTL for remaining swag updates
  res.setHeader('Cache-Control', 'public,max-age=60');
  
  // Render the HTML, passing in `count` variable
  return utils.render(res, LANDING, { count });
});

通过这些更改,登录页面将始终检查 KV 命名空间以获取最新::remain值并将其注入正确的位置。如果您有兴趣查看项目的源代码,您会发现几乎每个 HTML 响应中都使用了这种模式。

接受表格提交

正如预期的那样,这个应用程序大量使用了表单提交。幸运的是,Fetch API 提供了各种内置的正文解析器,使数据的检索变得简单。此外,worktop还提供了一个方便的功能,可以根据请求的Content-Type标头自动调用正确的解析器。它的名字恰如其分req.body()

解析和检索用户数据很容易,但仍需对其进行验证。有多种方法可以做到这一点,所有这些方法都可以归结为一个输入对象、一组规则以及通过这些规则的循环,将任何错误消息收集到一个errors对象中。这正是我的utils.validate助手所做的,让我能够清晰地定义和管理我的内联规则。

让我们看看它在POST /submit接受初始注册表单的处理程序中的外观:

// worker/index.ts
import * as utils from './utils';

API.add('POST', '/signup', async (req, res) => {
  try {
    var input = await req.body<Entry>();
  } catch (err) {
    return toError(res, 400, 'Error parsing input');
  }

  let { email, firstname, lastname } = input || {};
  firstname = String(firstname||'').trim();
  lastname = String(lastname||'').trim();
  email = String(email||'').trim();

  let { errors, invalid } = utils.validate({
    email, firstname, lastname
  }, {
    email(val: string) {
      if (val.length < 1) return 'Required';
      return utils.isEmail(val) || 'Invalid email address';
    },
    firstname(val: string) {
      return val.length > 1 || 'Required';
    },
    lastname(val: string) {
      return val.length > 1 || 'Required';
    }
  });

  if (invalid) {
    return res.send(422, errors);
  }
      
  // The `input` is valid!
  
  return res.send(200, 'TODO: finish me');
});

只有在数据被认为有效后,数据才能存储到KV中以备将来使用。对于初始注册,需要做一些事情:

  1. 确保input.email尚未注册;
  2. 使用 input 值保持新注册,用user:<email>密钥标识每个文档;
  3. 生成并保存注册的唯一代码,以确保(a)未注册的人不能提交项目和(b)注册人只能提交一次;
  4. 向用户发送一封电子邮件,其中包含他们唯一的提交链接;和
  5. 呈现一个确认页面,提醒用户检查他们的收件箱中的链接。

看起来很多,但是在将一些实用程序助手和抽象拼凑起来之后,它实际上会让人感觉很平易近人:

// worker/index.ts
import * as utils from './utils';
import * as Sparkpost from './emails';
import * as Signup from './signup';
import * as Code from './code';

function toError(res: ServerResponse, status: number, reason: string) {
  return res.send(status, { status, reason });
}

API.add('POST', '/signup', async (req, res) => {
  try {
    var input = await req.body<Entry>();
  } catch (err) {
    return toError(res, 400, 'Error parsing input');
  }
  
  let { email, firstname, lastname } = input || {};
  firstname = String(firstname||'').trim();
  lastname = String(lastname||'').trim();
  email = String(email||'').trim();
  
  // truncated: validation
  
  // Ensure email is not already in use
  let exists = await Signup.find(email);
  if (exists) return toError(res, 400, 'You have already signed up');

  // Generate new `Entry` record
  let entry = Signup.prepare({ email, firstname, lastname });

  // create "user:<unique email>" document
  let isOK = await Signup.save(entry);
  if (!isOK) return toError(res, 500, 'Error persisting entry');

  // create "code:<unique value>" document
  isOK = await Code.save(entry);
  if (!isOK) return toError(res, 500, 'Error saving unique code');

  // dispatch "We received your registration" email
  let sent = await Sparkpost.confirm(entry);
  if (!sent) return toError(res, 500, 'Error sending confirmation email');

  // render "Thank you, check your {{ email }} for next steps" page
  return utils.render(res, CONFIRM, { email: entry.email });
});

返回完整的 HTML 响应,这意味着客户端表单处理程序应该能够看到此内容并直接在浏览器窗口中呈现它。这可以在以下index.js代码段中看到,该代码段在前面被引用submit/index.html为注入资产:

// (client) index.js

$('form').onsubmit = async function (ev) {
  ev.preventDefault();

  var form = ev.target;
  var res = await fetch(form.action, {
    method: form.method || 'POST',
    body: new FormData(form),
  });

  // truncate: clear existing errors

  if (res.ok) {
    form.reset();
    // Receive HTML response
    let html = await res.text();
    // Force-write the new HTML into this window
    document.documentElement.innerHTML = html;
  } else {
    // truncate: render errors
  }
};

***奖励:*因为返回了完整的 HTML 响应,并且所有客户端<form>元素在语义上都是正确的,所以表单提交工作流将在禁用 JavaScript 的情况下工作!客户端验证将保持功能,但会降低体验——错误对话框不会弹出,任何错误消息也不会出现在各自的表单输入下方。

发送交易电子邮件

如今,以编程方式发送电子邮件非常简单,这应该(希望如此)不足为奇。我们选择使用 SparkPost,但实际上每个服务都有相同的 API 机制:

  • 获取 API Token
  • 使用以下命令向端点发送 POST 请求:
    • 您的 API 令牌作为Authorization标头
    • 您的收件人、发件人身份和文本和/或 HTML 内容作为 POST 正文
  • 等待 200 级响应,或处理任何 API 错误

大多数电子邮件即服务提供商允许您定义模板,这允许您用每封电子邮件的唯一值替换变量——本质上与我们的utils.render函数对 HTML 内容所做的事情相同。这样做的好处是您只需担心写一次电子邮件;那么您只是将新值发布到 API 端点。

SparkPost 允许通过自定义名称而不是随机生成的标识符来引用模板,这使得随着时间的推移跟踪和调试模板变得容易。

// worker/emails.ts
import type { Entry } from './signup';

// wrangler secret
// @see https://developers.sparkpost.com/api/#header-authentication
declare const SPARKPOST_KEY: string;

/**
 * Assemble the POST request for all SparkPost email triggers
 * @see https://developers.sparkpost.com/api/transmissions/#transmissions-post-send-a-template
 */
async function send(
  templateid: string,
  recipient: Entry,
  values?: Record<string, string>
): Promise<boolean> {
  const res = await fetch('https://api.sparkpost.com/api/v1/transmissions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': SPARKPOST_KEY,
    },
    body: JSON.stringify({
      content: {
        template_id: templateid,
      },
      recipients: [{
        address: {
          email: recipient.email,
          name: recipient.firstname + ' ' + recipient.lastname,
        },
        substitution_data: values || {},
      }]
    })
  });

  let data = await res.json() as {
    results: {
      id: string;
      total_rejected_recipients: number;
      total_accepted_recipients: number;
    }
  };

  return res.ok && data.results.total_accepted_recipients === 1;
}
    
/**
 * Confirming user's signup
 * Sending unique submission form
 */
export function confirm(entry: Entry): Promise<boolean> {
  return send('devchallenge-confirm', entry, {
    firstname: entry.firstname,
    code: entry.code,
  });
}

上面的代码片段包括整个 POST请求格式化程序——类型提示几乎比代码还多!还显示了一个示例confirm方法,它负责向新注册的用户发送唯一的提交链接。您会注意到firstnamecode是“devchallenge-confirm”模板所需的注入变量。

整体表现

我会称之为成功!

尽管这肯定不是我的第一个 Worker 项目——也绝对不会是我的最后一个——我一直惊讶于 Workers 运行时让我逃脱了多少。我的意思是,如果你只能从这篇文章中拿走两点,它们应该是:

  1. 我能够从头开始构建一个中等复杂的应用程序,同时合并一个缓存层、一个全局复制的存储层和一个超高性能的 JS 运行时,所有这些都生活在同一个屋檐下。
  2. 我(可能)花了更多时间处理自定义客户端构建管道,而不是将关键任务 API 表单处理程序拼凑在一起。

最重要的樱桃:如果这场比赛像病毒一样传播并吸引数百万游客,我在月底只需支付几美元。显然我在这里有偏见,但这真的很神奇。

最后,在性能方面,这可能证明花时间摆弄 HTML 构建输出是合理的:

得到教训

正如我之前提到的,如果我要重建这个应用程序,或者如果我要在未来添加更多应用程序,我会用一个 Pages 项目替换 Workers Site 架构,并在它前面部署一个 Worker 以满足我的 API 要求和动态 KV 注入。

由于静态资产将不再嵌入到 Worker 的源代码中,我需要将 utils.render 方法替换为另一个实用程序,该实用程序从 Pages(成为我的“原始服务器”)获取 URL,然后用于HTMLRewriter注入变量。此外,并不是说我接近 1MB 的大小限制,我的 Worker 字节大小的最大贡献者将会消失。

但是,更重要的是,这种重构也会减少我的总工具,因为项目的大部分复杂性在于前端资产的自定义构建系统。换句话说,整个/src目录可以像一个普通的静态网站一样构建和部署,这将允许我利用现有的框架和/或工具包,而不是自己走弯路。这里就没有必要创建一个自定义前端工具包它的桥梁,让加载到我的工人的静态资产。

然而,这并不是说 Workers Sites 对这个应用程序来说是一个糟糕的方法。恰恰相反!这一切都是为了突出 Worker Sites 以及整个 Workers 平台的灵活性。Cloudflare Pages 的存在是为了让我(开发人员)可以依靠现有的、经过广泛应用的路径,让专家担心工具包、构建管道和部署……但这并不能阻止您,常驻专家,自定义每个方面,如果那是你的愿望。

资源