bluesky client without react native baggage written in sveltekit

Add atproto OAuth authentication via @atcute/oauth-browser-client

Set up the full OAuth flow for AT Protocol login:
- Create src/lib/atproto.ts with configureOAuth, login/resume/callback helpers,
and exported publicClient (unauthenticated) + rpc (authenticated) clients
- Add /oauth/callback route to handle the OAuth redirect
- Update left sidebar in +page.svelte with login form / logged-in state
- Update +page.js to use authenticated client when available
- Configure Vite with loopback client_id for local development on 127.0.0.1:12520

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+283 -19
+11
.claude/launch.json
··· 1 + { 2 + "version": "0.0.1", 3 + "configurations": [ 4 + { 5 + "name": "dev", 6 + "runtimeExecutable": "pnpm", 7 + "runtimeArgs": ["dev"], 8 + "port": 12520 9 + } 10 + ] 11 + }
+3 -1
package.json
··· 60 60 "@atcute/bluesky": "^3.2.18", 61 61 "@atcute/bluesky-richtext-parser": "^2.1.1", 62 62 "@atcute/bluesky-richtext-segmenter": "^3.0.0", 63 - "@atcute/client": "^4.2.1" 63 + "@atcute/client": "^4.2.1", 64 + "@atcute/identity-resolver": "^1.2.2", 65 + "@atcute/oauth-browser-client": "^3.0.0" 64 66 } 65 67 }
+81
pnpm-lock.yaml
··· 23 23 '@atcute/client': 24 24 specifier: ^4.2.1 25 25 version: 4.2.1 26 + '@atcute/identity-resolver': 27 + specifier: ^1.2.2 28 + version: 1.2.2(@atcute/identity@1.1.3) 29 + '@atcute/oauth-browser-client': 30 + specifier: ^3.0.0 31 + version: 3.0.0(@atcute/identity@1.1.3) 26 32 devDependencies: 27 33 '@chromatic-com/storybook': 28 34 specifier: ^5.0.1 ··· 150 156 '@atcute/client@4.2.1': 151 157 resolution: {integrity: sha512-ZBFM2pW075JtgGFu5g7HHZBecrClhlcNH8GVP9Zz1aViWR+cjjBsTpeE63rJs+FCOHFYlirUyo5L8SGZ4kMINw==} 152 158 159 + '@atcute/identity-resolver@1.2.2': 160 + resolution: {integrity: sha512-eUh/UH4bFvuXS0X7epYCeJC/kj4rbBXfSRumLEH4smMVwNOgTo7cL/0Srty+P/qVPoZEyXdfEbS0PHJyzoXmHw==} 161 + peerDependencies: 162 + '@atcute/identity': ^1.0.0 163 + 153 164 '@atcute/identity@1.1.3': 154 165 resolution: {integrity: sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==} 155 166 156 167 '@atcute/lexicons@1.2.9': 157 168 resolution: {integrity: sha512-/RRHm2Cw9o8Mcsrq0eo8fjS9okKYLGfuFwrQ0YoP/6sdSDsXshaTLJsvLlcUcaDaSJ1YFOuHIo3zr2Om2F/16g==} 158 169 170 + '@atcute/multibase@1.1.8': 171 + resolution: {integrity: sha512-pJgtImMZKCjqwRbu+2GzB+4xQjKBXDwdZOzeqe0u97zYKRGftpGYGvYv3+pMe2xXe+msDyu7Nv8iJp+U14otTA==} 172 + 173 + '@atcute/oauth-browser-client@3.0.0': 174 + resolution: {integrity: sha512-7AbKV8tTe7aRJNJV7gCcWHSVEADb2nr58O1p7dQsf73HSe9pvlBkj/Vk1yjjtH691uAVYkwhHSh0bC7D8XdwJw==} 175 + 176 + '@atcute/oauth-crypto@0.1.0': 177 + resolution: {integrity: sha512-qZYDCNLF/4B6AndYT1rsQelN8621AC5u/sL5PHvlr/qqAbmmUwCBGjEgRSyZtHE1AqD60VNiSMlOgAuEQTSl3w==} 178 + 179 + '@atcute/oauth-keyset@0.1.0': 180 + resolution: {integrity: sha512-+wqT/+I5Lg9VzKnKY3g88+N45xbq+wsdT6bHDGqCVa2u57gRvolFF4dY+weMfc/OX641BIZO6/o+zFtKBsMQnQ==} 181 + 182 + '@atcute/oauth-types@0.1.1': 183 + resolution: {integrity: sha512-u+3KMjse3Uc/9hDyilu1QVN7IpcnjVXgRzhddzBB8Uh6wePHNVBDdi9wQvFTVVA3zmxtMJVptXRyLLg6Ou9bqg==} 184 + 159 185 '@atcute/uint8array@1.1.1': 160 186 resolution: {integrity: sha512-3LsC8XB8TKe9q/5hOA5sFuzGaIFdJZJNewC5OKa3o/eU6+K7JR6see9Zy2JbQERNVnRl11EzbNov1efgLMAs4g==} 187 + 188 + '@atcute/util-fetch@1.0.5': 189 + resolution: {integrity: sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==} 161 190 162 191 '@atcute/util-text@1.1.1': 163 192 resolution: {integrity: sha512-JH0SxzUQJAmbOBTYyhxQbkkI6M33YpjlVLEcbP5GYt43xgFArzV0FJVmEpvIj0kjsmphHB45b6IitdvxPdec9w==} ··· 1625 1654 engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 1626 1655 hasBin: true 1627 1656 1657 + nanoid@5.1.6: 1658 + resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==} 1659 + engines: {node: ^18 || >=20} 1660 + hasBin: true 1661 + 1628 1662 natural-compare@1.4.0: 1629 1663 resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} 1630 1664 ··· 2203 2237 dependencies: 2204 2238 '@atcute/identity': 1.1.3 2205 2239 '@atcute/lexicons': 1.2.9 2240 + 2241 + '@atcute/identity-resolver@1.2.2(@atcute/identity@1.1.3)': 2242 + dependencies: 2243 + '@atcute/identity': 1.1.3 2244 + '@atcute/lexicons': 1.2.9 2245 + '@atcute/util-fetch': 1.0.5 2246 + '@badrap/valita': 0.4.6 2206 2247 2207 2248 '@atcute/identity@1.1.3': 2208 2249 dependencies: ··· 2216 2257 '@standard-schema/spec': 1.1.0 2217 2258 esm-env: 1.2.2 2218 2259 2260 + '@atcute/multibase@1.1.8': 2261 + dependencies: 2262 + '@atcute/uint8array': 1.1.1 2263 + 2264 + '@atcute/oauth-browser-client@3.0.0(@atcute/identity@1.1.3)': 2265 + dependencies: 2266 + '@atcute/client': 4.2.1 2267 + '@atcute/identity-resolver': 1.2.2(@atcute/identity@1.1.3) 2268 + '@atcute/lexicons': 1.2.9 2269 + '@atcute/multibase': 1.1.8 2270 + '@atcute/oauth-crypto': 0.1.0 2271 + '@atcute/oauth-types': 0.1.1 2272 + nanoid: 5.1.6 2273 + transitivePeerDependencies: 2274 + - '@atcute/identity' 2275 + 2276 + '@atcute/oauth-crypto@0.1.0': 2277 + dependencies: 2278 + '@atcute/multibase': 1.1.8 2279 + '@atcute/uint8array': 1.1.1 2280 + '@badrap/valita': 0.4.6 2281 + nanoid: 5.1.6 2282 + 2283 + '@atcute/oauth-keyset@0.1.0': 2284 + dependencies: 2285 + '@atcute/oauth-crypto': 0.1.0 2286 + 2287 + '@atcute/oauth-types@0.1.1': 2288 + dependencies: 2289 + '@atcute/identity': 1.1.3 2290 + '@atcute/lexicons': 1.2.9 2291 + '@atcute/oauth-keyset': 0.1.0 2292 + '@badrap/valita': 0.4.6 2293 + 2219 2294 '@atcute/uint8array@1.1.1': {} 2295 + 2296 + '@atcute/util-fetch@1.0.5': 2297 + dependencies: 2298 + '@badrap/valita': 0.4.6 2220 2299 2221 2300 '@atcute/util-text@1.1.1': 2222 2301 dependencies: ··· 3545 3624 ms@2.1.3: {} 3546 3625 3547 3626 nanoid@3.3.11: {} 3627 + 3628 + nanoid@5.1.6: {} 3548 3629 3549 3630 natural-compare@1.4.0: {} 3550 3631
+87
src/lib/atproto.ts
··· 1 + import { Client, simpleFetchHandler } from '@atcute/client'; 2 + import { 3 + configureOAuth, 4 + createAuthorizationUrl, 5 + finalizeAuthorization, 6 + getSession, 7 + listStoredSessions, 8 + OAuthUserAgent, 9 + type Session 10 + } from '@atcute/oauth-browser-client'; 11 + import { 12 + CompositeDidDocumentResolver, 13 + LocalActorResolver, 14 + PlcDidDocumentResolver, 15 + WebDidDocumentResolver, 16 + XrpcHandleResolver 17 + } from '@atcute/identity-resolver'; 18 + 19 + configureOAuth({ 20 + metadata: { 21 + client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 22 + redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI 23 + }, 24 + identityResolver: new LocalActorResolver({ 25 + handleResolver: new XrpcHandleResolver({ 26 + serviceUrl: 'https://public.api.bsky.app' 27 + }), 28 + didDocumentResolver: new CompositeDidDocumentResolver({ 29 + methods: { 30 + plc: new PlcDidDocumentResolver(), 31 + web: new WebDidDocumentResolver() 32 + } 33 + }) 34 + }) 35 + }); 36 + 37 + /** Public (unauthenticated) RPC client — always available */ 38 + export const publicClient = new Client({ 39 + handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }) 40 + }); 41 + 42 + /** Authenticated RPC client — set after login, null otherwise */ 43 + export let rpc: Client | null = null; 44 + 45 + /** Current OAuth session */ 46 + let currentSession: Session | null = null; 47 + 48 + /** Try to resume an existing session from localStorage */ 49 + export async function resumeSession(): Promise<boolean> { 50 + const dids = listStoredSessions(); 51 + if (dids.length === 0) return false; 52 + 53 + try { 54 + const session = await getSession(dids[0], { allowStale: true }); 55 + setSession(session); 56 + return true; 57 + } catch { 58 + return false; 59 + } 60 + } 61 + 62 + /** Handle the OAuth callback — call this from the callback route */ 63 + export async function handleCallback(params: URLSearchParams): Promise<void> { 64 + const { session } = await finalizeAuthorization(params); 65 + setSession(session); 66 + } 67 + 68 + /** Start the login flow */ 69 + export async function login(handle: string): Promise<void> { 70 + const authUrl = await createAuthorizationUrl({ 71 + target: { type: 'account', identifier: handle }, 72 + scope: import.meta.env.VITE_OAUTH_SCOPE 73 + }); 74 + await new Promise((r) => setTimeout(r, 200)); 75 + window.location.assign(authUrl); 76 + } 77 + 78 + /** Check if a session is active */ 79 + export function isLoggedIn(): boolean { 80 + return rpc !== null; 81 + } 82 + 83 + function setSession(session: Session): void { 84 + currentSession = session; 85 + const agent = new OAuthUserAgent(session); 86 + rpc = new Client({ handler: agent }); 87 + }
+12 -16
src/routes/+page.js
··· 1 - import { Client, simpleFetchHandler } from '@atcute/client'; 2 - 3 - const client = new Client({ 4 - handler: simpleFetchHandler({ service: 'https://public.api.bsky.app' }), 5 - }); 1 + import { publicClient, rpc } from '$lib/atproto'; 6 2 7 3 export async function load() { 8 - const { data, ok } = await client.get('app.bsky.feed.getFeed', { 9 - params: { 10 - feed: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot', 11 - limit: 30 12 - }, 13 - }); 14 - console.log(data) 15 - if (!ok) { 16 - throw new Error("couldn't load profile") 17 - } 18 - return { data } 4 + const client = rpc ?? publicClient; 5 + const { data, ok } = await client.get('app.bsky.feed.getFeed', { 6 + params: { 7 + feed: 'at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot', 8 + limit: 30 9 + } 10 + }); 11 + if (!ok) { 12 + throw new Error("couldn't load profile"); 13 + } 14 + return { data }; 19 15 }
+41 -1
src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import Post from '$lib/components/Post.svelte'; 3 + import { login, resumeSession } from '$lib/atproto'; 4 + import { onMount } from 'svelte'; 5 + 3 6 let { data } = $props(); 7 + let loggedIn = $state(false); 8 + let handle = $state(''); 9 + let loggingIn = $state(false); 10 + 11 + onMount(async () => { 12 + loggedIn = await resumeSession(); 13 + }); 14 + 15 + async function handleLogin() { 16 + if (!handle.trim()) return; 17 + loggingIn = true; 18 + try { 19 + await login(handle.trim()); 20 + } catch { 21 + loggingIn = false; 22 + } 23 + } 4 24 </script> 5 25 6 26 <div class="mx-auto flex max-h-screen max-w-290"> 7 - <div class="sidebar">owo</div> 27 + <div class="sidebar p-4"> 28 + {#if loggedIn} 29 + <p>Logged in</p> 30 + {:else} 31 + <form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}> 32 + <input 33 + type="text" 34 + placeholder="handle (e.g. alice.bsky.social)" 35 + bind:value={handle} 36 + class="mb-2 w-full rounded border px-2 py-1 text-sm" 37 + /> 38 + <button 39 + type="submit" 40 + disabled={loggingIn} 41 + class="w-full rounded bg-blue-500 px-3 py-1.5 text-sm text-white hover:bg-blue-600 disabled:opacity-50" 42 + > 43 + {loggingIn ? 'Logging in...' : 'Log in'} 44 + </button> 45 + </form> 46 + {/if} 47 + </div> 8 48 <div class="max-w-150.5 overflow-y-scroll"> 9 49 {#each data.data.feed as entry, i (i)} 10 50 <Post post={entry.post} />
+25
src/routes/oauth/callback/+page.svelte
··· 1 + <script lang="ts"> 2 + import { goto } from '$app/navigation'; 3 + import { handleCallback } from '$lib/atproto'; 4 + import { onMount } from 'svelte'; 5 + 6 + let error = $state(''); 7 + 8 + onMount(async () => { 9 + const params = new URLSearchParams(location.hash.slice(1)); 10 + history.replaceState(null, '', location.pathname + location.search); 11 + 12 + try { 13 + await handleCallback(params); 14 + goto('/'); 15 + } catch (e) { 16 + error = e instanceof Error ? e.message : 'Authorization failed'; 17 + } 18 + }); 19 + </script> 20 + 21 + {#if error} 22 + <p>Error: {error}</p> 23 + {:else} 24 + <p>Logging in...</p> 25 + {/if}
+23 -1
vite.config.ts
··· 10 10 const dirname = 11 11 typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)); 12 12 13 + const SERVER_HOST = '127.0.0.1'; 14 + const SERVER_PORT = 12520; 15 + const OAUTH_SCOPE = 'atproto transition:generic'; 16 + 13 17 // More info at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon 14 18 export default defineConfig({ 15 - plugins: [tailwindcss(), sveltekit(), devtoolsJson()], 19 + plugins: [ 20 + tailwindcss(), 21 + sveltekit(), 22 + devtoolsJson(), 23 + { 24 + name: 'oauth-env', 25 + config(_conf, { command }) { 26 + const redirectUri = `http://${SERVER_HOST}:${SERVER_PORT}/oauth/callback`; 27 + if (command === 'serve') { 28 + process.env.VITE_OAUTH_CLIENT_ID = 29 + `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(OAUTH_SCOPE)}`; 30 + process.env.VITE_OAUTH_REDIRECT_URI = redirectUri; 31 + } 32 + process.env.VITE_OAUTH_SCOPE = OAUTH_SCOPE; 33 + } 34 + } 35 + ], 16 36 server: { 37 + host: SERVER_HOST, 38 + port: SERVER_PORT, 17 39 allowedHosts: ['localhost', 'bodhi-unremunerated-lily.ngrok-free.dev'] 18 40 }, 19 41 test: {