Building a full-stack application with Cloudflare Pages

我们很高兴地宣布在 Cloudflare Pages 中支持全栈应用程序,我们知道,我们需要对此进行全面展示。我们构建了一个示例图像分享平台,演示如何在 Cloudflare Workers 的帮助下,直接从 Pages 中添加无服务器功能。只需添加一个新文件到您的项目,您就可以添加动态呈现、与其他 API 交互,以及使用 KV 和 Durable Objects 保存数据。全栈应用程序的可能性,结合 Pages 的快速部署周期和无限制的预览环境,让您几乎可以创建任何应用程序。

今天,我们将介绍我们的示例图像分享平台。我们希望能够和朋友分享图片,同时又能将一些图片保持私密。我们使用 Functions 构建一个JSON API(在 KV 和 Durable Objects 上储存数据),与 Cloudflare Images 和 Cloudflare Access 集成,并为前端使用 React。

如果您想直接深入挖掘好东西,我们的演示实例发布在此处代码位于 GitHub 上,不过继续读下去可以了解更温和的方法。

使用 Cloudflare Pages 构建无服务器函数

基于文件的路由

如果您尚不熟悉,Cloudflare Pages 可以连接您的 git 提供商(GitHub 和 GitLab),并自动将您的静态站点部署到 Cloudflare 的网络。Functions 可让您通过添加动态数据来增强这些应用程序。如果您尚未注册,可在此处注册

让我们在项目中创建一个新函数:

// ./functions/time.js


export const onRequest = () => {
  return new Response(new Date().toISOString())
}

git commit-ing 和推送此文件会触发构建并部署您的第一个 Pages 函数。对 /time 的任何请求都将由此函数提供服务,所有其他请求将回退到项目的静态资产中。将 Functions 文件放置在目录中将按您的预期工作:./functions/api/time.js 将在 /api/time 处可用,./functions/some_directory/index.js 将在 /some_directory 处可用。

我们还支持 TypeScript(./functions/time.ts 将以相同方式工作)以及参数化文件:

  • 含有具有单个方括号对的 ./functions/todos/[id].js 将匹配类似 /todos/123 的所有请求;
  • 具有两个方括号对的 ./functions/todos/[[path]].js 将匹配任何数量的路径段的请求(例如 /todos/123/subtasks)。

我们在 @cloudflare/workers-types 库中声明 PagesFunction 类型,您可以用来对您的 Functions 进行类型检查。

动态数据

回到我们的图像分享应用程序,我们假设已经上传一些图像,且想要在主页上展示这些图像。我们需要一个返回图像列表的端点,前端可以调用该端点:

// ./functions/api/images.ts

export const jsonResponse = (value: any, init: ResponseInit = {}) =>
  new Response(JSON.stringify(value), {
    headers: { "Content-Type": "application/json", ...init.headers },
    ...init,
  });

const generatePreviewURL = ({
  previewURLBase,
  imagesKey,
  isPrivate,
}: {
  previewURLBase: string;
  imagesKey: string;
  isPrivate: boolean;
}) => {
  // If isPrivate, generates a signed URL for the 'preview' variant
  // Else, returns the 'blurred' variant URL which never requires signed URLs
  // https://developers.cloudflare.com/images/cloudflare-images/serve-images/serve-private-images-using-signed-url-tokens

  return "SIGNED_URL";
};

export const onRequestGet: PagesFunction<{
  IMAGES: KVNamespace;
}> = async ({ env }) => {
  const { imagesKey } = (await env.IMAGES.get("setup", "json")) as Setup;

  const kvImagesList = await env.IMAGES.list<ImageMetadata>({
    prefix: `image:uploaded:`,
  });

  const images = kvImagesList.keys
    .map((kvImage) => {
      try {
        const { id, previewURLBase, name, alt, uploaded, isPrivate } =
          kvImage.metadata as ImageMetadata;

        const previewURL = generatePreviewURL({
          previewURLBase,
          imagesKey,
          isPrivate,
        });

        return {
          id,
          previewURL,
          name,
          alt,
          uploaded,
          isPrivate,
        };
      } catch {
        return undefined;
      }
    })
    .filter((image) => image !== undefined);

  return jsonResponse({ images });
};

目光敏锐的读者会注意到,我们正在导出 onRequestGet,这可让我们仅响应 GET 请求。

我们还使用 KV 命名空间(通过 env.IMAGES 访问)来存储有关已上传图像的信息。要在您的 Pages 项目中创建绑定,请导航至“设置”选项卡。

与其他 API 配合

Cloudflare Images 是一项实惠、高性能且功能丰富的服务,用于托管和转换图像。您可以创建多个变体来以不同的方式呈现您的图像,以及使用带签名的 URL 控制访问。我们将添加一个函数与该服务的 API 配合并将传入文件上传至 Cloudflare Images:

// ./functions/api/admin/upload.ts

export const onRequestPost: PagesFunction<{
  IMAGES: KVNamespace;
}> = async ({ request, env }) => {
  const { apiToken, accountId } = (await env.IMAGES.get(
    "setup",
    "json"
  )) as Setup;

  // Prepare the Cloudflare Images API request body
  const formData = await request.formData();
  formData.set("requireSignedURLs", "true");
  const alt = formData.get("alt") as string;
  formData.delete("alt");
  const isPrivate = formData.get("isPrivate") === "on";
  formData.delete("isPrivate");

  // Upload the image to Cloudflare Images
  const response = await fetch(
    `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
    {
      method: "POST",
      body: formData,
      headers: {
        Authorization: `Bearer ${apiToken}`,
      },
    }
  );

  // Store the image metadata in KV
  const {
    result: {
      id,
      filename: name,
      uploaded,
      variants: [url],
    },
  } = await response.json<{
    result: {
      id: string;
      filename: string;
      uploaded: string;
      requireSignedURLs: boolean;
      variants: string[];
    };
  }>();

  const metadata: ImageMetadata = {
    id,
    previewURLBase: url.split("/").slice(0, -1).join("/"),
    name,
    alt,
    uploaded,
    isPrivate,
  };

  await env.IMAGES.put(
    `image:uploaded:${uploaded}`,
    "Values stored in metadata.",
    { metadata }
  );
  await env.IMAGES.put(`image:${id}`, JSON.stringify(metadata));

  return jsonResponse(true);
};

持久保存数据

我们已使用 KV 来存储经常读取但鲜少写入的信息。但那些需要更多同步性的功能怎么办呢?

让我们向每张图像添加一个下载计数器。我们可以在 Cloudflare Images 中创建一个 highres 变量,每当用户请求链接时就增加计数。这需要更多一点设置,但是值得我们去做,因为可以解锁项目中 Durable Objects 的强大功能。

我们需要创建并发布能够维持此下载计数的 Durable Object 类:

// ./durable_objects/downloadCounter.js
ts#example---counter

export class DownloadCounter {
  constructor(state) {
    this.state = state;
    // `blockConcurrencyWhile()` ensures no requests are delivered until initialization completes.
    this.state.blockConcurrencyWhile(async () => {
      let stored = await this.state.storage.get("value");
      this.value = stored || 0;
    });
  }

  async fetch(request) {
    const url = new URL(request.url);
    let currentValue = this.value;

    if (url.pathname === "/increment") {
      currentValue = ++this.value;
      await this.state.storage.put("value", currentValue);
    }

    return jsonResponse(currentValue);
  }
}

中间件

如果您在运行函数前需要执行一些代码(例如身份验证或日志记录),Pages 提供易于使用的中间件,可以应用至基于文件的路由的任何层级。通过在目录中创建一个 _middleware.ts 文件,我们就能知道先运行这个文件,然后在调用 next() 时再执行您的函数。

在我们的应用程序中,我们想要阻止未经授权的用户上传图像 (/api/admin/upload) 或删除图像 (/api/admin/delete)。Cloudflare Access 让我们能够向全部或部分应用程序应用基于角色的访问控制,您只需要一个文件就可以将其集成到我们的无服务器功能。我们创建 ./functions/api/admin/_middleware.ts,它将应用于 /api/admin/* 处的所有传入请求:

// ./functions/api/admin/_middleware.ts

const validateJWT = async (jwtAssertion: string | null, aud: string) => {
  // If the JWT is valid, return the JWT payload
  // Else, return false
  // https://developers.cloudflare.com/cloudflare-one/identity/users/validating-json

  return jwtPayload;
};

const cloudflareAccessMiddleware: PagesFunction<{ IMAGES: KVNamespace }> =
  async ({ request, env, next, data }) => {
    const { aud } = (await env.IMAGES.get("setup", "json")) as Setup;

    const jwtPayload = await validateJWT(
      request.headers.get("CF-Access-JWT-Assertion"),
      aud
    );

    if (jwtPayload === false)
      return new Response("Access denied.", { status: 403 });

    // We could also use the data object to pass information between middlewares
    data.user = jwtPayload.email;

    return await next();
  };

export const onRequest = [cloudflareAccessMiddleware];

中间件是一款可任意使用的强大工具,让您能够使用 Cloudflare Access 轻松保护应用程序的部分,或与 Honeycomb 和 Sentry 等可观察性和错误记录平台快速集成。

集成为 Jamstack

“Jamstack”中的“Jam”表示 JavaScript、API 和 Markup。Cloudflare Pages 之前提供了“J”和“M”,将 Workers 放在中间,即可真正实现全栈 Jamstack。

我们已使用 Create React App 作为一个易于使用的示例构建了这个图像分享平台的前端,但是 Cloudflare Pages 本身集成了不断增长的框架数(当前为 23 个),您始终能够配置您自己的整个自定义构建命令

您的前端仅需调用我们已配置的 Functions 并呈现该数据。我们使用 SWR 来简化此过程,但您可以依据自身偏好使用整个 vanilla JavaScript fetch-es 来执行此操作。

// ./src/components/ImageGrid.tsx

export const ImageGrid = () => {
  const { data, error } = useSWR<{ images: Image[] }>("/api/images");

  if (error || data === undefined) {
    return <div>An unexpected error has occurred when fetching the list of images. Please try again.</div>;
  }


  return (
    <div>
      {data.images.map((image) => (
        <ImageCard image={image} key={image.id} />
      ))}
    </div>
  );

}

本地开发

无论速度多快,如果您需要推送每一个更改来测试其效果,那么像这样的项目迭代可能会令人十分痛苦。我们发布了一个与 wrangler 的一流集成,用于 Pages 项目的本地开发,包括对 Functions、Workers、密码、环境变量和 KV 的全面支持。Durable Objects 支持即将推出。

从 npm 安装:

npm install [email protected]

并服务静态资产文件夹或代理您的现有工具:

# Serve a directory
npx wrangler pages dev ./public

# or integrate with your other tools
npx wrangler pages dev -- npx react-scripts start

一路向前,继续构建!

如果您喜欢小狗,我们在这里部署了图像分享应用程序,如果您喜欢代码,则位于 GitHub 上。您可以自己进行挖掘并进行部署!这里有一个五分钟的安装向导,您需要用到 Cloudflare Images、Access、Workers 和 Durable Objects。

我们对于 Pages 平台的未来寄予希望,对于大家构建的产品也十分期待!可前往 #what-i-built channel 炫耀您的全栈应用程序,或前往 Discord 服务器上的 #pages-help channel 获取帮助。