Something went wrong. Try again.
Browser extension: detect and subscribe to standard.site publications on ATProto
Something went wrong. Try again.
8.9 kB · 205 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206// How long anything the extension reads stays good for.//// Before this, every feature answered that question for itself: eight// hand-rolled `storage.session` caches with eight TTLs between five minutes// and an hour, each with its own key format, its own staleness check and its// own refresh bypass. The TTLs did not disagree because anyone had decided// they should — they disagreed because they were written on different days.//// The answer does not actually depend on which feature is asking. It depends// on whose data it is://// - `own` — the signed-in account's own repo, where this extension also// writes: its subscriptions, blocks, labeler subscriptions. It changes// when the person changes it, and when they change it *here* we know, so// this can be cached for an hour and dropped on the write. What it must// never do is show somebody their own action not having happened.// - `graph` — the signed-in account's own repo, which this extension only// ever reads: the follow list. See below; it is the one scope on disk.// - `world` — somebody else's publication, profile or reputation: the owner// card, subscriber counts, labels. Nothing the reader does changes it, and// it changes slowly at the source, so a day is fine and a stale day costs// a slightly out-of-date number.//// `graph` is `own` data with the write taken away, and both halves of that// matter. Nothing in this extension creates or deletes an `app.bsky.graph.follow`// record, so a stale follow set cannot show somebody their own action not// having happened — the rule the hour exists to keep. It can only lag what// they did in another client, and it costs a face missing from a decorative// row until tomorrow. That buys the day.//// The day is also why it is the one thing written to disk. The walk is one// request per hundred follows against the account's own PDS, and an account// following fifty thousand people is five hundred of them; paying that again// every time the browser restarts is not a cache. It is allowed on disk// because of what it is not: a follow list is the account's own public social// graph, and unlike everything under `world` it records nothing about which// sites were visited to produce it.//// Detection's well-known probe cache is deliberately not here. Its TTL// depends on the answer rather than on the scope — five minutes for a hit,// an hour for the miss nearly every origin gives — and it also dedups probes// already in flight, which is a different concern from freshness. It keeps// its own policy in src/lib/detection.ts, where that reasoning is written// down.//// `own` and `world` live in `chrome.storage.session`, which the browser clears// when it closes. So a day-long TTL there means "up to a day, within this// browsing session" — nothing about which publications were visited is written// to disk. Moving `world` to `storage.local` would make its day literal at// that price; it is one word here, and deliberately not taken.
/** Whose data it is, which is what decides how long it keeps. */export type Scope = 'own' | 'graph' | 'world'
export interface Policy { ttlMs: number /** `local` only where the value carries no record of where the user has been. */ area: 'session' | 'local'}
export const POLICIES: Record<Scope, Policy> = { own: { ttlMs: 60 * 60 * 1000, area: 'session' }, graph: { ttlMs: 24 * 60 * 60 * 1000, area: 'local' }, world: { ttlMs: 24 * 60 * 60 * 1000, area: 'session' },}
/** * What a cached value is about. `subject` is the thing it describes — an * account DID for `own` and `graph`, a publication at-uri or origin for * `world` — and it * is a separate field because dropping everything cached about one subject * (signing out, switching account) has to be one call, not a list of names * kept in sync by hand. */export interface CacheKey { scope: Scope subject: string name: string}
export interface CacheOptions { /** The user asking again: read through, and refill. */ refresh?: boolean /** Override the scope's TTL. For a caller with a reason, not for taste. */ ttlMs?: number}
interface Entry<T> { at: number value: T}
export function storageKey(key: CacheKey): string { return `${key.scope}:${key.subject}/${key.name}`}
function area(scope: Scope) { return chrome.storage[POLICIES[scope].area]}
/** * A cached read. `load` runs on a miss, on an expiry, and on `refresh`; its * result is stored and returned. * * A `load` that throws is not cached and the error reaches the caller, which * is what lets a failed lookup stay a hidden row rather than a remembered * one. A stored value is not returned once it is older than the policy, even * if `load` would fail — nothing here serves stale data to paper over an * outage, because the features using it would rather show nothing. */export async function cached<T>( key: CacheKey, load: () => Promise<T>, opts: CacheOptions = {},): Promise<T> { const storage = area(key.scope) const k = storageKey(key) const ttl = opts.ttlMs ?? POLICIES[key.scope].ttlMs if (!opts.refresh) { const entry = (await storage.get(k))[k] as Entry<T> | undefined if (entry && Date.now() - entry.at < ttl) return entry.value } const value = await load() await storage.set({ [k]: { at: Date.now(), value } satisfies Entry<T> }) return value}
/** The stored value if it is still good, without running a loader. */export async function peek<T>(key: CacheKey, opts: CacheOptions = {}): Promise<T | undefined> { const k = storageKey(key) const entry = (await area(key.scope).get(k))[k] as Entry<T> | undefined const ttl = opts.ttlMs ?? POLICIES[key.scope].ttlMs return entry && Date.now() - entry.at < ttl ? entry.value : undefined}
export async function put<T>(key: CacheKey, value: T): Promise<void> { await area(key.scope).set({ [storageKey(key)]: { at: Date.now(), value } satisfies Entry<T> })}
/** * Drop one entry. This is what a write from this extension owes the cache: * whoever writes to a collection invalidates what was cached from it, in the * same place, so subscribing never leaves the popup insisting you have not. */export async function invalidate(key: CacheKey): Promise<void> { await area(key.scope).remove(storageKey(key))}
/** * Drop everything cached about one subject — every name under it. For signing * out and for switching account, where the next answer must not be the * previous account's. *//** * Everything cached from one account's own repo, across every scope keyed by * a DID. For signing out and for switching account. * * A list of scopes rather than one call, and in here rather than at the two * call sites, because the cost of getting it wrong is the new account being * shown the old one's follows — and the way to get it wrong is to add an * account scope and not find every place that had to be told about it. */export const ACCOUNT_SCOPES: Scope[] = ['own', 'graph']
export async function invalidateAccount(did: string): Promise<void> { await Promise.all(ACCOUNT_SCOPES.map((scope) => invalidateSubject(scope, did)))}
export async function invalidateSubject(scope: Scope, subject: string): Promise<void> { const storage = area(scope) const prefix = `${scope}:${subject}/` const all = await storage.get(null) const keys = Object.keys(all).filter((k) => k.startsWith(prefix)) if (keys.length > 0) await storage.remove(keys)}
/** * Every entry this module owns, in both areas, so the next read of anything * runs cold. For the dev channel's Drop cache (src/popup/popup.ts) — nothing * in normal operation wants this, since the whole point of the policies above * is that the extension does not re-ask a stranger's PDS on every popup. * * By scope prefix rather than by clearing the areas, because both hold things * that are not caches and that dropping would break rather than refill: the * account mirror the worker reads (`session` in storage.local), the stored * reader choice, and the per-tab detection states the badges are drawn from. * The worker drops those last ones itself, where it can re-badge as it goes. * * Returns how many entries went, so the caller can say. */export async function dropAll(): Promise<number> { const areas = ['session', 'local'] as const const counts = await Promise.all( areas.map(async (name) => { const scopes = (Object.keys(POLICIES) as Scope[]).filter((s) => POLICIES[s].area === name) if (scopes.length === 0) return 0 const storage = chrome.storage[name] const all = await storage.get(null) const keys = Object.keys(all).filter((k) => scopes.some((s) => k.startsWith(`${s}:`))) if (keys.length > 0) await storage.remove(keys) return keys.length }), ) return counts.reduce((a, b) => a + b, 0)}