FastComments.com

Add Live Commenting to Val Town Apps

Val Town runs TypeScript on Deno, so a val is a real server. That makes it a good fit for FastComments: the widget is a script tag on the page, and anything that needs a secret, like Secure SSO or verifying a webhook, can run server-side in the same val.

This guide covers adding the comment widget to an HTTP val, showing comment counts on an index page, signing users in with the Val Town account they already have, and receiving comment webhooks.

You don't need an account to try it. The examples use tenantId: "demo", a shared sandbox, and Step 2 covers switching to your own.

Comment Counts on an Index Page Internal Link

On an index page, don't render one comment-count widget per row. That is one request per post. Use the bulk count, which takes a single request for the whole page.

Mark each row with the urlId its thread uses, then load the bulk widget once:

Bulk comment counts on an index
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

The script finds every .fast-comments-count element on the page and fills in its count.

data-fast-comments-url-id has to match the urlId that post's comment widget uses. If the widget uses the slug, the marker uses the slug. A mismatch shows zero on a thread that has comments.

The script polls for window.FastCommentsBulkCountConfig, so it does not matter whether you set the config before or after the script tag.

Secure SSO with 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.

Build the payload on the server

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 must be stable for the same person, or they get a new comment identity on every login.
6 const id = `vt-${user.id}`;
7
8 return new SecureSSOPayloadBuilder(Deno.env.get("FASTCOMMENTS_API_SECRET"), {
9 id,
10 // email is required and must be unique.
11 email: user.email ?? `${id}@users.noreply.val.town`,
12 // username is required and cannot be an email.
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.

Pass it to the widget

Widget config with 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 // ...render the widget with this config
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.

Things that go wrong

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.

Receiving Webhooks Internal Link

A val is a natural webhook receiver: it has a stable URL, it can verify a signature, and it has SQLite and blob storage built in.

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

Webhook headers
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.

Verifying a delivery
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

Two things that bite

Verify the raw bytes. Parsing the JSON and re-serializing it changes key order and whitespace, so the hash differs and every delivery fails with no obvious cause. This is the usual reason a webhook receiver "just doesn't work".

Compare in constant time. A plain === on the signature leaks how many bytes matched, which is enough to forge one byte at a time.

Handling events

Answer quickly. FastComments retries on a non-2xx, and an endpoint that keeps failing is eventually disabled automatically, so do real work after responding rather than inline.

Make that work idempotent on the comment id. A retry is re-signed with a fresh timestamp, and the same comment id arrives again on edit and delete, so there is nothing stable to deduplicate on.

Example Vals Internal Link

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

Blog with comments (live) is a Markdown blog with a thread under every post and bulk comment counts on the index. It works the moment you remix it, and one environment variable points it at your own account.

SSO demo (live) signs the visitor in with their Val Town account and hands that identity to the widget, so there is no second login.

Webhook receiver (live) verifies the HMAC signature on every delivery and stores events in SQLite. It has a button that signs a test payload and delivers it to itself, so you can watch verification succeed before configuring a real webhook.

Agent skills (live) is a library of FastComments agent skills covering the widget, SSO, the REST API, moderation, and migrating off Disqus. Remix it and Val Town's agent, Townie, picks the skills up out of skills/ automatically, so your agent knows how to wire up comments without you pasting documentation into the chat.

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