FastComments.com

为 Val Town 应用添加实时评论


Val Town 在 Deno 上运行 TypeScript,因此 val 是一个真实的服务器。这使它非常适合 FastComments:小部件是页面上的 script 标签,任何需要密钥的功能,如 Secure SSO 或验证 webhook,都可以在同一个 val 的服务器端运行。

本指南涵盖了将评论小部件添加到 HTTP val、在索引页面显示评论计数、使用用户已有的 Val Town 账户登录以及接收评论 webhook。

您无需账户即可尝试。示例使用 tenantId: "demo",这是一个共享的沙盒环境,第 2 步将介绍如何切换到您自己的。


索引页上的评论计数 Internal Link

在索引页面上,不要为每一行渲染一个 comment-count 小部件。这会对每篇文章产生一次请求。使用批量计数,它只需要对整页发起一次请求。

为每一行标记其线程使用的 urlId,然后一次性加载批量小部件:

索引页面的批量评论计数
Copy CopyRun External Link
1
2<ul>
3 {posts.map((post) => (
4 <li>
5 <a href={post.slug}>{post.title}</a>{" "}
6 <span class="fast-comments-count" data-fast-comments-url-id={post.slug}></span>
7 </li>
8 ))}
9</ul>
10
11<script
12 dangerouslySetInnerHTML={{
13 __html: `window.FastCommentsBulkCountConfig = ${
14 JSON.stringify({ tenantId: TENANT_ID })
15 };`,
16 }}
17/>
18<script src="https://cdn.fastcomments.com/js/embed-widget-comment-count-bulk.min.js"></script>
19

脚本会查找页面上所有 .fast-comments-count 元素并填充其计数。

data-fast-comments-url-id 必须与帖子评论小部件使用的 urlId 相匹配。如果小部件使用 slug,则标记也使用 slug。若不匹配,则会在已有评论的线程上显示为零。

脚本会轮询 window.FastCommentsBulkCountConfig,因此在脚本标签之前或之后设置配置都没有关系。

使用 std/oauth 的安全单点登录 Internal Link

If your val already knows who the visitor is, Secure SSO hands that identity to the widget so they never see a second login. There are no endpoints to build and nothing to call at runtime: you compute three values server-side and pass them in the widget config.

Val Town ships zero-config login with std/oauth, so the visitor can sign in with the Val Town account they already have. Swap that for whatever your app uses; the FastComments half does not change.

在服务器端构建负载

The API secret signs the payload and must never reach browser code. Install the SDK from npm, which works on Val Town's Deno runtime as-is:

sso.ts
Copy CopyRun External Link
1
2import { SecureSSOPayloadBuilder } from "npm:fastcomments-sdk/server";
3
4export function buildSSOPayload(user) {
5 // id 必须对同一个人保持稳定,否则每次登录都会获得新的评论身份。
6 const id = `vt-${user.id}`;
7
8 return new SecureSSOPayloadBuilder(Deno.env.get("FASTCOMMENTS_API_SECRET"), {
9 id,
10 // email 为必填项且必须唯一。
11 email: user.email ?? `${id}@users.noreply.val.town`,
12 // username 为必填项且不能是电子邮件。
13 username: user.username ?? id,
14 displayName: user.username ?? undefined,
15 avatar: user.links.profileImageUrl ?? undefined,
16 }).getPayload();
17}
18

getPayload() returns { userDataJSONBase64, verificationHash, timestamp }. Those three values are all that reach the browser. The secret signs them and is then dropped, so nothing in the page lets a reader forge a different user.

将其传递给小部件

带 SSO 的小部件配置
Copy CopyRun External Link
1
2import { getOAuthUserData, oauthMiddleware } from "https://esm.town/v/std/oauth/middleware.ts";
3
4app.get("/", async (c) => {
5 const session = await getOAuthUserData(c.req.raw);
6 const user = session?.user;
7
8 const config = {
9 tenantId: TENANT_ID,
10 urlId: "my-thread",
11 ...(user
12 ? { sso: { ...buildSSOPayload(user), logoutURL: "/logout" } }
13 : { sso: { loginURL: "/auth/login" } }),
14 };
15
16 // ...使用此配置渲染小部件
17});
18
19export default oauthMiddleware(app.fetch);
20

oauthMiddleware adds GET /auth/login, GET /auth/callback and POST /auth/logout for you. Note that logout is a POST, while the widget navigates to logoutURL with a GET, so point logoutURL at a small route of your own that submits the POST.

When the visitor is logged out, pass sso with only a loginURL. The widget then shows a login prompt instead of an anonymous comment box.

常见问题

timestamp is epoch milliseconds, must not be in the future, and must not be more than two days old. Generate it on the server in the same request that computes the hash. Generating it in the browser is the classic failure: the value differs from the one that was hashed and every comment is rejected.

Never set isAdmin or isModerator from the identity provider. Signing in with a Val Town account says nothing about who should moderate your site.

See the SSO guide for the full field list, group-gated threads, and badges.

接收 Webhook Internal Link

A val 是一种天然的 webhook 接收器:它拥有稳定的 URL,能够验证签名,并内置 SQLite 和 blob 存储。

FastComments signs ${timestamp}.${body} with your account's API secret and sends two headers:

Webhook 标头
Copy CopyRun External Link
1
2X-FastComments-Timestamp: 1789004710 unix seconds, not milliseconds
3X-FastComments-Signature: sha256=<hex>
4

The method carries the event: PUT for a created or updated comment, DELETE for a deleted one.

验证交付
Copy CopyRun External Link
1
2import { createHmac, timingSafeEqual } from "node:crypto";
3
4async function receive(c) {
5 // 到达的原始字节。不要使用 c.req.json() 并重新序列化。
6 const rawBody = await c.req.raw.text();
7 const timestamp = c.req.raw.headers.get("X-FastComments-Timestamp");
8 const signature = c.req.raw.headers.get("X-FastComments-Signature");
9
10 if (!timestamp || !signature) return new Response("Missing headers", { status: 400 });
11
12 // 拒绝过期的交付,以防捕获的请求被稍后重放。
13 if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > 300) {
14 return new Response("Timestamp outside window", { status: 400 });
15 }
16
17 const expected = "sha256=" + createHmac("sha256", Deno.env.get("FASTCOMMENTS_API_SECRET"))
18 .update(`${timestamp}.${rawBody}`)
19 .digest("hex");
20
21 const a = new TextEncoder().encode(signature);
22 const b = new TextEncoder().encode(expected);
23 if (a.length !== b.length || !timingSafeEqual(a, b)) {
24 return new Response("Signature mismatch", { status: 401 });
25 }
26
27 // ...处理 JSON.parse(rawBody)
28 return Response.json({ received: true });
29}
30
31app.put("/", receive);
32app.delete("/", receive);
33

两个常见问题

验证原始字节。 解析 JSON 并重新序列化会改变键的顺序和空白字符,导致哈希不同,所有交付都会因未知原因失败。这是 webhook 接收器“根本不起作用”的常见原因。

常量时间比较。 对签名使用普通的 === 会泄露匹配的字节数,这足以一次伪造一个字节。

处理事件

快速响应。FastComments 会在非 2xx 响应时重试,且持续失败的端点会被自动禁用,因此应在响应后再进行实际工作,而不是在响应时内联处理。

使处理在评论 ID 上具备幂等性。重试时会使用新的时间戳重新签名,同一评论 ID 在编辑和删除时会再次到达,因此没有稳定的字段可用于去重。


示例 Vals Internal Link

Four public vals you can remix, each covering one piece of this guide.

Blog with comments (live) 是一个 Markdown 博客,每篇文章下都有一个线程,并在索引页显示批量评论计数。它在你 remix 的瞬间即可工作,只需一个环境变量即可指向你的账户。

SSO demo (live) 使用访客的 Val Town 账户登录,并将该身份传递给小部件,因此无需二次登录。

Webhook receiver (live) 对每次交付的 HMAC 签名进行验证,并将事件存储在 SQLite 中。它有一个按钮可以对测试负载进行签名并发送给自身,这样你可以在配置真实 webhook 之前看到验证成功。

Agent skills (live) 是一个 FastComments 代理技能库,涵盖小部件、SSO、REST API、审核以及从 Disqus 迁移。Remix 它后,Val Town 的代理 Townie 会自动从 skills/ 中加载这些技能,使你的代理能够在不需要你将文档粘贴到聊天中的情况下,了解如何接入评论。

The same skills install anywhere else with npx skills add fastcomments/skills.