Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol.
1
fork

Configure Feed

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

i can sleep now yay

+82 -46
+5 -3
apps/firehose-service/src/index.ts
··· 21 21 import { closeCacheInvalidationPublisher } from './lib/cache-invalidation' 22 22 import { fetchSiteRecord, handleSiteCreateOrUpdate, listSiteRecordsForDid } from './lib/cache-writer' 23 23 import { closeDatabase, getSiteCache, listAllKnownDids, listAllSiteCaches, listAllSites, upsertSite } from './lib/db' 24 - import { getCurrentSeq, getFirehoseHealth, startFirehose, stopFirehose } from './lib/firehose' 24 + import { getActiveService, getCurrentSeq, getFirehoseHealth, startFirehose, stopFirehose } from './lib/firehose' 25 25 import { closeLeaderRedis, getLeaderInfo, releaseLeadership, runLeaderElection, saveCursor } from './lib/leader' 26 26 import { startRevalidateWorker, stopRevalidateWorker } from './lib/revalidate-worker' 27 27 import { storage } from './lib/storage' ··· 289 289 logger.info('Leader election enabled, waiting to win leadership before starting firehose') 290 290 leaderAbortController = new AbortController() 291 291 292 - // Save cursor to Redis periodically so a new leader can resume from it 292 + // Save cursor to Redis periodically so a new leader can resume from it. 293 + // Namespaced by the currently-active relay — seq is relay-local. 293 294 cursorSaveTimer = setInterval(async () => { 294 295 const seq = getCurrentSeq() 295 - if (seq !== undefined) await saveCursor(seq) 296 + if (seq !== undefined) await saveCursor(seq, getActiveService()) 296 297 }, config.cursorSaveIntervalMs) 297 298 298 299 // Run election loop (non-blocking) ··· 304 305 }), 305 306 () => stopFirehose(), 306 307 leaderAbortController.signal, 308 + config.firehoseService, 307 309 ).catch((err) => logger.error('[Leader] Election loop fatal error', err)) 308 310 } else { 309 311 // Single-instance mode: start firehose directly
+8
apps/firehose-service/src/lib/firehose.ts
··· 49 49 return currentSeq 50 50 } 51 51 52 + export function getActiveService(): string { 53 + return activeService 54 + } 55 + 52 56 export function getFirehoseHealth() { 53 57 return { 54 58 connected: isConnected, ··· 191 195 firehoseHandle?.destroy() 192 196 firehoseHandle = null 193 197 activeService = alternate 198 + // seq numbers are relay-scoped; don't carry the previous relay's cursor. 199 + currentSeq = undefined 194 200 connect(onTooManyFailures) 195 201 return 196 202 } ··· 233 239 stallReconnects = 0 234 240 consecutiveFailures = 0 235 241 activeService = alternate 242 + // seq numbers are relay-scoped; don't carry the previous relay's cursor. 243 + currentSeq = undefined 236 244 firehoseHandle?.destroy() 237 245 firehoseHandle = null 238 246 lastEventTime = Date.now()
+19 -7
apps/firehose-service/src/lib/leader.ts
··· 16 16 const logger = createLogger('firehose-service') 17 17 18 18 const LEADER_KEY = 'wisp:firehose-leader' 19 - const CURSOR_KEY = 'wisp:firehose-cursor' 19 + const CURSOR_KEY_PREFIX = 'wisp:firehose-cursor' 20 + 21 + // Cursors are seq numbers scoped to a specific relay's backfill — they are not 22 + // portable across relays. Namespacing by host prevents a stale cursor from one 23 + // relay stalling a connection to another (accepted WS, zero events). 24 + function cursorKey(service: string): string { 25 + try { 26 + return `${CURSOR_KEY_PREFIX}:${new URL(service).host}` 27 + } catch { 28 + return `${CURSOR_KEY_PREFIX}:${service}` 29 + } 30 + } 20 31 21 32 // Unique ID for this process instance 22 33 const instanceId = randomUUID() ··· 75 86 } 76 87 } 77 88 78 - export async function saveCursor(seq: number): Promise<void> { 89 + export async function saveCursor(seq: number, service: string): Promise<void> { 79 90 try { 80 - await getRedis().set(CURSOR_KEY, String(seq)) 91 + await getRedis().set(cursorKey(service), String(seq)) 81 92 } catch (err) { 82 93 logger.warn('[Leader] Failed to save cursor', { error: String(err) }) 83 94 } 84 95 } 85 96 86 - export async function readCursor(): Promise<number | undefined> { 97 + export async function readCursor(service: string): Promise<number | undefined> { 87 98 try { 88 - const val = await getRedis().get(CURSOR_KEY) 99 + const val = await getRedis().get(cursorKey(service)) 89 100 if (!val) return undefined 90 101 const n = parseInt(val, 10) 91 102 return Number.isNaN(n) ? undefined : n ··· 112 123 onBecomeLeader: (cursor: number | undefined) => void, 113 124 onLoseLeadership: () => void, 114 125 signal: AbortSignal, 126 + initialService: string, 115 127 ): Promise<void> { 116 128 logger.info(`[Leader] Starting election loop (instance: ${instanceId})`) 117 129 ··· 175 187 176 188 isLeader = true 177 189 renewalFailures = 0 178 - const cursor = await readCursor() 179 - logger.info(`[Leader] Won leadership, cursor: ${cursor ?? 'none (starting from head)'}`) 190 + const cursor = await readCursor(initialService) 191 + logger.info(`[Leader] Won leadership, cursor for ${initialService}: ${cursor ?? 'none (starting from head)'}`) 180 192 onBecomeLeader(cursor) 181 193 scheduleRenew() 182 194 }
+7 -7
apps/main-app/public/editor/tabs/CLITab.tsx
··· 9 9 { 10 10 platform: 'macOS (Apple Silicon)', 11 11 filename: 'wisp-cli-aarch64-darwin', 12 - sha256: 'ccf8076d13a32806cd2902941d9ce2825915a7c9c82867288c0921c2eae4db7a', 12 + sha256: '67c7552645d8006daa41fc6c62d7412f9a6aef50cdd04cc2e815189c6d5fa7af', 13 13 }, 14 14 { 15 15 platform: 'macOS (Intel)', 16 16 filename: 'wisp-cli-x86_64-darwin', 17 - sha256: '9ca9e9f4dbfd6422529bcfda1fcbf12ea3b84b19dcbf212b1c3ee46f488306c9', 17 + sha256: '5a0b09c00eac6a8d2b1a8d8c2e54a16cf173cc6c38cc631bf19b0483d093a7f5', 18 18 }, 19 19 { 20 20 platform: 'Linux (ARM64)', 21 21 filename: 'wisp-cli-aarch64-linux', 22 - sha256: '83cff4775f85f597c62fa50d027108b4228feff3c927300fed0d44bc2922dedb', 22 + sha256: 'b23fe58b8c53a670414a2f0cebe38f31630fd8b5ecca099cd85d543ea0c3f2d1', 23 23 }, 24 24 { 25 25 platform: 'Linux (x86_64)', 26 26 filename: 'wisp-cli-x86_64-linux', 27 - sha256: '99225a569f4ad395ca52055f5d602f1d8d649bc9ea9d542e303469a700fa3a73', 27 + sha256: 'f1d4d655f2714879f44bb23318b30aab79f78cb329bbf6b51abe1fd7a6a5bd84', 28 28 }, 29 29 { 30 30 platform: 'Windows (x86_64)', 31 31 filename: 'wisp-cli-x86_64-windows.exe', 32 - sha256: '65da77e8868c8fb900b788e9c613866f7af3920a0274bdb1e76a4ca4b930c5a8', 32 + sha256: 'df9660b27a9d6f8bcebcafab4622be88639d15dbe74649bdb16e5001d8abe041', 33 33 }, 34 34 ] as const 35 35 ··· 58 58 <div className="flex items-center gap-2"> 59 59 <span className="text-sm font-semibold">Wisp CLI</span> 60 60 <Badge variant="secondary" className="text-xs"> 61 - v1.1.0 61 + v1.1.1 62 62 </Badge> 63 63 </div> 64 64 <div className="flex items-center gap-4"> ··· 100 100 101 101 {/* Binary downloads */} 102 102 <div className="p-4 border-b border-border/50"> 103 - <SectionLabel>Binary Downloads v1.1.0</SectionLabel> 103 + <SectionLabel>Binary Downloads v1.1.1</SectionLabel> 104 104 <div className="grid grid-cols-2 gap-2"> 105 105 {BINARIES.map(({ platform, filename, sha256 }) => ( 106 106 <a
+2 -1
cli/index.ts
··· 142 142 } 143 143 } 144 144 145 - program.name('wisp-cli').description('CLI for wisp.place - deploy static sites to the AT Protocol').version('1.1.0') 145 + program.name('wisp-cli').description('CLI for wisp.place - deploy static sites to the AT Protocol').version('1.1.1') 146 146 147 147 // Deploy command (default) 148 148 program ··· 537 537 const { did } = await authenticateOAuth(handle, { 538 538 dbPath: options.db, 539 539 onStatus: bindAuthStatusToSpinner(authSpinner), 540 + forceReauth: true, 540 541 }) 541 542 authSpinner.succeed(`Authenticated as ${did}`) 542 543 }),
+37 -24
cli/lib/auth.ts
··· 12 12 type NodeSavedStateStore, 13 13 requestLocalLock, 14 14 } from '@atproto/oauth-client-node' 15 - import { confirm, log } from '@clack/prompts' 15 + import { log } from '@clack/prompts' 16 16 import { serve as honoNodeServe } from '@hono/node-server' 17 - import { resolvePdsFromHandle } from '@wispplace/atproto-utils' 17 + import { resolveDid, resolvePdsFromHandle } from '@wispplace/atproto-utils' 18 18 import { isBun } from '@wispplace/bun-firehose' 19 19 import { Hono } from 'hono' 20 20 import open from 'open' ··· 94 94 if (!result.moduleAvailable) { 95 95 return 'Secure OS credential storage is unavailable in this build.' 96 96 } 97 - return result.detail ? `Secure OS credential storage failed: ${result.detail}` : 'Secure OS credential storage is unavailable.' 97 + return result.detail 98 + ? `Secure OS credential storage failed: ${result.detail}` 99 + : 'Secure OS credential storage is unavailable.' 98 100 } 99 101 100 102 async function probeKeychain(): Promise<KeychainProbeResult> { ··· 343 345 dbPath?: string 344 346 appPassword?: string 345 347 onStatus?: (message: string) => void 348 + forceReauth?: boolean 346 349 } 347 350 348 351 function emitStatus(options: AuthOptions | undefined, message: string) { ··· 384 387 385 388 const keychainProbe = await probeKeychain() 386 389 const useKeychain = keychainProbe.available 387 - if (!useKeychain) { 388 - log.warn(describeUnavailableKeychain(keychainProbe)) 389 - const fallback = await confirm({ 390 - message: 391 - 'Fall back to storing session tokens unencrypted in SQLite? (On headless systems, prefer --password instead.)', 392 - initialValue: false, 393 - }) 394 - if (!fallback) { 395 - throw new Error('Cannot store session securely. Use --password for app-password authentication.') 396 - } 397 - } 398 - 399 390 const keyringEntryConstructor = useKeychain ? await getKeyringEntryConstructor() : null 400 391 const stateStore = createStateStore(kv) 401 392 const sessionStore = createSessionStore(kv, keyringEntryConstructor) ··· 429 420 let redirectUri = `http://${LOOPBACK_HOST}:${oauthPort}/oauth/callback` 430 421 let client = createOAuthClient(redirectUri) 431 422 const storedDid = kvGet(kv, dirKey) 432 - if (storedDid) { 433 - try { 434 - const session = await client.restore(storedDid) 435 - if (session) { 436 - emitStatus(options, `Restored session for ${storedDid}`) 437 - return { agent: new Agent(session), did: storedDid } 423 + if (storedDid && options.forceReauth) { 424 + kv.del(dirKey) 425 + } else if (storedDid) { 426 + let canRestore = true 427 + if (handle) { 428 + const resolvedDid = await resolveDid(handle) 429 + if (resolvedDid && resolvedDid !== storedDid) { 430 + emitStatus( 431 + options, 432 + `Stored session is for ${storedDid}, but ${handle} resolves to ${resolvedDid}. Re-authenticating.`, 433 + ) 434 + kv.del(dirKey) 435 + canRestore = false 436 + } 437 + } 438 + if (canRestore) { 439 + try { 440 + const session = await client.restore(storedDid) 441 + if (session) { 442 + emitStatus(options, `Restored session for ${storedDid}`) 443 + return { agent: new Agent(session), did: storedDid } 444 + } 445 + } catch { 446 + // Session invalid or expired — clear mapping and re-auth 447 + kv.del(dirKey) 438 448 } 439 - } catch { 440 - // Session invalid or expired — clear mapping and re-auth 441 - kv.del(dirKey) 442 449 } 443 450 } 444 451 445 452 // Need a handle to start a new OAuth flow 446 453 if (!handle) { 447 454 throw new Error('No active session for this directory. Run `wispctl login <handle>` first.') 455 + } 456 + 457 + if (!useKeychain) { 458 + log.warn( 459 + `Session tokens will be stored unencrypted in SQLite: ${describeUnavailableKeychain(keychainProbe)} Use --password for headless app-password auth.`, 460 + ) 448 461 } 449 462 450 463 const preferredPort = oauthPort
+2 -2
cli/package.json
··· 1 1 { 2 2 "name": "wispctl", 3 - "version": "1.1.0", 3 + "version": "1.1.1", 4 4 "main": "./dist/index.js", 5 5 "devDependencies": { 6 6 "@atproto/api": "^0.18.17", ··· 41 41 ], 42 42 "scripts": { 43 43 "dev": "bun run index.ts", 44 - "build": "bun build ./index.ts --outdir ./dist --target node --sourcemap=linked && sed -i '' '1s|#!/usr/bin/env bun|#!/usr/bin/env node|' ./dist/index.js", 44 + "build": "bun build ./index.ts --outdir ./dist --target node --sourcemap=linked --external @napi-rs/keyring --external '@napi-rs/keyring-*' && sed -i '' '1s|#!/usr/bin/env bun|#!/usr/bin/env node|' ./dist/index.js", 45 45 "typecheck": "tsc --noEmit" 46 46 }, 47 47 "type": "module",
+2 -2
packages/create-wisp/package.json
··· 1 1 { 2 2 "name": "create-wisp", 3 - "version": "1.1.0", 3 + "version": "1.1.1", 4 4 "description": "CLI for wisp.place - deploy static sites to the AT Protocol", 5 5 "type": "module", 6 6 "bin": { ··· 10 10 "bin.js" 11 11 ], 12 12 "dependencies": { 13 - "wispctl": "^1.1.0" 13 + "wispctl": "^1.1.1" 14 14 } 15 15 }