pushes on tangled sites.wisp.place/zzstoatzz.io/punch
fun tangled
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

worker serves /leaderboard.json dynamically, UI fetches from worker URL

drops the in-cycle wispctl publish step in favor of KV-cached payload
served from the worker's fetch handler. wisp's 10-min edge cache was
the reason the UI lagged behind the data layer; fetching from the
worker bypasses that entirely.

bun run publish now ships only html/css/js; the leaderboard file is no
longer part of the wisp manifest.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+52 -191
+1 -1
package.json
··· 3 3 "type": "module", 4 4 "scripts": { 5 5 "dev": "bun run --hot src/server.ts", 6 - "publish": "curl -sf https://sites.wisp.place/zzstoatzz.io/punch/leaderboard.json -o public/leaderboard.json && wispctl deploy zzstoatzz.io --path public --site punch --password \"$PUNCH_APP_PASSWORD\" --yes" 6 + "publish": "rm -f public/leaderboard.json && wispctl deploy zzstoatzz.io --path public --site punch --password \"$PUNCH_APP_PASSWORD\" --yes" 7 7 }, 8 8 "devDependencies": { 9 9 "@types/bun": "latest"
+5 -1
public/app.js
··· 47 47 48 48 // ---- data ---- 49 49 50 + // the CF Worker serves the leaderboard straight from KV so we sidestep 51 + // wisp's edge cache (max-age=600 meant 10-min staleness). 52 + const LEADERBOARD_URL = "https://punch-indexer.nate-8fe.workers.dev/leaderboard.json"; 53 + 50 54 async function load() { 51 55 try { 52 - const r = await fetch("./leaderboard.json", { cache: "no-store" }); 56 + const r = await fetch(LEADERBOARD_URL, { cache: "no-store" }); 53 57 if (!r.ok) throw new Error(`http ${r.status}`); 54 58 const payload = await r.json(); 55 59 rows = payload.rows ?? [];
+46 -18
worker/src/index.ts
··· 1 1 // punch-indexer worker entry point. 2 2 // 3 - // scheduled() runs every KNOT_CHUNK_MINUTES minutes: 3 + // scheduled() runs every cron tick: 4 4 // 1. pick N knots with the oldest last_drained_at (or NULL first) 5 5 // 2. drain each via wss://{host}/events?cursor={stored-cursor} 6 6 // 3. bump pushes per committerDid in D1, advance cursors 7 7 // 4. resolve any new committerDids via the handle cascade 8 - // 5. build the leaderboard JSON and publish the single file to wisp 8 + // 5. build the leaderboard payload, stash it in KV under `board` 9 9 // 10 - // sized so a single invocation stays comfortably under the 15-min CPU ceiling 11 - // even when a knot has a lot of backlog. 10 + // GET /leaderboard.json serves the stashed payload dynamically — bypasses 11 + // wisp's edge cache so the UI always sees the latest cron's output. 12 12 13 - import type { Env, KnotRow } from "./types.ts"; 13 + import type { Env, KnotRow, LeaderboardPayload } from "./types.ts"; 14 14 import { drainKnot } from "./drain.ts"; 15 15 import { resolveMissing } from "./handles.ts"; 16 16 import { buildLeaderboard } from "./render.ts"; 17 - import { publishFile } from "./wisp.ts"; 18 17 19 18 const KNOT_CHUNK_SIZE = 30; 20 19 const DRAIN_OPTS = { ··· 23 22 connectTimeoutMs: 6_000, 24 23 } as const; 25 24 25 + const BOARD_KEY = "board"; 26 + const CORS_HEADERS = { 27 + "access-control-allow-origin": "*", 28 + "access-control-allow-methods": "GET, OPTIONS", 29 + "access-control-allow-headers": "content-type", 30 + } as const; 31 + 26 32 export default { 27 33 async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise<void> { 28 34 ctx.waitUntil(runCycle(env)); 29 35 }, 30 36 31 - // handy for `wrangler dev --test-scheduled` — GET /__run fires one cycle 32 37 async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> { 33 38 const url = new URL(req.url); 39 + 40 + if (req.method === "OPTIONS") { 41 + return new Response(null, { headers: CORS_HEADERS }); 42 + } 43 + 44 + if (url.pathname === "/leaderboard.json") { 45 + return serveBoard(env); 46 + } 47 + 34 48 if (url.pathname === "/__run") { 35 49 ctx.waitUntil(runCycle(env)); 36 50 return new Response("dispatched\n"); 37 51 } 52 + 38 53 return new Response("punch-indexer worker\n", { 39 54 headers: { "content-type": "text/plain" }, 40 55 }); 41 56 }, 42 57 }; 43 58 59 + async function serveBoard(env: Env): Promise<Response> { 60 + const cached = await env.HANDLES.get(BOARD_KEY); 61 + if (cached) { 62 + return new Response(cached, { 63 + headers: { 64 + "content-type": "application/json; charset=utf-8", 65 + "cache-control": "no-cache, no-store, must-revalidate", 66 + ...CORS_HEADERS, 67 + }, 68 + }); 69 + } 70 + const stub: LeaderboardPayload = { 71 + rows: [], 72 + updatedAt: new Date().toISOString(), 73 + knots: [], 74 + totalPushes: 0, 75 + status: "warming up — first cron tick pending", 76 + }; 77 + return Response.json(stub, { headers: CORS_HEADERS }); 78 + } 79 + 44 80 async function runCycle(env: Env): Promise<void> { 45 81 const chunk = await pickChunk(env, KNOT_CHUNK_SIZE); 46 82 if (chunk.length === 0) { ··· 88 124 await resolveMissing(env, [...seenDids]); 89 125 } 90 126 91 - // publish 127 + // build + cache in KV; fetch handler serves from there 92 128 const payload = await buildLeaderboard(env, null); 93 - const bytes = new TextEncoder().encode(JSON.stringify(payload, null, 2)); 94 - await publishFile( 95 - env.PUNCH_HANDLE, 96 - env.PUNCH_APP_PASSWORD, 97 - "punch", 98 - "leaderboard.json", 99 - bytes, 100 - "application/json", 101 - ); 129 + await env.HANDLES.put(BOARD_KEY, JSON.stringify(payload)); 102 130 103 131 await env.DB.prepare( 104 132 "UPDATE meta SET value = ? WHERE key = 'last_published_at'", 105 133 ).bind(payload.updatedAt).run(); 106 134 107 - console.log(`published ${payload.rows.length} rows at ${payload.updatedAt}`); 135 + console.log(`cached ${payload.rows.length} rows at ${payload.updatedAt}`); 108 136 } 109 137 110 138 async function pickChunk(env: Env, n: number): Promise<KnotRow[]> {
-171
worker/src/wisp.ts
··· 1 - // publish a single file to an existing place.wisp.fs site without re-uploading 2 - // everything. steps: 3 - // 1. resolve the handle's PDS + DID via plc.directory 4 - // 2. createSession with app password -> access token 5 - // 3. uploadBlob for the new file bytes 6 - // 4. getRecord on the existing manifest 7 - // 5. splice in the new blob ref for the target filename 8 - // 6. putRecord to update the manifest 9 - // 10 - // this keeps all other files (html/css/js/og.png) untouched. a full-site 11 - // redeploy still goes through wispctl locally. 12 - 13 - interface Session { 14 - accessJwt: string; 15 - did: string; 16 - handle: string; 17 - pds: string; 18 - } 19 - 20 - interface BlobRef { 21 - $type: "blob"; 22 - ref: { $link: string }; 23 - mimeType: string; 24 - size: number; 25 - } 26 - 27 - interface FileNode { 28 - $type: "place.wisp.fs#file"; 29 - type: "file"; 30 - base64: boolean; 31 - mimeType: string; 32 - encoding?: string; 33 - blob: BlobRef; 34 - } 35 - 36 - interface DirEntry { 37 - name: string; 38 - node: FileNode | DirectoryNode; 39 - } 40 - 41 - interface DirectoryNode { 42 - $type: "place.wisp.fs#directory"; 43 - type: "directory"; 44 - entries: DirEntry[]; 45 - } 46 - 47 - interface Manifest { 48 - root: DirectoryNode; 49 - } 50 - 51 - async function resolvePds(did: string): Promise<string> { 52 - const res = await fetch(`https://plc.directory/${did}`); 53 - if (!res.ok) throw new Error(`plc.directory ${res.status}`); 54 - const doc = (await res.json()) as { 55 - service?: { id: string; type: string; serviceEndpoint: string }[]; 56 - }; 57 - const svc = doc.service?.find( 58 - (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 59 - ); 60 - if (!svc) throw new Error(`no PDS service in did doc for ${did}`); 61 - return svc.serviceEndpoint; 62 - } 63 - 64 - async function resolveHandle(handle: string): Promise<string> { 65 - const url = new URL("https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle"); 66 - url.searchParams.set("handle", handle); 67 - const res = await fetch(url); 68 - if (!res.ok) throw new Error(`resolveHandle ${res.status}`); 69 - const data = (await res.json()) as { did: string }; 70 - return data.did; 71 - } 72 - 73 - async function createSession(pds: string, identifier: string, password: string): Promise<Session> { 74 - const res = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { 75 - method: "POST", 76 - headers: { "content-type": "application/json" }, 77 - body: JSON.stringify({ identifier, password }), 78 - }); 79 - if (!res.ok) throw new Error(`createSession ${res.status} ${await res.text()}`); 80 - const data = (await res.json()) as { accessJwt: string; did: string; handle: string }; 81 - return { ...data, pds }; 82 - } 83 - 84 - async function uploadBlob(session: Session, bytes: Uint8Array, mimeType: string): Promise<BlobRef> { 85 - const res = await fetch(`${session.pds}/xrpc/com.atproto.repo.uploadBlob`, { 86 - method: "POST", 87 - headers: { 88 - "content-type": mimeType, 89 - authorization: `Bearer ${session.accessJwt}`, 90 - }, 91 - body: bytes, 92 - }); 93 - if (!res.ok) throw new Error(`uploadBlob ${res.status} ${await res.text()}`); 94 - const data = (await res.json()) as { blob: BlobRef }; 95 - return data.blob; 96 - } 97 - 98 - async function getManifest(session: Session, siteRkey: string): Promise<Manifest> { 99 - const url = new URL(`${session.pds}/xrpc/com.atproto.repo.getRecord`); 100 - url.searchParams.set("repo", session.did); 101 - url.searchParams.set("collection", "place.wisp.fs"); 102 - url.searchParams.set("rkey", siteRkey); 103 - const res = await fetch(url); 104 - if (!res.ok) throw new Error(`getRecord ${res.status}`); 105 - const data = (await res.json()) as { value: Manifest }; 106 - return data.value; 107 - } 108 - 109 - async function putManifest(session: Session, siteRkey: string, manifest: Manifest): Promise<void> { 110 - const res = await fetch(`${session.pds}/xrpc/com.atproto.repo.putRecord`, { 111 - method: "POST", 112 - headers: { 113 - "content-type": "application/json", 114 - authorization: `Bearer ${session.accessJwt}`, 115 - }, 116 - body: JSON.stringify({ 117 - repo: session.did, 118 - collection: "place.wisp.fs", 119 - rkey: siteRkey, 120 - record: manifest, 121 - }), 122 - }); 123 - if (!res.ok) throw new Error(`putRecord ${res.status} ${await res.text()}`); 124 - } 125 - 126 - function replaceEntry(dir: DirectoryNode, name: string, node: FileNode): void { 127 - const idx = dir.entries.findIndex((e) => e.name === name); 128 - const entry: DirEntry = { name, node }; 129 - if (idx >= 0) dir.entries[idx] = entry; 130 - else dir.entries.push(entry); 131 - } 132 - 133 - /** 134 - * publish a single file into an existing wisp site manifest. 135 - * caller supplies the file bytes + filename; we upload the blob, 136 - * splice it into the site's root directory, and update the record. 137 - * 138 - * JSON files must be uploaded as application/octet-stream at the blob layer 139 - * or the PDS serializes its internal fs.ReadStream object instead of the 140 - * actual bytes. the file-node mimeType is what the browser sees, so we 141 - * still advertise application/json there. (see memory: 142 - * atproto_pds_json_blob_bug.md) 143 - */ 144 - export async function publishFile( 145 - handle: string, 146 - appPassword: string, 147 - siteRkey: string, 148 - filename: string, 149 - bytes: Uint8Array, 150 - mimeType: string, 151 - ): Promise<void> { 152 - const did = await resolveHandle(handle); 153 - const pds = await resolvePds(did); 154 - const session = await createSession(pds, handle, appPassword); 155 - 156 - const blobUploadType = mimeType === "application/json" ? "application/octet-stream" : mimeType; 157 - const blob = await uploadBlob(session, bytes, blobUploadType); 158 - 159 - const manifest = await getManifest(session, siteRkey); 160 - 161 - const fileNode: FileNode = { 162 - $type: "place.wisp.fs#file", 163 - type: "file", 164 - base64: false, 165 - mimeType, // what browsers see (application/json for the leaderboard) 166 - blob, 167 - }; 168 - replaceEntry(manifest.root, filename, fileNode); 169 - 170 - await putManifest(session, siteRkey, manifest); 171 - }