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

インデックスページでは、行ごとにコメントカウントウィジェットを1つずつレンダリングしないでください。これは投稿ごとに1リクエストになります。ページ全体で1回のリクエストで済む一括カウントを使用してください。

urlId(スレッドが使用する)で各行にマークし、まとめてウィジェットを1回だけロードします:

インデックスページの一括コメント数
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 と一致する必要があります。ウィジェットがスラッグを使用する場合、マーカーもスラッグを使用します。不一致の場合、コメントがあるスレッドでも0が表示されます。

スクリプトは window.FastCommentsBulkCountConfig をポーリングするため、設定をスクリプトタグの前に置くか後に置くかは関係ありません。

std/oauth による安全な SSO 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 は同一人物に対して安定している必要があります。さもなければ、ログインのたびに新しいコメント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 は ${timestamp}.${body} にアカウントの API シークレットで署名し、2 つのヘッダーを送信します。

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

このメソッドはイベントを伝えます。作成または更新されたコメントには PUT、削除されたコメントには DELETE を使用します。

配信の検証
Copy CopyRun External Link
1
2import { createHmac, timingSafeEqual } from "node:crypto";
3
4async function receive(c) {
5 // The exact bytes that arrived. Do NOT use c.req.json() and re-serialize.
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 // Reject stale deliveries so a captured request cannot be replayed later.
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 // ...handle JSON.parse(rawBody)
28 return Response.json({ received: true });
29}
30
31app.put("/", receive);
32app.delete("/", receive);
33

問題となる2つの点

生バイトを検証する。 JSON を解析して再シリアライズするとキーの順序や空白が変わり、ハッシュが一致せず、明確な原因なしにすべての配信が失敗します。これが Webhook 受信者が「うまく動かない」一般的な理由です。

一定時間で比較する。 署名に対して単純な === を使用すると、何バイト一致したかが漏洩し、1 バイトずつ偽造するのに十分です。

イベントの処理

迅速に応答してください。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ブログです。リミックスした瞬間に動作し、1つの環境変数で自分のアカウントを指すようになります。

SSO demo (live) は、訪問者をVal Townアカウントでサインインさせ、そのアイデンティティをウィジェットに渡すので、二度目のログインは不要です。

Webhook receiver (live) は、各配信でHMAC署名を検証し、イベントをSQLiteに保存します。テストペイロードに署名して自身に配信するボタンがあり、実際のWebhookを設定する前に検証が成功する様子を確認できます。

Agent skills (live) は、ウィジェット、SSO、REST API、モデレーション、Disqusからの移行をカバーするFastCommentsエージェントスキルのライブラリです。リミックスすると、Val TownのエージェントであるTownieが skills/ から自動的にスキルを取得するため、チャットにドキュメントを貼り付けなくてもエージェントがコメント機能を設定できるようになります。

同じスキルは、npx skills add fastcomments/skills を使って他の場所にもインストールできます。