diff --git a/packages/core/src/bsky/feed.ts b/packages/core/src/bsky/feed.ts index 5d434f8..718d4aa 100644 --- a/packages/core/src/bsky/feed.ts +++ b/packages/core/src/bsky/feed.ts @@ -9,9 +9,10 @@ import type { AppBskyNS, } from '@atproto/api'; import { Paginator } from '~/tsky/paginator'; +import type { XrpcClient } from '~/tsky/xrpc'; export class Feed { - constructor(private instance: AppBskyNS) {} + constructor(private client: XrpcClient) {} /** * Get a hydrated feed from an actor's selected feed generator. Implemented by App View. @@ -21,10 +22,10 @@ export class Feed { options?: AppBskyFeedGetFeed.CallOptions, ) { return new Paginator(async (cursor) => { - const res = await this.instance.feed.getFeed( - { cursor, ...params }, - options, - ); + const res = await this.client.request('app.bsky.feed.getFeed', 'GET', { + cursor, + ...params, + }); return res.data; }); @@ -38,10 +39,10 @@ export class Feed { options?: AppBskyFeedGetTimeline.CallOptions, ) { return new Paginator(async (cursor) => { - const res = await this.instance.feed.getTimeline( - { cursor, ...params }, - options, - ); + const res = await this.client.request('app.bsky.feed.getTimeline', 'GET', { + cursor, + ...params, + }); return res.data; }); diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index ac4d366..89014b0 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -1,13 +1,54 @@ import { describe, expect, it } from 'vitest'; +import { TSky } from './index'; +import { PasswordSession } from './tsky/session'; + +const env = process.env; +const TEST_CREDENTIALS = { + alice: { + handle: 'alice.tsky.dev', + did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3', + appPassword: env.ALICE_APP_PASSWORD, + }, + bob: { + handle: 'bob.tsky.dev', + did: 'did:plc:2ig7akkyfq256j42uxvc4g2h', + appPassword: env.BOB_APP_PASSWORD, + }, +}; +const TEST_ENDPOINT = 'https://bsky.social'; describe('tSky', () => { - it('profile', async () => { - // TODO: use actual client - const profile = { - handle: 'alice.tsky.dev', - }; + it('.profile()', async () => { + const session = new PasswordSession(TEST_ENDPOINT); + await session.login(TEST_CREDENTIALS.alice.did, TEST_CREDENTIALS.alice.appPassword); + + const tSky = new TSky(session); + + const profile = await tSky.profile(TEST_CREDENTIALS.alice.did); expect(profile).toBeDefined(); - expect(profile).toHaveProperty('handle', 'alice.tsky.dev'); + expect(profile).toHaveProperty('handle', TEST_CREDENTIALS.alice.handle); + }); + + describe('feed', () => { + it('.timeline()', async () => { + const session = new PasswordSession(TEST_ENDPOINT); + await session.login(TEST_CREDENTIALS.alice.did, TEST_CREDENTIALS.alice.appPassword); + + const tSky = new TSky(session); + + const paginator = tSky.feed.timeline({ + limit: 30, + }); + + await paginator.next(); + + expect(paginator).toBeDefined(); + expect(paginator.values).toBeDefined(); + expect(paginator.values).toBeInstanceOf(Array); + expect(paginator.values.length).toBe(1); // we should get the first page from the paginator + expect(paginator.values[0].feed.length).toBeGreaterThan(0); // alice has some posts ;) + expect(paginator.values[0].feed[0]).toHaveProperty('post'); + }); }); }); diff --git a/packages/core/src/tsky/session.ts b/packages/core/src/tsky/session.ts new file mode 100644 index 0000000..9d54c71 --- /dev/null +++ b/packages/core/src/tsky/session.ts @@ -0,0 +1,85 @@ +export interface Session { + fetchHandler: (pathname: string, init?: RequestInit) => Promise; +} + + +function isTokenExpired(response: Response) { + if (response.status !== 400) return false + + const contentLength = Number(response.headers.get('content-length') ?? '0'); + + // FROM: https://github.com/mary-ext/atcute/blob/3fcf7f990d494049f87d07e940fcc6550b7fbc67/packages/core/client/lib/credential-manager.ts#L293 + // {"error":"ExpiredToken","message":"Token has expired"} + // {"error":"ExpiredToken","message":"Token is expired"} + if (contentLength > 54 * 1.5) { + return false; + } + + return response.clone().json().then((json) => { + if (json.error === 'ExpiredToken') { + return true; + } + + return false; + }).catch(() => false); +} + +export class PasswordSession implements Session { + token?: string; + identifier?: string; + password?: string; + + constructor(private _baseUrl: string) { + this.token = process.env.TOKEN; // TODO: remove this hack + } + + private get baseUrl() { + return this._baseUrl; // TODO: support session-based URLs + } + + async login(identifier: string, password: string) { + // TODO: implement login + } + + private async refresh(force = false) { + console.log('Refreshing token', { force }); + } + + async fetchHandler(endpoint: string, init?: RequestInit) { + await this.refresh(); + + const url = new URL(endpoint, this.baseUrl); + const headers = new Headers(init?.headers); + + if (!headers.has('authorization')) { + headers.set('authorization', `Bearer ${this.token}`); + } + + const response = await fetch(url, { + ...init, + headers, + }); + + if (!isTokenExpired(response)) { + return response; + } + + try { + await this.refresh(true); + } catch (e) { + return response; + } + + // if the body is a stream, we can't retry + if (ReadableStream && init?.body instanceof ReadableStream) { + return response + } + + // try again with the new token + headers.set('authorization', `Bearer ${this.token}`); + return fetch(url, { + ...init, + headers, + }); + } +} diff --git a/packages/core/src/tsky/tsky.ts b/packages/core/src/tsky/tsky.ts index 5c7fbb6..29ebde2 100644 --- a/packages/core/src/tsky/tsky.ts +++ b/packages/core/src/tsky/tsky.ts @@ -1,54 +1,34 @@ import type { - AppBskyActorDefs, - AppBskyActorGetProfile, - AppBskyActorGetProfiles, AppBskyActorSearchActors, AppBskyActorSearchActorsTypeahead, - AppBskyNS, } from '@atproto/api'; +import { Feed } from '~/bsky'; import { Paginator } from './paginator'; +import type { Session } from './session'; +import { XrpcClient } from './xrpc'; export class TSky { - constructor(private instance: AppBskyNS) {} + xrpc: XrpcClient; + + constructor(session: Session) { + this.xrpc = new XrpcClient(session); + } /** * Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth. */ - profile( - identifier: string, - options?: AppBskyActorGetProfile.CallOptions, - ): Promise; - /** - * Get detailed profile views of multiple actors. - */ - profile( - identifiers: string[], - options?: AppBskyActorGetProfiles.CallOptions, - ): Promise; - - async profile( - identifier: string | string[], - options?: - | AppBskyActorGetProfile.CallOptions - | AppBskyActorGetProfiles.CallOptions, - ) { - if (Array.isArray(identifier)) { - const res = await this.instance.actor.getProfiles( - { actors: identifier }, - options, - ); - - return res.data.profiles; - } - - const res = await this.instance.actor.getProfile( - { actor: identifier[0] }, - options, - ); + async profile(identifier: string | string[]) { + const res = await this.xrpc.request('app.bsky.actor.getProfile', 'GET', { + actor: identifier, + }); return res.data; } + get feed() { + return new Feed(this.xrpc); + } + /** * Find actor suggestions for a prefix search term. Expected use is for auto-completion during text field entry. Does not require auth. */ diff --git a/packages/core/src/tsky/xrpc.ts b/packages/core/src/tsky/xrpc.ts new file mode 100644 index 0000000..39ac315 --- /dev/null +++ b/packages/core/src/tsky/xrpc.ts @@ -0,0 +1,103 @@ +import type { Session } from './session'; + +async function getResponseJSONData( + response: Response, + fail?: true, +): Promise; +async function getResponseJSONData( + response: Response, + fail: false, +): Promise; +async function getResponseJSONData( + response: Response, + fail = true, +): Promise { + if (response.headers.get('Content-Type')?.includes('application/json')) { + return response.json(); + } + + if (fail) { + throw new Error('Response is not JSON'); + } + + return null; +} + +async function getResponseContent(response: Response): Promise { + const json = await getResponseJSONData(response); + if (json) { + return json; + } + + return response.text(); +} + +export class XrpcError extends Error { + statusCode: number; + + constructor(statusCode: number, error?: string, message?: string) { + super(message || error); + this.statusCode = statusCode; + } + + static async fromResponse(response: Response): Promise { + const data = await getResponseJSONData<{ + error?: string; + message?: string; + }>(response, false); + if (data) { + return new XrpcError(response.status, data.error, data.message); + } + + return new XrpcError(response.status, response.statusText); + } +} + +function dropEmptyValues>(obj: T): T { + const _obj = { ...obj }; + for (const key of Object.keys(_obj)) { + if (_obj[key] === undefined) { + delete _obj[key]; + } + } + return _obj; +} + +export class XrpcClient { + session: Session; + + constructor(session: Session) { + this.session = session; + } + + async request

, R = unknown>( + nsid: string, + method: 'GET' | 'POST' = 'GET', + params?: P, + ): Promise<{ + data: R; + headers: Record; + }> { + const searchParams = new URLSearchParams(dropEmptyValues(params ?? {})); + + const response = await this.session.fetchHandler( + `/xrpc/${nsid}?${searchParams.toString()}`, + { + method, + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + if (response.status >= 200 && response.status < 300) { + const data = await getResponseContent(response); + return { + data: data as T, + headers: Object.fromEntries(response.headers.entries()), + }; + } + + throw await XrpcError.fromResponse(response); + } +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index f68a77a..ef52a1d 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,7 +1,8 @@ { "compilerOptions": { "target": "ESNext", - "lib": ["ESNext"], + "lib": ["ESNext", "DOM"], + "moduleDetection": "force", "useDefineForClassFields": false, "experimentalDecorators": true, "baseUrl": ".", @@ -10,24 +11,23 @@ "paths": { "~/*": ["src/*"] }, + "resolveJsonModule": true, + "allowJs": true, "strict": true, "noFallthroughCasesInSwitch": true, "noImplicitOverride": true, "noImplicitReturns": true, - "declaration": false, - "noEmit": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "skipLibCheck": true, - "moduleDetection": "force", - "resolveJsonModule": true, - "allowJs": true, "noUnusedLocals": true, "noUnusedParameters": true, + "declaration": false, + "noEmit": true, "outDir": "dist/", "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, "isolatedModules": true, "verbatimModuleSyntax": true, + "skipLibCheck": true }, "include": ["./src/**/*", "./tests/**/*", "./test-utils/**/*"], "typedocOptions": {