将 Hexo 部署到 Cloudflare Workers Site 上的趟坑记录

将 Hexo 部署到 Cloudflare Workers Site 上的趟坑记录

技术向约 3.6 千字

为了进一步缩短自己的博客的 TTFB,我决定将自己的博客部署到 Cloudflare Workers Site。没想到我会把几天时间花费在折腾这个上。

缘由

哪个男孩不想拥有一个速度特别快的博客 非常关心博客速度的苏卡卡时不时就用 Google 的 Pagespeed 跑一次分。终于 Google Pagespeed 给我报了一项问题 —— 「网站 TTFB 过高(0.15s)」。

苏卡卡的博客在 Serverless 平台上,套了一层 Cloudflare,同时启用了 Cloudflare Argo。Cloudflare 控制台的数据是没有经过 Argo 优化的回源用时为 385ms;即使通过 Argo 回源平均用时也有 165ms。而就算这样,Google Pagespeed 也觉得不够快。

blog-cf-argo

好嘛,去年九月份 Cloudflare Workers 放出了一个新产品「Cloudflare Workers Site」,与所有的静态网站生成器(Hexo、Hugo、Jekyll、Gatsby 等)兼容。Cloudflare Workers Site 是 Cloudflare Workers 提供了 KV Storage(Workers KV)以后开发出的一个功能,将静态文件存储在 KV Storage 中,Cloudflare Workers 从 KV 存储中获取文件并以 HTTP 响应的形式返回,实现静态网站托管。

这听起来几乎太完美了,一个类似于 GitHub Pages 的静态网站托管服务,直接可以部署在 Cloudflare 的 200+ 数据中心上,免去了回源的几百毫秒延时。

从 CLI 进行首次部署

Cloudflare 的文档提供了相对详细的教程 介绍如何将现有的网站迁移到 Cloudflare Workers Site。按照教程一步一步操作即可。

安装 Wrangler CLI

既然已经有了 Node.js 环境,通过 NPM 安装 Wrangler CLI 自然是最合适的选择。

$ npm i @cloudflare/wrangler -g
# yarn global add @cloudflare/wrangler # 我反正喜欢用 yarn

创建 API Token

使用 Wrangler CLI 前需要 先申请一个 API Token。Cloudflare 提供了包括「Edit Cloudflare Workers」在内的一系列 Token 模板,直接选择 Workers 那个即可。不过需要注意的是,在下一步设置 Token 权限时将下图两个权限去掉:部署 Cloudflare Workers Site 不需要用到这两个权限。

cfworkers-tokens-perm

其余的配置同理,遵循「权限最小化」原则配置 API Token 的账户限制、域名限制还有有效期。

初始化项目

在现有的项目(如 Hexo 站点目录)下执行:

$ wrangler init --site my-static-site # 其中 my-static-site 是目录名称

Wrangler CLI 会使用 Cloudflare Workers Site 的模板在项目里新生成一个 workers-site 目录和一个 wrangler.toml 文件(为什么不用 YAML 呢,TOML 一股 ini 味)。接着手动配置 wrangler.toml

[site]
bucket = "./public" # 生成的 dist 路径,对于 Hexo 和 Hugo 来说就是 public
entry-point = "workers-site" # Cloudflare Workers 相关代码存放处,默认就是 workers-site 目录

如果要将 Cloudflare Workers Site 部署在自己的域名下,还需要一些额外的配置:

account_id = "" # Cloudflare Account ID,去 Cloudflare 的控制面板找找
workers_dev = false # 是否启用 workers.dev 子域名
route = "blog.skk.moe/*" # Workers 所载的 Route
zone_id = "" # 域名在 Cloudflare 的 Zone ID

接着,运行 wrangler config,按照提示输入前一步获取到的 API Token 即可。

预览和发布

运行下述命令即可对 Cloudflare Workers Site 进行预览:

$ wrangler preview --watch # 将 bucket 中的文件上传到 Workers KV 中,浏览器会自动打开一个窗口进行预览

运行下述命令即可将网站发布到 Cloudflare Workers Site:

$ wrangler publish # 将 Workers Site 发布到生产环境

使用 GitHub Action 持续集成 Hexo 并部署到 Cloudflare Workers Site

使用 Cloudflare Workers Site 部署 Hexo 听起来很简单,一样的 hexo g,然后把 hexo d 换成 wrangler publish。但是我一直以来都在使用 Travis CI 持续集成和部署 Hexo,怎么能因为换了 Cloudflare Workers Site 就放弃了 CI/CD?趁着这个机会,还可以从 Travis CI 迁移到 GitHub Action 上。

编写 GitHub Action 配置文件

在目录下新建目录 .github/workflows,并在该目录下新建任意名称的 YAML 文件,该文件将作为 GitHub Actions 的配置文件。

作为 Hexo 的核心团队成员之一,我强烈建议不要使用任何 Hexo 的 GitHub Action。把 Hexo 当成一个普通的依赖 Node.js 的构建程序、遵照这个思路编写 GitHub Action 的配置文件即可。

name: My Hexo Blog
on:
  push:
    branches:
      - master
jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version:
          - 12.x
    steps:
      - name: Checkout
      uses: actions/checkout@v2
      with:
        # 令 GitHub 在 git clone 和 git checkout 后「忘记」使用的 credentials。
        # 如果之后需要以另外的身份(如你的 GitHub Bot)执行 git push 操作时(如部署到 GitHub Pages),必须设置为 false。
        persist-credentials: false
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node-version }}
    # 缓存 node_modules,缓存机制参见 GitHub 文档:https://help.github.com/en/actions/configuring-and-managing-workflows/caching-dependencies-to-speed-up-workflows
    - name: Cache node_modules
      uses: actions/cache@v1 # 使用 GitHub 官方的缓存 Action。
      env:
        cache-name: hexo-node-modules
      with:
        path: node_modules
        key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('package-lock.json') }} # 使用 package-lock.json 的 Hash 作为缓存的 key。也可以使用 package.json 代替
    # Wrangler 在构建时会在 workers-site 目录下执行 npm i,因此也要缓存这里的 node_modules
    - name: Cache workers-site/node_modules
      uses: actions/cache@v1
      env:
        cache-name: workers-site-node-modules
      with:
        path: workers-site/node_modules
        key: ${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('workers-site/package-lock.json') }}
    - run: npm i # 执行 Hexo 的依赖安装
    # 完成 npm i 后,hexo 已经被链接到 node_modules 下的 bin 目录、并被注册在 Node.js 的 $PATH 中
    # Hexo 博客的 package.json 中默认注册了这些 script:clean/build/deploy/server
    # 因此,在目录下执行 npm run build 等同于执行 hexo g,但是不需要全局安装 hexo-cli
    - run: npm run build

先将配置文件推到 GitHub 上,如果 GitHub Action 自动触发开始构建,且没有错误发生,再添加部署到 Cloudflare Workes Site 的相关配置。

通过 GitHub Action 将构建结果发布至 Cloudflare Workers Site

Cloudflare 推出了 Wrangler CLI 的 GitHub Action,通过引入 wrangler-action 可以直接执行 wrangler publish。先在 GitHub 仓库的设置页面添加 Secrets 环境变量,内容为之前生成的 Cloudflare 的 API Token(当然,如果遵循「权限最小化」和分割原则,还是为 GitHub Action 专门再生成一个 API Token)。

然后,在之前的 GitHub Action 配置文件的结尾补充以下内容:

- name: Deploy to Cloudflare Workers
  uses: cloudflare/wrangler-action@1.1.0
  with:
    apiToken: ${{ secrets.CF_WORKERS_TOKEN }} # 前一步设置的 Secrets 的名称
    # Wrangler Action 也支持使用传统的 Global API Token + Email 的鉴权方式,但不推荐

自定义 Cloudflare Workers Site 的行为

相比之前使用的 Serverless 平台可以通过配置文件充当 Router、以及为不同路径添加响应头等,Cloudflare Workers 都需要编写代码才能实现。

虽然 Cloudflare Workers 提供了一个 Router 的样例,其 API 和 Express 非常接近,然而我不需要通过 Router 将请求引导至不同的后端 Endpoint,而且 Cloudflare 给出的 Router 代码 过于的简陋和 …… 算了,你自己点进去看看就知道了。

这给了我一个 Cloudflare Workers Team 不太会写 JavaScript 的感觉。没有想到的是,这个感觉接下来会被不断地印证。

好在 Cloudflare Workers 正如其名是个 Workers,worker-site 目录下的 index.js 不到 1000 行的代码,dirty hack 不是什么问题。

你可以在 Cloudflare Workers Site 的模板仓库 查看完整的代码,我在这里只给出一小部分样例

import { getAssetFromKV, mapRequestToAsset } from '@cloudflare/kv-asset-handler'

addEventListener('fetch', event => {
  try {
    event.respondWith(handleEvent(event))
  } catch (e) { /* Some error handler stuff */ }
})

async function handleEvent(event) { 
  try {
    const page = await getAssetFromKV(event, {})
    // allow headers to be altered
    const response = new Response(page.body, page)
    response.headers.set('X-XSS-Protection', '1; mode=block')
    return response
  } catch (e) {
    // if an error is thrown try to serve the asset at 404.html
    try {
      let notFoundResponse = await getAssetFromKV(event, {
        mapRequestToAsset: req => new Request(`${new URL(req.url).origin}/404.html`, req),
      })
      return new Response(notFoundResponse.body, { ...notFoundResponse, status: 404 })
    } catch (e) {}
  }
}

上面这份代码再一次让我觉得 Cloudflare Workers Team 的 JavaScript 水平堪忧,例如他们在样例代码中声明一个没有再次赋值的变量 notFoundResponse 时用的是 let 而不是 const(你不能说他们不用 const,因为第 12 行刚刚用了一次)。还有,读过 @cloudflare/kv-asset-handler 的文档和其 Types 定义的人都会知道 getAssetFromKV 返回的就是 Promise<Response> 对象,没必要 new Response()

上述样例代码介绍了在 Cloudflare Workers Site 中设置响应头的方法,以及如何将 404.html 文件作为 404 响应的输出。所以,是时候动手 hack 一下了,先在 handleEvent 中添加 URL 解析:

async function handleEvent(event) {
  const { origin, pathname: path, search } = new URL(event.request.url);
  // Other stuff goes here
}

接下来,将以 index.html 结尾的请求 301 跳转到不包含 index.html 的 URL:

if (path.endsWith('/index.html')) {
  return new Response(null, {
    status: 301,
      headers: {
        'Location': `${origin}${path.substring(0, path.length - 10)}${search}`,
        'Cache-Control': 'max-age=3600'
      }
  });
}

由于 Cloudflare 的 kv-asset-handler 包含了处理 Cloudflare CDN 缓存的逻辑,因此设置在 CDN 上缓存不能通过手动添加 Cache-Control 响应头实现(Cloudflare 的 Workers 在 Cache Layer 外面,通过 Workers 设置的响应头 Cache Layer 是读不到的),而是需要作为 getAssetFromKV 的参数传进去。与此同时,顺便去掉不必要的 new Response

const response = await getAssetFromKV(event, {
  cacheControl: {
    edgeTtl: 60 * 60,
    browserTtl: 5 * 60
  }
});

虽然为 getAssetFromKV 传入参数后会自动添加响应的 Cache-Control 响应头,但是 CSS 等静态资源文件、以及 RSS 等应该比 HTML 缓存更久一些,所以可以根据路径在前面添加专门的处理逻辑:

if (path === '/atom.xml') {
  return getAssetFromKV(event, {
    cacheControl: {
      edgeTtl: 60 * 60;
      browserTtl: 2 * 60 * 60;
    }
  });
}

if (path.startsWith('/css/')) } {
  const response = await getAssetFromKV(event, {
    cacheControl: {
      edgeTtl: 365 * 24 * 60 * 60;
      browserTtl: 365 * 24 * 60 * 60;
    }
  });
  // getAssetFromKV 不会添加 immutable,所以需要手动覆盖掉 Cache-Control
  response.headers.set('cache-control', `public, max-age=${365 * 24 * 60 * 60}, immutable`);
  return response;
}

const response = await getAssetFromKV(event, {
  cacheControl: {
    edgeTtl: 60 * 60,
    browserTtl: 5 * 60
  }
});

response.headers.set('X-XSS-Protection', '1; mode=block');
return response;

让 Cloudflare Workers Site 处理中文路径

搞定 Cache-Control 和响应头、嘲笑并议论 小飞机 Cloudflare 的代码,然后把自己的博客部署到 Cloudflare Workers Site,信心满满感觉博客快了几百毫秒,应该去买杯奶茶犒劳一下自己。

结果,不到半小时监控就开始报警了,博客 4xx 状态码异常增多的警告开始轰炸我的邮箱和 Telegram。

这 Cloudflare Workers Site 一事无成,像极了我的人生。

还好之前在用的 Serverless 并没有停掉,删掉 Cloudflare Workers 的 Route、无缝回滚到之前的部署方式。4xx 状态码的数量是下去了,但是问题仍然需要解决。好在我已经是 Cloudflare Enterprise Customer 了,可以直接查看 Cloudflare 的原始 Log。Pull 下日志然后 grep 一下,发现 URL 中包含中文字符的请求都 404 了。

作为 Cloudflare Enterprise Customer,遇到这种问题当然是马上给 Cloudflare 发个工单,然后让他们工程师来解决啦。

cf-ticket

「Expected Behavior」、「Known Issue」、「No ETA」、「Migrate Away」、「Thanks for your patience」。

这 Cloudflare 技术支持一事无成,像极了他们的 Cloudflare Workers Site。

Cloudflare 的 kv-asset-handler 是开源的,核心代码甚至不到 200 行,我不妨来看看。

// https://github.com/cloudflare/kv-asset-handler/blob/0be199020400771fda976ffa2975df51b5f9b910/src/index.ts#L100-L104

const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s 
//set to the raw file if exists, else the approriate HTML file
const requestKey = ASSET_MANIFEST[rawPathKey] ? request : options.mapRequestToAsset(request)
const parsedUrl = new URL(requestKey.url)
const pathname = parsedUrl.pathname

kv-asset-handler 通过 URL API 解析得到 URL 中的 pathname,并去除了开头的 /。根据 WHATWG URL 规范,new URL() 得到的 pathname 是经过 encode 过的:

const url = new URL('https://example.com/你好/');
url.href;
// "https://example.com/%E4%BD%A0%E5%A5%BD/"
url.pathname;
// "/%E4%BD%A0%E5%A5%BD/"

与此同时,Wrangler CLI 在 Publish 时存到 Workers KV 里的是原始的路径和文件名:

cf-workers-kv

所以 kv-asset-handler 通过 ASSET_MANIFEST[rawPathKey] 拿到的一定是 undefined,加上没有传入 options.mapRequestToAsset(request)getAssetFromKV 自然会 throw 出一个 Error,最后被外部 catch 到、以 404 的形式展示出来。

值得一提的是,我在查阅他们的 README 时、从样例代码中发现了很荒唐的错误 —— 他们通过 extends 的方式自定义错误类型:

// https://github.com/cloudflare/kv-asset-handler/blob/0be199020400771fda976ffa2975df51b5f9b910/src/types.ts
export class KVError extends Error { /* ... */ }
export class MethodNotAllowedError extends KVError { /* ... */ }
export class NotFoundError extends KVError { /* ... */ }
export class InternalError extends KVError { /* ... */ }

—— 但是他们的 README 中给的错误类型判断 样例代码竟然是:

stupid-cf-kv

Excuse me?先不说 resp 全程都没有被声明,也不说 NotFoundErrorMethodNotAllowedError 一样没有被引入或声明,用 typeof 试图得到 Class 的类名是什么操作?

这已经不是是否擅长的问题了,Cloudflare Workers Team 真的学过怎么写 JavaScript 么?

然而问题还是要修复的。所以我先后开了两个 Pull Request 解决了路径中包含 Non-ASCII 字符的问题,第一个 Pull Request 简单粗暴地引入了 decodeURIComponents 解决 Non-ASCII 字符在 new URL() 时会被 encode 的问题:

-const rawPathKey = new URL(request.url).pathname.replace(/^\/+/, '') // strip any preceding /'s 
+const rawPathKey = decodeURIComponent(new URL(request.url).pathname.replace(/^\/+/, '')) // strip any preceding /'s
//set to the raw file if exists, else the approriate HTML file
const requestKey = ASSET_MANIFEST[rawPathKey] ? request : options.mapRequestToAsset(request)
const parsedUrl = new URL(requestKey.url)
-const pathname = parsedUrl.pathname	
+const pathname = decodeURIComponent(parsedUrl.pathname) // decode percentage encoded path

由于 Cloudflare Workers Team 担心这会引入 Breaking Changes(在 Workers KV 中以 encode 过的字符串作为 key 的用户会有 Unexpected Behavior),我又开了 第二个 Pull Request,改善了向后兼容性。

kv4cf

如果你也正在用 Cloudflare Workers Site、且也遇到了中文字符路径的问题,在等待他们合并我的两个 Pull Request(或者找到更好的解决方案)、发布新版本之前,你可以先为 Cloudflare Workers Site 安装我修改过的 kv4cfkv4cf 的 API 和 @cloudflare/kv-asset-handler 几乎完全一致,但是支持路径包含中文等 Non-ASCII 字符、还提供了正确的自定义错误处理方法(而且使用 JavaScript 而不是 TypeScript 编写,诶嘿嘿)。

kv4cf 安装方法如下:

$ cd workers-site # 前往 workers-site 目录
$ npm uninstall npm uninstall @cloudflare/kv-asset-handler # 卸载 Cloudflare 的 kv-asset-handler
$ npm i kv4cf # 安装 kv4cf

接着,修改 workers-site/index.js 引入 kv4cf

// 将这一行注释掉
// import { getAssetFromKV } from '@cloudflare/kv-asset-handler'
// 然后加上这一行
const { getAssetFromKV } = require('kv4cf');

别问,问就是我喜欢 Node.js 的 CommonJS。反正 Cloudflare Workers 用 webpack 构建是同时支持 CommonJS 和 ESModule 的。

kv4cf 开源在 GitHub 上,欢迎大家围观w。

将 Hexo 部署到 Cloudflare Workers Site 上的趟坑记录
本文作者
Sukka
发布于
2020-06-07
许可协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
如果你喜欢我的文章,或者我的文章有帮到你,可以考虑一下打赏作者
评论加载中...