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

Configure Feed

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

wisp-cli typescript mvp

+697 -42
+3 -6
cli/commands/deploy.ts
··· 407 407 ); 408 408 409 409 // 5. Build directory structure 410 - const { directory: rawDirectory, fileCount } = processUploadedFiles(uploadedFiles); 410 + // CLI paths are already relative to site directory, so skip normalization 411 + const { directory: rawDirectory, fileCount } = processUploadedFiles(uploadedFiles, { skipNormalization: true }); 411 412 const successfulPaths = new Set(filePaths); 412 - const directory = updateFileBlobs(rawDirectory, uploadResults, filePaths, '', successfulPaths); 413 + const directory = updateFileBlobs(rawDirectory, uploadResults, filePaths, '', successfulPaths, { skipNormalization: true }); 413 414 414 415 // 6. Split into subfs if needed 415 416 let finalDirectory = directory; ··· 463 464 464 465 const uri = `at://${did}/place.wisp.fs/${siteName}`; 465 466 const url = `https://sites.wisp.place/${did}/${siteName}`; 466 - 467 - console.log(pc.green(`\n✓ Deployed successfully!`)); 468 - console.log(pc.dim(` URI: ${uri}`)); 469 - console.log(pc.cyan(` URL: ${url}\n`)); 470 467 471 468 return { uri, url }; 472 469 }
+24 -24
cli/commands/serve.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 - import { Firehose } from '@atproto/sync'; 2 + import { IdResolver } from '@atproto/identity'; 3 + import { BunFirehose } from '../lib/firehose'; 3 4 import type { Record as SettingsRecord } from '@wisp/lexicons/types/place/wisp/settings'; 4 5 import { existsSync, readFileSync, statSync, readdirSync } from 'fs'; 5 6 import { join, extname } from 'path'; ··· 319 320 console.log(pc.dim('Watching for updates via firehose...\n')); 320 321 321 322 // 6. Connect to firehose for live updates 322 - const firehose = new Firehose({ 323 + const idResolver = new IdResolver(); 324 + const firehose = new BunFirehose({ 325 + idResolver, 323 326 service: pdsEndpoint.replace('https://', 'wss://').replace('http://', 'ws://'), 324 - handleEvent: async (evt: any) => { 325 - if (evt.event !== 'commit') return; 327 + filterCollections: ['place.wisp.fs', 'place.wisp.settings'], 328 + handleEvent: async (evt) => { 329 + // Only handle commit events for this DID 330 + if (evt.event !== 'create' && evt.event !== 'update' && evt.event !== 'delete') return; 331 + if (evt.did !== did) return; 332 + if (evt.rkey !== site) return; 326 333 327 - const commit = evt.commit; 328 - if (!commit || commit.repo !== did) return; 334 + if (evt.collection === 'place.wisp.fs') { 335 + console.log(pc.yellow('\nSite updated, re-pulling...\n')); 336 + await pull(identifier, { site, path: outputPath }); 329 337 330 - for (const op of commit.ops || []) { 331 - const collection = op.path?.split('/')[0]; 332 - const rkey = op.path?.split('/')[1]; 333 - 334 - if (rkey !== site) continue; 335 - 336 - if (collection === 'place.wisp.fs') { 337 - console.log(pc.yellow('\nSite updated, re-pulling...\n')); 338 - await pull(identifier, { site, path: outputPath }); 339 - 340 - // Reload redirects 341 - state.redirectRules = loadRedirectRules(outputPath); 342 - console.log(pc.green('✓ Site reloaded\n')); 343 - } else if (collection === 'place.wisp.settings') { 344 - console.log(pc.yellow('\nSettings updated...\n')); 345 - state.settings = await fetchSettings(pdsEndpoint, did, site); 346 - console.log(pc.green('✓ Settings reloaded\n')); 347 - } 338 + // Reload redirects 339 + state.redirectRules = loadRedirectRules(outputPath); 340 + console.log(pc.green('✓ Site reloaded\n')); 341 + } else if (evt.collection === 'place.wisp.settings') { 342 + console.log(pc.yellow('\nSettings updated...\n')); 343 + state.settings = await fetchSettings(pdsEndpoint, did, site); 344 + console.log(pc.green('✓ Settings reloaded\n')); 348 345 } 349 346 }, 350 347 onError: (err: Error) => { 351 348 console.error(pc.red('Firehose error:'), err.message); 349 + if (err.cause) { 350 + console.error(pc.red(' Cause:'), err.cause); 351 + } 352 352 } 353 353 }); 354 354
+84 -7
cli/index.ts
··· 1 1 #!/usr/bin/env bun 2 2 import { Command } from 'commander'; 3 + import { text, isCancel, cancel, intro, outro } from '@clack/prompts'; 3 4 import { authenticate, clearSessions } from './lib/auth.ts'; 4 5 import { deploy } from './commands/deploy.ts'; 5 6 import { pull } from './commands/pull.ts'; ··· 15 16 16 17 // Deploy command (default) 17 18 program 18 - .command('deploy <handle>', { isDefault: true }) 19 + .command('deploy [handle]', { isDefault: true }) 19 20 .description('Deploy a static site to wisp.place') 20 - .option('-p, --path <path>', 'Directory to deploy', '.') 21 + .option('-p, --path <path>', 'Directory to deploy') 21 22 .option('-s, --site <name>', 'Site name (defaults to directory name)') 22 23 .option('--directory', 'Enable directory listing') 23 24 .option('--spa', 'Enable SPA mode (serve index.html for all routes)') 24 25 .option('--password <password>', 'App password for headless authentication') 25 26 .option('--store <path>', 'OAuth session store path') 26 27 .option('-y, --yes', 'Skip confirmation prompts') 27 - .action(async (handle: string, options) => { 28 + .action(async (handle: string | undefined, options) => { 28 29 try { 29 - const { agent, did } = await authenticate(handle, { 30 + let resolvedHandle = handle; 31 + let resolvedPath = options.path; 32 + let resolvedSite = options.site; 33 + 34 + // If any required values are missing, show prompts 35 + const needsPrompts = !resolvedHandle || !resolvedPath || !resolvedSite; 36 + 37 + if (needsPrompts) { 38 + intro(pc.cyan('wisp.place deploy')); 39 + 40 + // Prompt for handle if not provided 41 + if (!resolvedHandle) { 42 + const handleResult = await text({ 43 + message: 'AT Protocol handle', 44 + placeholder: 'alice.bsky.social', 45 + validate: (value) => { 46 + if (!value) return 'Handle is required'; 47 + if (!value.includes('.')) return 'Handle must include a domain (e.g., alice.bsky.social)'; 48 + } 49 + }); 50 + 51 + if (isCancel(handleResult)) { 52 + cancel('Deploy cancelled'); 53 + process.exit(0); 54 + } 55 + resolvedHandle = handleResult; 56 + } 57 + 58 + // Prompt for path if not provided 59 + if (!resolvedPath) { 60 + const pathResult = await text({ 61 + message: 'Directory to deploy', 62 + placeholder: '.', 63 + defaultValue: '.' 64 + }); 65 + 66 + if (isCancel(pathResult)) { 67 + cancel('Deploy cancelled'); 68 + process.exit(0); 69 + } 70 + resolvedPath = pathResult || '.'; 71 + } 72 + 73 + // Prompt for site name if not provided 74 + if (!resolvedSite) { 75 + const siteResult = await text({ 76 + message: 'Site name', 77 + placeholder: 'my-website', 78 + validate: (value) => { 79 + if (!value) return 'Site name is required'; 80 + if (!/^[a-zA-Z0-9._~:-]{1,512}$/.test(value)) { 81 + return 'Site name must be 1-512 characters of [a-zA-Z0-9._~:-]'; 82 + } 83 + } 84 + }); 85 + 86 + if (isCancel(siteResult)) { 87 + cancel('Deploy cancelled'); 88 + process.exit(0); 89 + } 90 + resolvedSite = siteResult; 91 + } 92 + } 93 + 94 + const { agent, did } = await authenticate(resolvedHandle!, { 30 95 appPassword: options.password, 31 96 storePath: options.store 32 97 }); 33 98 34 - await deploy(agent, did, { 35 - path: options.path, 36 - site: options.site, 99 + const result = await deploy(agent, did, { 100 + path: resolvedPath, 101 + site: resolvedSite, 37 102 directory: options.directory, 38 103 spa: options.spa, 39 104 yes: options.yes 40 105 }); 106 + 107 + console.log(); 108 + console.log(pc.dim(` URI: ${result.uri}`)); 109 + console.log(pc.cyan(` URL: ${result.url}`)); 110 + 111 + if (needsPrompts) { 112 + outro(pc.green('Deployed successfully!')); 113 + } else { 114 + console.log(); 115 + console.log(pc.green('✓ Deployed successfully!')); 116 + } 117 + process.exit(0); 41 118 } catch (err: any) { 42 119 console.error(pc.red(`\nError: ${err.message}\n`)); 43 120 process.exit(1);
+324
cli/lib/firehose.ts
··· 1 + /** 2 + * Bun-compatible AT Protocol Firehose 3 + * Uses our BunSubscription with the SDK's parsing/validation logic 4 + */ 5 + 6 + import { IdResolver } from '@atproto/identity'; 7 + import { cborToLexRecord, readCar, verifyProofs, parseDataKey, formatDataKey } from '@atproto/repo'; 8 + import { CID } from 'multiformats/cid'; 9 + import { AtUri } from '@atproto/syntax'; 10 + import { BunSubscription } from './subscription'; 11 + 12 + // Re-export types from @atproto/sync for compatibility 13 + export interface CommitMeta { 14 + seq: number; 15 + time: string; 16 + commit: CID; 17 + blocks: Map<string, Uint8Array>; 18 + rev: string; 19 + uri: AtUri; 20 + did: string; 21 + collection: string; 22 + rkey: string; 23 + } 24 + 25 + export interface CommitEvt extends CommitMeta { 26 + event: 'create' | 'update' | 'delete'; 27 + cid?: CID; 28 + record?: unknown; 29 + } 30 + 31 + export interface IdentityEvt { 32 + event: 'identity'; 33 + seq: number; 34 + time: string; 35 + did: string; 36 + handle?: string; 37 + } 38 + 39 + export interface AccountEvt { 40 + event: 'account'; 41 + seq: number; 42 + time: string; 43 + did: string; 44 + active: boolean; 45 + status?: string; 46 + } 47 + 48 + export type Event = CommitEvt | IdentityEvt | AccountEvt; 49 + 50 + // Lexicon types for subscribeRepos 51 + interface RepoOp { 52 + action: 'create' | 'update' | 'delete'; 53 + path: string; 54 + cid: CID | null; 55 + } 56 + 57 + interface Commit { 58 + $type: string; 59 + seq: number; 60 + rebase: boolean; 61 + tooBig: boolean; 62 + repo: string; 63 + commit: CID; 64 + rev: string; 65 + since: string | null; 66 + blocks: Uint8Array; 67 + ops: RepoOp[]; 68 + blobs: CID[]; 69 + time: string; 70 + } 71 + 72 + interface Identity { 73 + $type: string; 74 + seq: number; 75 + did: string; 76 + time: string; 77 + handle?: string; 78 + } 79 + 80 + interface Account { 81 + $type: string; 82 + seq: number; 83 + did: string; 84 + time: string; 85 + active: boolean; 86 + status?: string; 87 + } 88 + 89 + type RepoEvent = Commit | Identity | Account; 90 + 91 + function isCommit(evt: unknown): evt is Commit { 92 + return (evt as any)?.$type === 'com.atproto.sync.subscribeRepos#commit'; 93 + } 94 + 95 + function isIdentity(evt: unknown): evt is Identity { 96 + return (evt as any)?.$type === 'com.atproto.sync.subscribeRepos#identity'; 97 + } 98 + 99 + function isAccount(evt: unknown): evt is Account { 100 + return (evt as any)?.$type === 'com.atproto.sync.subscribeRepos#account'; 101 + } 102 + 103 + function isValidRepoEvent(value: unknown): RepoEvent | undefined { 104 + if (!value || typeof value !== 'object') return undefined; 105 + const $type = (value as any).$type; 106 + if ( 107 + $type === 'com.atproto.sync.subscribeRepos#commit' || 108 + $type === 'com.atproto.sync.subscribeRepos#identity' || 109 + $type === 'com.atproto.sync.subscribeRepos#account' 110 + ) { 111 + return value as RepoEvent; 112 + } 113 + return undefined; 114 + } 115 + 116 + export interface BunFirehoseOptions { 117 + idResolver: IdResolver; 118 + service: string; 119 + handleEvent: (evt: Event) => Promise<void> | void; 120 + onError: (err: Error) => void; 121 + filterCollections?: string[]; 122 + unauthenticatedCommits?: boolean; 123 + getCursor?: () => number | undefined | Promise<number | undefined>; 124 + } 125 + 126 + export class BunFirehose { 127 + private subscription: BunSubscription<RepoEvent> | null = null; 128 + private abortController: AbortController; 129 + private matchCollection: ((col: string) => boolean) | null = null; 130 + 131 + constructor(private opts: BunFirehoseOptions) { 132 + this.abortController = new AbortController(); 133 + 134 + if (opts.filterCollections) { 135 + const exact = new Set<string>(); 136 + const prefixes: string[] = []; 137 + 138 + for (const pattern of opts.filterCollections) { 139 + if (pattern.endsWith('.*')) { 140 + prefixes.push(pattern.slice(0, -2)); 141 + } else { 142 + exact.add(pattern); 143 + } 144 + } 145 + 146 + this.matchCollection = (col: string): boolean => { 147 + if (exact.has(col)) return true; 148 + for (const prefix of prefixes) { 149 + if (col.startsWith(prefix)) return true; 150 + } 151 + return false; 152 + }; 153 + } 154 + } 155 + 156 + async start(): Promise<void> { 157 + this.subscription = new BunSubscription<RepoEvent>({ 158 + service: this.opts.service, 159 + method: 'com.atproto.sync.subscribeRepos', 160 + signal: this.abortController.signal, 161 + validate: isValidRepoEvent, 162 + getParams: async () => { 163 + const cursor = await this.opts.getCursor?.(); 164 + return cursor !== undefined ? { cursor } : undefined; 165 + }, 166 + onReconnectError: (err, n) => { 167 + this.opts.onError(new Error(`Reconnect attempt ${n}: ${err}`)); 168 + }, 169 + }); 170 + 171 + try { 172 + for await (const evt of this.subscription) { 173 + try { 174 + const events = await this.parseEvent(evt); 175 + for (const event of events) { 176 + try { 177 + await this.opts.handleEvent(event); 178 + } catch (err) { 179 + this.opts.onError(err instanceof Error ? err : new Error(String(err))); 180 + } 181 + } 182 + } catch (err) { 183 + this.opts.onError(err instanceof Error ? err : new Error(String(err))); 184 + } 185 + } 186 + } catch (err) { 187 + if ((err as any)?.name !== 'AbortError') { 188 + this.opts.onError(err instanceof Error ? err : new Error(String(err))); 189 + } 190 + } 191 + } 192 + 193 + private async parseEvent(evt: RepoEvent): Promise<Event[]> { 194 + if (isCommit(evt)) { 195 + return this.opts.unauthenticatedCommits 196 + ? await this.parseCommitUnauthenticated(evt) 197 + : await this.parseCommitAuthenticated(evt); 198 + } else if (isIdentity(evt)) { 199 + return [{ 200 + event: 'identity', 201 + seq: evt.seq, 202 + time: evt.time, 203 + did: evt.did, 204 + handle: evt.handle, 205 + }]; 206 + } else if (isAccount(evt)) { 207 + return [{ 208 + event: 'account', 209 + seq: evt.seq, 210 + time: evt.time, 211 + did: evt.did, 212 + active: evt.active, 213 + status: evt.status, 214 + }]; 215 + } 216 + return []; 217 + } 218 + 219 + private filterOps(ops: RepoOp[]): RepoOp[] { 220 + if (!this.matchCollection) return ops; 221 + return ops.filter((op) => { 222 + const { collection } = parseDataKey(op.path); 223 + return this.matchCollection!(collection); 224 + }); 225 + } 226 + 227 + private async parseCommitAuthenticated(evt: Commit, forceKeyRefresh = false): Promise<CommitEvt[]> { 228 + const did = evt.repo; 229 + const ops = this.filterOps(evt.ops); 230 + if (ops.length === 0) return []; 231 + 232 + const claims = ops.map((op) => { 233 + const { collection, rkey } = parseDataKey(op.path); 234 + return { 235 + collection, 236 + rkey, 237 + cid: op.action === 'delete' ? null : op.cid, 238 + }; 239 + }); 240 + 241 + try { 242 + const key = await this.opts.idResolver.did.resolveAtprotoKey(did, forceKeyRefresh); 243 + const verifiedCids: Record<string, CID | null> = {}; 244 + 245 + const results = await verifyProofs(evt.blocks, claims, did, key); 246 + results.verified.forEach((op) => { 247 + const path = formatDataKey(op.collection, op.rkey); 248 + verifiedCids[path] = op.cid; 249 + }); 250 + 251 + const verifiedOps = ops.filter((op) => { 252 + if (op.action === 'delete') { 253 + return verifiedCids[op.path] === null; 254 + } 255 + return op.cid !== null && op.cid.equals(verifiedCids[op.path]); 256 + }); 257 + 258 + return this.formatCommitOps(evt, verifiedOps, { skipCidVerification: true }); 259 + } catch (err) { 260 + // Retry with key refresh on verification error 261 + if (!forceKeyRefresh && (err as any)?.name === 'RepoVerificationError') { 262 + return this.parseCommitAuthenticated(evt, true); 263 + } 264 + throw err; 265 + } 266 + } 267 + 268 + private async parseCommitUnauthenticated(evt: Commit): Promise<CommitEvt[]> { 269 + const ops = this.filterOps(evt.ops); 270 + return this.formatCommitOps(evt, ops); 271 + } 272 + 273 + private async formatCommitOps( 274 + evt: Commit, 275 + ops: RepoOp[], 276 + options?: { skipCidVerification: boolean } 277 + ): Promise<CommitEvt[]> { 278 + const car = await readCar(evt.blocks, options); 279 + const events: CommitEvt[] = []; 280 + 281 + for (const op of ops) { 282 + const uri = AtUri.make(evt.repo, op.path); 283 + 284 + const meta: CommitMeta = { 285 + seq: evt.seq, 286 + time: evt.time, 287 + commit: evt.commit, 288 + blocks: car.blocks, 289 + rev: evt.rev, 290 + uri, 291 + did: uri.host, 292 + collection: uri.collection, 293 + rkey: uri.rkey, 294 + }; 295 + 296 + if (op.action === 'create' || op.action === 'update') { 297 + if (!op.cid) continue; 298 + const recordBytes = car.blocks.get(op.cid); 299 + if (!recordBytes) continue; 300 + const record = cborToLexRecord(recordBytes); 301 + events.push({ 302 + ...meta, 303 + event: op.action, 304 + cid: op.cid, 305 + record, 306 + }); 307 + } 308 + 309 + if (op.action === 'delete') { 310 + events.push({ 311 + ...meta, 312 + event: 'delete', 313 + }); 314 + } 315 + } 316 + 317 + return events; 318 + } 319 + 320 + destroy(): void { 321 + this.abortController.abort(); 322 + this.subscription?.close(); 323 + } 324 + }
+41 -4
cli/lib/progress.ts
··· 1 - import ora, { type Ora } from 'ora'; 1 + import { spinner } from '@clack/prompts'; 2 2 import pc from 'picocolors'; 3 3 4 - export { ora, pc }; 4 + export { pc }; 5 5 6 6 export function formatBytes(bytes: number): string { 7 7 if (bytes === 0) return '0 B'; ··· 11 11 return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i] ?? 'TB'}`; 12 12 } 13 13 14 - export function createSpinner(text: string): Ora { 15 - return ora({ text, color: 'cyan' }); 14 + // Adapter to provide ora-like interface using clack spinner 15 + export interface SpinnerLike { 16 + text: string; 17 + start(): SpinnerLike; 18 + succeed(text?: string): SpinnerLike; 19 + fail(text?: string): SpinnerLike; 20 + } 21 + 22 + export function createSpinner(text: string): SpinnerLike { 23 + const s = spinner(); 24 + let currentText = text; 25 + let started = false; 26 + 27 + return { 28 + get text() { 29 + return currentText; 30 + }, 31 + set text(newText: string) { 32 + currentText = newText; 33 + if (started) { 34 + s.message(newText); 35 + } 36 + }, 37 + start() { 38 + started = true; 39 + s.start(currentText); 40 + return this; 41 + }, 42 + succeed(message?: string) { 43 + s.stop(pc.green('✓ ') + (message ?? currentText)); 44 + started = false; 45 + return this; 46 + }, 47 + fail(message?: string) { 48 + s.stop(pc.red('✗ ') + (message ?? currentText)); 49 + started = false; 50 + return this; 51 + } 52 + }; 16 53 }
+214
cli/lib/subscription.ts
··· 1 + /** 2 + * Bun-compatible AT Protocol subscription client 3 + * Uses Bun's native WebSocket instead of @atproto/ws-client 4 + */ 5 + 6 + import { decodeAll } from '@atproto/lex-cbor'; 7 + import { isPlainObject } from '@atproto/lex-data'; 8 + 9 + // Frame types from AT Protocol 10 + const FrameType = { 11 + Message: 1, 12 + Error: -1, 13 + } as const; 14 + 15 + interface FrameHeader { 16 + op: number; 17 + t?: string; 18 + } 19 + 20 + interface ErrorFrameBody { 21 + error: string; 22 + message?: string; 23 + } 24 + 25 + function decodeFrame(bytes: Uint8Array): { header: FrameHeader; body: unknown } { 26 + const decoded = decodeAll(bytes); 27 + if (decoded.length < 2) { 28 + throw new Error('Invalid frame: missing header or body'); 29 + } 30 + const [header, body] = decoded as [FrameHeader, unknown]; 31 + return { header, body }; 32 + } 33 + 34 + export interface BunSubscriptionOptions<T> { 35 + service: string; 36 + method: string; 37 + signal?: AbortSignal; 38 + validate: (obj: unknown) => T | undefined; 39 + getParams?: () => Record<string, unknown> | Promise<Record<string, unknown> | undefined> | undefined; 40 + onReconnectError?: (error: unknown, n: number, initialSetup: boolean) => void; 41 + maxReconnectSeconds?: number; 42 + } 43 + 44 + export class BunSubscription<T = unknown> { 45 + private ws: WebSocket | null = null; 46 + private reconnectAttempts = 0; 47 + private aborted = false; 48 + 49 + constructor(public opts: BunSubscriptionOptions<T>) { 50 + if (opts.signal) { 51 + opts.signal.addEventListener('abort', () => { 52 + this.aborted = true; 53 + this.ws?.close(); 54 + }); 55 + } 56 + } 57 + 58 + private async getUrl(): Promise<string> { 59 + const params = (await this.opts.getParams?.()) ?? {}; 60 + const query = encodeQueryParams(params); 61 + const base = this.opts.service.replace(/\/$/, ''); 62 + return `${base}/xrpc/${this.opts.method}${query ? '?' + query : ''}`; 63 + } 64 + 65 + private getReconnectDelay(): number { 66 + const maxSeconds = this.opts.maxReconnectSeconds ?? 64; 67 + const seconds = Math.min(Math.pow(2, this.reconnectAttempts), maxSeconds); 68 + return seconds * 1000; 69 + } 70 + 71 + async *[Symbol.asyncIterator](): AsyncGenerator<T> { 72 + while (!this.aborted) { 73 + try { 74 + const url = await this.getUrl(); 75 + 76 + // Create a queue for messages 77 + const messageQueue: Uint8Array[] = []; 78 + let resolveMessage: (() => void) | null = null; 79 + let wsError: Error | null = null; 80 + let wsOpen = false; 81 + let wsClosed = false; 82 + 83 + this.ws = new WebSocket(url); 84 + this.ws.binaryType = 'arraybuffer'; 85 + 86 + this.ws.addEventListener('open', () => { 87 + wsOpen = true; 88 + this.reconnectAttempts = 0; 89 + }); 90 + 91 + this.ws.addEventListener('message', (event) => { 92 + const data = event.data; 93 + if (data instanceof ArrayBuffer) { 94 + messageQueue.push(new Uint8Array(data)); 95 + resolveMessage?.(); 96 + } 97 + }); 98 + 99 + this.ws.addEventListener('error', (event) => { 100 + wsError = new Error('WebSocket error'); 101 + }); 102 + 103 + this.ws.addEventListener('close', () => { 104 + wsClosed = true; 105 + resolveMessage?.(); 106 + }); 107 + 108 + // Wait for open or error 109 + while (!wsOpen && !wsError && !wsClosed) { 110 + await new Promise<void>((resolve) => { 111 + resolveMessage = resolve; 112 + setTimeout(resolve, 100); 113 + }); 114 + } 115 + 116 + if (wsError) { 117 + throw wsError; 118 + } 119 + 120 + // Process messages 121 + while (!this.aborted && !wsClosed) { 122 + // Wait for message if queue is empty 123 + while (messageQueue.length === 0 && !wsClosed && !this.aborted) { 124 + await new Promise<void>((resolve) => { 125 + resolveMessage = resolve; 126 + }); 127 + } 128 + 129 + if (wsClosed || this.aborted) break; 130 + 131 + const bytes = messageQueue.shift(); 132 + if (!bytes) continue; 133 + 134 + try { 135 + const { header, body } = decodeFrame(bytes); 136 + 137 + if (header.op === FrameType.Error) { 138 + const errorBody = body as ErrorFrameBody; 139 + throw new Error(`Subscription error: ${errorBody.error} - ${errorBody.message || ''}`); 140 + } 141 + 142 + if (header.op === FrameType.Message) { 143 + const t = header.t; 144 + const typedBody = isPlainObject(body) 145 + ? t !== undefined 146 + ? { ...body, $type: t.startsWith('#') ? this.opts.method + t : t } 147 + : body 148 + : undefined; 149 + 150 + const result = this.opts.validate(typedBody); 151 + if (result !== undefined) { 152 + yield result; 153 + } 154 + } 155 + } catch (err) { 156 + // Log decode errors but continue 157 + console.error('Frame decode error:', err); 158 + } 159 + } 160 + 161 + // Clean up 162 + this.ws?.close(); 163 + this.ws = null; 164 + 165 + if (this.aborted) break; 166 + 167 + // Reconnect 168 + this.reconnectAttempts++; 169 + const delay = this.getReconnectDelay(); 170 + this.opts.onReconnectError?.(new Error('Connection closed'), this.reconnectAttempts, false); 171 + await new Promise((resolve) => setTimeout(resolve, delay)); 172 + 173 + } catch (err) { 174 + this.ws?.close(); 175 + this.ws = null; 176 + 177 + if (this.aborted) break; 178 + 179 + this.reconnectAttempts++; 180 + const delay = this.getReconnectDelay(); 181 + this.opts.onReconnectError?.(err, this.reconnectAttempts, this.reconnectAttempts === 1); 182 + await new Promise((resolve) => setTimeout(resolve, delay)); 183 + } 184 + } 185 + } 186 + 187 + close() { 188 + this.aborted = true; 189 + this.ws?.close(); 190 + } 191 + } 192 + 193 + function encodeQueryParams(obj: Record<string, unknown>): string { 194 + const params = new URLSearchParams(); 195 + for (const [key, value] of Object.entries(obj)) { 196 + const encoded = encodeQueryParam(value); 197 + if (Array.isArray(encoded)) { 198 + encoded.forEach((enc) => params.append(key, enc)); 199 + } else if (encoded !== '') { 200 + params.set(key, encoded); 201 + } 202 + } 203 + return params.toString(); 204 + } 205 + 206 + function encodeQueryParam(value: unknown): string | string[] { 207 + if (typeof value === 'string') return value; 208 + if (typeof value === 'number') return value.toString(); 209 + if (typeof value === 'boolean') return value ? 'true' : 'false'; 210 + if (value === undefined || value === null) return ''; 211 + if (value instanceof Date) return value.toISOString(); 212 + if (Array.isArray(value)) return value.flatMap(encodeQueryParam); 213 + throw new Error(`Cannot encode ${typeof value} into query params`); 214 + }
+7 -1
cli/package.json
··· 12 12 }, 13 13 "dependencies": { 14 14 "@atproto/api": "^0.18.17", 15 + "@atproto/identity": "^0.4.10", 16 + "@atproto/lex-cbor": "^0.0.9", 17 + "@atproto/lex-data": "^0.0.9", 15 18 "@atproto/oauth-client-node": "^0.3.15", 19 + "@atproto/repo": "^0.8.12", 16 20 "@atproto/sync": "^0.1.39", 21 + "@atproto/syntax": "^0.4.3", 22 + "multiformats": "^13.4.2", 23 + "@clack/prompts": "^0.10.0", 17 24 "@wisp/atproto-utils": "workspace:*", 18 25 "@wisp/constants": "workspace:*", 19 26 "@wisp/fs-utils": "workspace:*", ··· 22 29 "ignore": "^7.0.5", 23 30 "mime-types": "^3.0.2", 24 31 "open": "^11.0.0", 25 - "ora": "^9.1.0", 26 32 "picocolors": "^1.1.1" 27 33 }, 28 34 "devDependencies": {