···11+# Generate both with: pnpm env:setup-dev
22+CLIENT_ASSERTION_KEY=
33+COOKIE_SECRET=
44+55+# Set to your tunnel URL to use a confidential client in dev
66+# OAUTH_PUBLIC_URL=https://your-tunnel.trycloudflare.com
+2-10
README.md
···11-# search bluesky likes
11+# atmo tools
2233-static client side app that allows you to search your bluesky likes
44-55-## how to run
66-77-```bash
88-npm install
99-npm run dev
1010-```
1111-33+- search your bluesky likes
···11-import adapter from '@sveltejs/adapter-static';
11+import adapter from '@sveltejs/adapter-cloudflare';
22import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3344/** @type {import('@sveltejs/kit').Config} */
55const config = {
66- // Consult https://svelte.dev/docs/kit/integrations
77- // for more information about preprocessors
86 preprocess: vitePreprocess(),
97108 kit: {
119 adapter: adapter(),
1212- paths: {
1313- base: process.env.NODE_ENV === 'development' ? '' : '/search-bluesky-likes'
1010+ experimental: {
1111+ remoteFunctions: true
1412 }
1513 }
1614};
+2-6
tsconfig.json
···99 "skipLibCheck": true,
1010 "sourceMap": true,
1111 "strict": true,
1212- "moduleResolution": "bundler"
1212+ "moduleResolution": "bundler",
1313+ "types": ["@cloudflare/workers-types"]
1314 }
1414- // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
1515- // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
1616- //
1717- // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
1818- // from the referenced tsconfig.json - TypeScript does not merge them in
1915}
···11+import { parseResourceUri, type Did, type Handle } from '@atcute/lexicons';
22+import { isDid } from '@atcute/lexicons/syntax';
33+import { user } from './auth.svelte';
44+import { DOH_RESOLVER, type AllowedCollection } from './settings';
55+import {
66+ CompositeDidDocumentResolver,
77+ CompositeHandleResolver,
88+ DohJsonHandleResolver,
99+ PlcDidDocumentResolver,
1010+ WebDidDocumentResolver,
1111+ WellKnownHandleResolver
1212+} from '@atcute/identity-resolver';
1313+import { Client, simpleFetchHandler } from '@atcute/client';
1414+import { type AppBskyActorDefs } from '@atcute/bluesky';
1515+1616+export type Collection = `${string}.${string}.${string}`;
1717+import * as TID from '@atcute/tid';
1818+1919+/**
2020+ * Parses an AT Protocol URI into its components.
2121+ */
2222+export function parseUri(uri: string) {
2323+ const parts = parseResourceUri(uri);
2424+ if (!parts.ok) return;
2525+ return parts.value;
2626+}
2727+2828+/**
2929+ * Resolves a handle to a DID using DNS and HTTP methods.
3030+ */
3131+export async function resolveHandle({ handle }: { handle: Handle }) {
3232+ const handleResolver = new CompositeHandleResolver({
3333+ methods: {
3434+ dns: new DohJsonHandleResolver({ dohUrl: DOH_RESOLVER }),
3535+ http: new WellKnownHandleResolver()
3636+ }
3737+ });
3838+3939+ const data = await handleResolver.resolve(handle);
4040+ return data;
4141+}
4242+4343+/**
4444+ * Returns a DID given a handle or DID string.
4545+ */
4646+export async function actorToDid(actor: string): Promise<Did> {
4747+ if (isDid(actor)) return actor;
4848+ return await resolveHandle({ handle: actor as Handle });
4949+}
5050+5151+const didResolver = new CompositeDidDocumentResolver({
5252+ methods: {
5353+ plc: new PlcDidDocumentResolver(),
5454+ web: new WebDidDocumentResolver()
5555+ }
5656+});
5757+5858+/**
5959+ * Gets the PDS (Personal Data Server) URL for a given DID.
6060+ */
6161+export async function getPDS(did: Did) {
6262+ const doc = await didResolver.resolve(did as Did<'plc'> | Did<'web'>);
6363+ if (!doc.service) throw new Error('No PDS found');
6464+ for (const service of doc.service) {
6565+ if (service.id === '#atproto_pds') {
6666+ return service.serviceEndpoint.toString();
6767+ }
6868+ }
6969+}
7070+7171+/**
7272+ * Fetches a detailed Bluesky profile for a user.
7373+ */
7474+export async function getDetailedProfile(data?: { did?: Did; client?: Client }) {
7575+ data ??= {};
7676+ data.did ??= user.did ?? undefined;
7777+7878+ if (!data.did) throw new Error('Error getting detailed profile: no did');
7979+8080+ data.client ??= new Client({
8181+ handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' })
8282+ });
8383+8484+ const response = await data.client.get('app.bsky.actor.getProfile', {
8585+ params: { actor: data.did }
8686+ });
8787+8888+ if (!response.ok) return;
8989+9090+ return response.data;
9191+}
9292+9393+/**
9494+ * Creates an AT Protocol client for a user's PDS.
9595+ */
9696+export async function getClient({ did }: { did: Did }) {
9797+ const pds = await getPDS(did);
9898+ if (!pds) throw new Error('PDS not found');
9999+100100+ const client = new Client({
101101+ handler: simpleFetchHandler({ service: pds })
102102+ });
103103+104104+ return client;
105105+}
106106+107107+/**
108108+ * Lists records from a repository collection with pagination support.
109109+ */
110110+export async function listRecords({
111111+ did,
112112+ collection,
113113+ cursor,
114114+ limit = 100,
115115+ client
116116+}: {
117117+ did?: Did;
118118+ collection: `${string}.${string}.${string}`;
119119+ cursor?: string;
120120+ limit?: number;
121121+ client?: Client;
122122+}) {
123123+ did ??= user.did ?? undefined;
124124+ if (!collection) {
125125+ throw new Error('Missing parameters for listRecords');
126126+ }
127127+ if (!did) {
128128+ throw new Error('Missing did for listRecords');
129129+ }
130130+131131+ client ??= await getClient({ did });
132132+133133+ const allRecords = [];
134134+135135+ let currentCursor = cursor;
136136+ do {
137137+ const response = await client.get('com.atproto.repo.listRecords', {
138138+ params: {
139139+ repo: did,
140140+ collection,
141141+ limit: !limit || limit > 100 ? 100 : limit,
142142+ cursor: currentCursor
143143+ }
144144+ });
145145+146146+ if (!response.ok) {
147147+ return allRecords;
148148+ }
149149+150150+ allRecords.push(...response.data.records);
151151+ currentCursor = response.data.cursor;
152152+ } while (currentCursor && (!limit || allRecords.length < limit));
153153+154154+ return allRecords;
155155+}
156156+157157+/**
158158+ * Fetches a single record from a repository.
159159+ */
160160+export async function getRecord({
161161+ did,
162162+ collection,
163163+ rkey = 'self',
164164+ client
165165+}: {
166166+ did?: Did;
167167+ collection: Collection;
168168+ rkey?: string;
169169+ client?: Client;
170170+}) {
171171+ did ??= user.did ?? undefined;
172172+173173+ if (!collection) {
174174+ throw new Error('Missing parameters for getRecord');
175175+ }
176176+ if (!did) {
177177+ throw new Error('Missing did for getRecord');
178178+ }
179179+180180+ client ??= await getClient({ did });
181181+182182+ const record = await client.get('com.atproto.repo.getRecord', {
183183+ params: {
184184+ repo: did,
185185+ collection,
186186+ rkey
187187+ }
188188+ });
189189+190190+ return JSON.parse(JSON.stringify(record.data));
191191+}
192192+193193+/**
194194+ * Creates or updates a record via remote function.
195195+ */
196196+export async function putRecord({
197197+ collection,
198198+ rkey = 'self',
199199+ record
200200+}: {
201201+ collection: AllowedCollection;
202202+ rkey?: string;
203203+ record: Record<string, unknown>;
204204+}) {
205205+ if (!user.did) throw new Error('Not logged in');
206206+207207+ const { putRecord: putRecordRemote } = await import('./server/repo.remote');
208208+ const data = await putRecordRemote({ collection, rkey, record });
209209+ return { ok: true, data };
210210+}
211211+212212+/**
213213+ * Deletes a record via remote function.
214214+ */
215215+export async function deleteRecord({
216216+ collection,
217217+ rkey = 'self'
218218+}: {
219219+ collection: AllowedCollection;
220220+ rkey: string;
221221+}) {
222222+ if (!user.did) throw new Error('Not logged in');
223223+224224+ const { deleteRecord: deleteRecordRemote } = await import('./server/repo.remote');
225225+ const data = await deleteRecordRemote({ collection, rkey });
226226+ return data.ok;
227227+}
228228+229229+/**
230230+ * Gets the dimensions of an image blob.
231231+ */
232232+function getImageDimensions(blob: Blob): Promise<{ width: number; height: number }> {
233233+ return new Promise((resolve, reject) => {
234234+ const img = new Image();
235235+ const url = URL.createObjectURL(blob);
236236+ img.onload = () => {
237237+ URL.revokeObjectURL(url);
238238+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
239239+ };
240240+ img.onerror = () => {
241241+ URL.revokeObjectURL(url);
242242+ reject(new Error('Failed to load image for dimensions'));
243243+ };
244244+ img.src = url;
245245+ });
246246+}
247247+248248+/**
249249+ * Uploads a blob via remote function.
250250+ * Converts the Blob to a byte array for serialization across the remote boundary.
251251+ * For image blobs, automatically includes aspectRatio with width and height.
252252+ */
253253+export async function uploadBlob({
254254+ blob,
255255+ aspectRatio
256256+}: {
257257+ blob: Blob;
258258+ aspectRatio?: { width: number; height: number };
259259+}) {
260260+ if (!user.did) throw new Error("Can't upload blob: Not logged in");
261261+262262+ // Auto-detect dimensions for image blobs if not provided
263263+ if (!aspectRatio && blob.type.startsWith('image/')) {
264264+ try {
265265+ aspectRatio = await getImageDimensions(blob);
266266+ } catch {
267267+ // Non-critical — proceed without aspectRatio
268268+ }
269269+ }
270270+271271+ const arrayBuffer = await blob.arrayBuffer();
272272+ const bytes = Array.from(new Uint8Array(arrayBuffer));
273273+274274+ const { uploadBlob: uploadBlobRemote } = await import('./server/repo.remote');
275275+ const result = await uploadBlobRemote({ bytes, mimeType: blob.type || 'application/octet-stream' });
276276+277277+ if (aspectRatio) {
278278+ return { ...result, aspectRatio };
279279+ }
280280+ return result;
281281+}
282282+283283+/**
284284+ * Gets metadata about a repository.
285285+ */
286286+export async function describeRepo({ client, did }: { client?: Client; did?: Did }) {
287287+ did ??= user.did ?? undefined;
288288+ if (!did) {
289289+ throw new Error('Error describeRepo: No did');
290290+ }
291291+ client ??= await getClient({ did });
292292+293293+ const repo = await client.get('com.atproto.repo.describeRepo', {
294294+ params: {
295295+ repo: did
296296+ }
297297+ });
298298+ if (!repo.ok) return;
299299+300300+ return repo.data;
301301+}
302302+303303+/**
304304+ * Constructs a URL to fetch a blob directly from a user's PDS.
305305+ */
306306+export async function getBlobURL({
307307+ did,
308308+ blob
309309+}: {
310310+ did: Did;
311311+ blob: {
312312+ $type: 'blob';
313313+ ref: {
314314+ $link: string;
315315+ };
316316+ };
317317+}) {
318318+ const pds = await getPDS(did);
319319+ return `${pds}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${blob.ref.$link}`;
320320+}
321321+322322+/**
323323+ * Constructs a Bluesky CDN URL for an image blob.
324324+ */
325325+export function getCDNImageBlobUrl({
326326+ did,
327327+ blob
328328+}: {
329329+ did?: string;
330330+ blob: {
331331+ $type: 'blob';
332332+ ref: {
333333+ $link: string;
334334+ };
335335+ };
336336+}) {
337337+ did ??= user.did ?? undefined;
338338+339339+ return `https://cdn.bsky.app/img/feed_thumbnail/plain/${did}/${blob.ref.$link}@webp`;
340340+}
341341+342342+/**
343343+ * Searches for actors with typeahead/autocomplete functionality.
344344+ */
345345+export async function searchActorsTypeahead(
346346+ q: string,
347347+ limit: number = 10,
348348+ host?: string
349349+): Promise<{ actors: AppBskyActorDefs.ProfileViewBasic[]; q: string }> {
350350+ host ??= 'https://public.api.bsky.app';
351351+352352+ const client = new Client({
353353+ handler: simpleFetchHandler({ service: host })
354354+ });
355355+356356+ const response = await client.get('app.bsky.actor.searchActorsTypeahead', {
357357+ params: {
358358+ q,
359359+ limit
360360+ }
361361+ });
362362+363363+ if (!response.ok) return { actors: [], q };
364364+365365+ return { actors: response.data.actors, q };
366366+}
367367+368368+/**
369369+ * Return a TID based on current time
370370+ */
371371+export function createTID() {
372372+ return TID.now();
373373+}
+3
src/lib/atproto/port.ts
···11+// Dev server port — generated by setup-dev, shared by vite, oauth, and tunnel.
22+// Each project gets a unique random port (5200–7200) so multiple projects can run simultaneously.
33+export const DEV_PORT = 5816;
+30
src/lib/atproto/settings.ts
···11+import { dev } from '$app/environment';
22+import { scope } from '@atcute/oauth-node-client';
33+44+// No writable collections — this app is read-only
55+export const collections = [] as const;
66+77+export type AllowedCollection = (typeof collections)[number];
88+99+// atproto for PDS listRecords (likes, posts), rpc for bookmarks via AppView
1010+export const scopes = [
1111+ 'atproto',
1212+ scope.rpc({
1313+ lxm: ['app.bsky.bookmark.getBookmarks'],
1414+ aud: '*'
1515+ })
1616+];
1717+1818+// Login only, no signup
1919+export const ALLOW_SIGNUP = false;
2020+2121+// Not used since ALLOW_SIGNUP is false, but required by the framework
2222+const devPDS = 'https://bsky.social/';
2323+const prodPDS = 'https://bsky.social/';
2424+export const signUpPDS = dev ? devPDS : prodPDS;
2525+2626+export const REDIRECT_PATH = '/oauth/callback';
2727+2828+export const REDIRECT_TO_LAST_PAGE_ON_LOGIN = true;
2929+3030+export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
···11-// keep a cache of formatter per locale, to avoid re-creating them (GC)
22-const formatters = new Map<string, Intl.RelativeTimeFormat>();
33-44-// get the Intl.RelativeTimeFormat formatter for the given locale
55-export function getFormatter(locale: string) {
66- if (formatters.has(locale)) {
77- return formatters.get(locale)!;
88- }
99- const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'always', style: 'narrow' });
1010- formatters.set(locale, formatter);
1111- return formatter;
1212-}
-6
src/lib/post/relative-time/index.ts
···11-// adapted from https://github.com/CaptainCodeman/svelte-relative-time
22-33-export * from './action';
44-export type { Callback } from './render';
55-export { register, unregister } from './state';
66-export { default } from './RelativeTime.svelte';
-80
src/lib/post/relative-time/render.ts
···11-export type Callback = (result: {
22- seconds: number;
33- count: number;
44- units: Intl.RelativeTimeFormatUnit;
55- text: string;
66-}) => void;
77-88-export interface RenderState {
99- date: Date | number;
1010- callback: Callback;
1111- formatter: Intl.RelativeTimeFormat;
1212-}
1313-1414-// Array reprsenting one minute, hour, day, week, month, etc in seconds
1515-const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
1616-1717-// Array equivalent to the above but in the string representation of the units
1818-const formatUnits: Intl.RelativeTimeFormatUnit[] = [
1919- 'seconds',
2020- 'minutes',
2121- 'hours',
2222- 'days',
2323- 'weeks',
2424- 'months',
2525- 'years'
2626-];
2727-2828-// function to render relative time into
2929-export function render(state: RenderState, now: number) {
3030- const { date, callback, formatter } = state;
3131-3232- // Allow dates or times to be passed
3333- const timeMs = typeof date === 'number' ? date : date.getTime();
3434-3535- // Get the amount of seconds between the given date and now
3636- const delta = timeMs - now;
3737- const seconds = Math.round(delta / 1000);
3838-3939- // Grab the ideal cutoff unit
4040- const unitIndex = cutoffs.findIndex((cutoff) => cutoff > Math.abs(seconds));
4141-4242- // units
4343- const units = formatUnits[unitIndex];
4444-4545- // Get the divisor to divide from the seconds. E.g. if our unit is 'day' our divisor
4646- // is one day in seconds, so we can divide our seconds by this to get the # of days
4747- const divisor = unitIndex ? cutoffs[unitIndex - 1] : 1;
4848-4949- // count of units
5050- const count = Math.round(seconds / divisor);
5151-5252- // Intl.RelativeTimeFormat do its magic
5353- callback({
5454- seconds: seconds,
5555- count,
5656- units,
5757- text: formatter.format(count, units).replace('ago', '')
5858- });
5959-6060- // calculate time to next update, taking account rounding (e.g. it goes from 2 minutes to 1 minute at 90 seconds)
6161- // and also the changeover from units (59 seconds shouldn't show as 1 minute, so at 61 seconds we schedule the next
6262- // update for 1 second time)
6363-6464- const divisorMs = divisor * 1000;
6565-6666- let updateIn;
6767-6868- if (unitIndex) {
6969- updateIn = divisorMs / 2 - (Math.abs(delta) % divisorMs);
7070- if (updateIn < 0) {
7171- updateIn += divisorMs;
7272- }
7373- } else {
7474- updateIn = divisorMs - (Math.abs(delta) % divisorMs);
7575- }
7676-7777- const updateAt = now + updateIn;
7878-7979- return updateAt;
8080-}
-60
src/lib/post/relative-time/state.ts
···11-import { getFormatter } from './formatter';
22-import { render } from './render';
33-import type { Callback, RenderState } from './render';
44-55-interface UpdateState extends RenderState {
66- update: number;
77-}
88-99-// keep track of each instance
1010-const instances = new Map<Object, UpdateState>();
1111-1212-// we use a single timer for efficiency and to keep updates in sync
1313-let updateInterval: number | NodeJS.Timeout;
1414-1515-// register or update instance
1616-export function register(
1717- instance: Object,
1818- date: Date | number,
1919- locale: string,
2020- live: boolean,
2121- callback: Callback
2222-) {
2323- // get the formatter for the given locale, we do this here so we don't keep having to look it up on each tick
2424- const formatter = getFormatter(locale);
2525-2626- // create state to render
2727- const state = { date, callback, formatter };
2828-2929- // initial render is immediate, so works for SSR
3030- const update = render(state, Date.now());
3131-3232- // if it's to update live, we keep a track and schedule the next update
3333- if (live) {
3434- instances.set(instance, { ...state, update });
3535- } else {
3636- instances.delete(instance);
3737- }
3838-3939- // start the clock ticking if there are any live instances
4040- if (instances.size) {
4141- updateInterval =
4242- updateInterval ||
4343- setInterval(() => {
4444- const now = Date.now();
4545- for (const state of instances.values()) {
4646- if (state.update <= now) {
4747- state.update = render(state, now);
4848- }
4949- }
5050- }, 1000);
5151- }
5252-}
5353-5454-export function unregister(instance: Object) {
5555- instances.delete(instance);
5656- if (instances.size === 0) {
5757- clearInterval(updateInterval);
5858- updateInterval = 0;
5959- }
6060-}