FastComments.com

Val Town 앱에 실시간 댓글 추가


Val Town 은 Deno에서 TypeScript를 실행하므로 val은 실제 서버입니다. 이는 FastComments에 적합합니다: 위젯은 페이지에 삽입되는 script 태그이며, Secure SSO나 웹훅 검증처럼 비밀이 필요한 모든 작업을 동일한 val에서 서버 측으로 실행할 수 있습니다.

이 가이드는 HTTP val에 댓글 위젯을 추가하고, 인덱스 페이지에 댓글 수를 표시하며, 사용자가 이미 가지고 있는 Val Town 계정으로 로그인하고, 댓글 웹훅을 받는 방법을 다룹니다.

시도하는 데 계정이 필요하지 않습니다. 예제는 tenantId: "demo" 를 사용하며, 공유 샌드박스이며, 2단계에서 자신의 것으로 전환하는 방법을 설명합니다.

인덱스 페이지의 댓글 수 Internal Link


인덱스 페이지에서는 행마다 하나의 comment-count 위젯을 렌더링하지 마세요. 이는 게시물당 하나의 요청이 발생합니다. 전체 페이지에 대해 단일 요청으로 처리되는 bulk count를 사용하세요.

각 행에 해당 스레드가 사용하는 urlId를 표시하고, bulk 위젯을 한 번만 로드하세요:

인덱스 페이지의 대량 댓글 수
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는 동일한 사람에 대해 안정적이어야 하며, 그렇지 않으면 매 로그인 시 새로운 댓글 아이덴티티가 생성됩니다.
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 가이드 for the full field list, group-gated threads, and badges.

웹훅 수신 Internal Link

A val은 자연스러운 웹훅 수신기입니다: 안정적인 URL을 가지고 있으며, 서명을 검증할 수 있고, SQLite와 블롭 스토리지를 내장하고 있습니다.

FastComments는 ${timestamp}.${body}에 계정의 API 비밀키로 서명하고 두 개의 헤더를 전송합니다:

웹훅 헤더
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

문제를 일으키는 두 가지

원시 바이트를 검증하세요. JSON을 파싱하고 다시 직렬화하면 키 순서와 공백이 바뀌어 해시가 달라지며, 그 결과 원인 없이 모든 전달이 실패합니다. 이것이 웹훅 수신기가 "그냥 작동하지 않는다"는 일반적인 이유입니다.

상수 시간으로 비교하세요. 서명에 대한 단순 === 연산은 일치한 바이트 수를 누출하게 되며, 이는 바이트를 하나씩 위조하는 데 충분합니다.

이벤트 처리

빠르게 응답하세요. 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 블로그입니다. 리믹스하는 순간 바로 작동하며, 하나의 환경 변수가 여러분의 계정으로 연결됩니다.

SSO demo (live) 은 방문자를 Val Town 계정으로 로그인시키고 그 신원을 위젯에 전달하므로 두 번째 로그인이 필요하지 않습니다.

Webhook receiver (live) 은 모든 전달에 대해 HMAC 서명을 검증하고 이벤트를 SQLite에 저장합니다. 테스트 페이로드에 서명하고 자체에 전달하는 버튼이 있어 실제 웹훅을 설정하기 전에 검증이 성공하는 것을 확인할 수 있습니다.

Agent skills (live) 은 위젯, SSO, REST API, 모더레이션, Disqus에서 마이그레이션까지를 다루는 FastComments 에이전트 스킬 라이브러리입니다. 이를 리믹스하면 Val Town의 에이전트인 Townie가 skills/ 폴더에서 자동으로 스킬을 가져와, 채팅에 문서를 붙여넣지 않아도 에이전트가 댓글을 설정하는 방법을 알게 됩니다.

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