老胡茶室
老胡茶室

排错:Astro Integration Middleware 重复执行

胡键

注:本文素材由冯宇提供。

在将我们的 astro paywall integration MonaKiosk 应用到我们自己的英文站点时,我们发现其 middleware 被执行了两次。虽然不会影响功能,因为那个 middleware 本身保证了幂等性,但终究还是让人觉得怪异,于是乎有了本文。

背景

MonaKiosk 是我们自用的 paywall 插件,其目的是帮助 astro 站点快速加上 paywall 功能,实现睡后收入。其主要职责:

  1. 同步商品到 polar.sh
  2. checkout 流程
  3. paywall,即根据当前访问者是否付费决定显示 paywall 还是全文

显然,最后一项需要 middleware。而我们也确实是这么做的,因为 astro integration 支持注入 middleware 嘛。当然,为了知道当前的访问者是谁,内容站点本身需要有自己的用户权限判断。因此,从一开始设计 MonaKiosk 时就敲定:

  1. astro integrator 自行搞定用户权限。
  2. MonaKiosk 负责提供集成方案,从 integrator 工程中获知当前的状态。并根据当前的状态显示不同的 paywall 模板。

为此,我们在插件配置中提供了 isAuthenticated handler,让使用者注入当前的权限上下文,典型实现:

monaKiosk({
  isAuthenticated: (context) => {
    return !!context.locals.user;
  },
});

开发者则在自己的工程中完成对于 context.locals.user 的复制,关于这一点可参考 Better-Auth 的集成指南,里面的代码例子很详尽。当然,你也可以参考我们的文章:出海产品的技术“黄金组合”:Next.js + Better-Auth + Polar + Resend 实战指南

然后在开发者自己的 Middleware 中使用 sequence 函数决定执行顺序,即确保在 MonaKiosk 的 middleware 执行时,数据已经就位。见下例:

...
import { onRequest as monaKioskMiddleware } from "mona-kiosk/middleware";

export const onRequest = sequence(async (context, next) => {
  ...
  return next();
}, monaKioskMiddleware);

整个设计完全符合直觉且能正常工作,直到我们无意间发现 MonaKiosk 中的调试日志被输出了两次。作为 vibe coding 时代有品味和技术要求的技术团队,我们当然要好好查查个中缘由到底如何。

排查

经过与 ai 的结对排查,我们发现 sequence 函数并不会改变 Middleware 的注入顺序,仅仅是将多个 Handler 串联起来形成一个新的 Handler,交给 Middleware 运行而已,具体的代码在 https://github.com/withastro/astro/blob/main/packages/astro/src/core/middleware/vite-plugin.ts 中的这部分:

const code = `
${
  userMiddlewareIsPresent
    ? `import { onRequest as userOnRequest } from '${resolvedMiddlewareId}';`
    : ""
}
import { sequence } from 'astro:middleware';
${preMiddleware.importsCode}${postMiddleware.importsCode}

export const onRequest = sequence(
${preMiddleware.sequenceCode}${preMiddleware.sequenceCode ? "," : ""}
${userMiddlewareIsPresent ? `userOnRequest${postMiddleware.sequenceCode ? "," : ""}` : ""}
${postMiddleware.sequenceCode}
);
`.trim();

很明显,sequence 并没有改变 addMiddleware 的注入顺序。Astro 在解析 Middleware 的时候,会先将插件提供的 addMiddleware 按照 order 参数分为 prepost 两组,分别注入到用户自定义 Middleware 的前后。

至此,真相大白。

补充说明

在最早和 CC 结对时,CC 就已经给出了两个方案:

  • order 参数,改 MonaKiosk 的 Config
  • sequence 函数,文档标注,让开发者自行通过 sequence 改变顺序。

而我通常倾向于多一事不如少一事,尽量避免在插件中包含太多职责,一看可以通过外部调整就能达到效果,自然一开始就采用了后者。在此过程中,CC 亦没有意识到上面所提的那些技术细节。

解决

既然知道了原因,解决办法也就呼之欲出:我们在 MonaKiosk 插件添加了一个 middlewareOrder 参数,允许用户配置 MonaKiosk Middleware 的注入顺序,例如:

monaKiosk({
  isAuthenticated: (context) => {
    return !!context.locals.user;
  }
  middlewareOrder: "post",
}

它可以控制 addMiddleware({order: "pre"}) 的预定义参数,从而改变 MonaKiosk 的 Middleware 的注入顺序。改动完成之后,重复执行问题自此消失不见。

精品内容