Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions packages/core/src/bsky/feed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
});
Expand All @@ -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<AppBskyFeedGetTimeline.QueryParams, AppBskyFeedGetTimeline.OutputSchema>('app.bsky.feed.getTimeline', 'GET', {
cursor,
...params,
});

return res.data;
});
Expand Down
53 changes: 47 additions & 6 deletions packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
85 changes: 85 additions & 0 deletions packages/core/src/tsky/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
export interface Session {
fetchHandler: (pathname: string, init?: RequestInit) => Promise<Response>;
}


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,
});
}
}
52 changes: 16 additions & 36 deletions packages/core/src/tsky/tsky.ts
Original file line number Diff line number Diff line change
@@ -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<AppBskyActorDefs.ProfileViewDetailed>;
/**
* Get detailed profile views of multiple actors.
*/
profile(
identifiers: string[],
options?: AppBskyActorGetProfiles.CallOptions,
): Promise<AppBskyActorDefs.ProfileViewDetailed[]>;

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.
*/
Expand Down
103 changes: 103 additions & 0 deletions packages/core/src/tsky/xrpc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { Session } from './session';

async function getResponseJSONData<T>(
response: Response,
fail?: true,
): Promise<T>;
async function getResponseJSONData<T>(
response: Response,
fail: false,
): Promise<T | null>;
async function getResponseJSONData<T>(
response: Response,
fail = true,
): Promise<T | null> {
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<unknown> {
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<XrpcError> {
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<T extends Record<string, string>>(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<P = Record<string, string>, R = unknown>(
nsid: string,
method: 'GET' | 'POST' = 'GET',
params?: P,
): Promise<{
data: R;
headers: Record<string, string>;
}> {
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);
}
}
Loading
Loading