Skip to content

Commit

Permalink
feat: add client (#32)
Browse files Browse the repository at this point in the history
Co-authored-by: TAKAHASHI Shuuji <[email protected]>
  • Loading branch information
anbraten and shuuji3 authored Jan 14, 2025
1 parent bdf6c6e commit 4c4bf31
Show file tree
Hide file tree
Showing 30 changed files with 6,136 additions and 6,899 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ jobs:

- name: 💪 Type check
run: pnpm -r test:typecheck

- name: 📦 Build
run: pnpm -r build

# - name: 🚢 Continuous Release
# run: pnpm dlx pkg-pr-new publish './packages/core'
# if: github.event_name == 'pull_request'
31 changes: 27 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,37 @@ bun add tsky

## Usage

Use an identity & password login:

```ts
import { Tsky } from 'tsky';
import { CredentialManager } from '@atcute/client';

const manager = new CredentialManager({ service: 'https://bsky.social' });
await manager.login({
identifier: 'alice.tsky.dev',
password: 'password',
});
```

or the [@atcute/oauth-browser-client](https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/browser-client):

```ts
import { Tsky } from 'tsky'
import { Tsky } from 'tsky';
import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client';

// get a session as described at: https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/browser-client

const app = new AppBskyNS(); // TODO
const tsky = new Tsky(app);
const manager = new OAuthUserAgent(session);
```

and then initialize the tsky client:

```ts
const tsky = new Tsky(manager);

const profile = await tsky.profile('did:plc:giohuovwawlijq7jkuysq5dd');
// get the profile of a user
const profile = await tsky.bsky.profile('did:plc:giohuovwawlijq7jkuysq5dd');

console.log(profile.handle);
```
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"url": "git+https://github.com/tsky-dev/tsky.git"
},
"scripts": {
"dev": "pnpm run --filter @tsky/core dev",
"build": "pnpm run --filter @tsky/core build",
"dev": "pnpm run -r dev",
"build": "pnpm run -r build",
"docs:dev": "pnpm run --filter @tsky/docs dev",
"docs:build": "pnpm run --filter @tsky/docs build",
"docs:preview": "pnpm run --filter @tsky/docs preview",
Expand All @@ -21,7 +21,8 @@
"lint": "biome lint .",
"lint:fix": "biome lint --write .",
"check": "biome check",
"check:fix": "biome check --write ."
"check:fix": "biome check --write .",
"typecheck": "pnpm run -r typecheck"
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
Expand Down
5 changes: 3 additions & 2 deletions packages/core/package.json → packages/client/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@tsky/core",
"name": "@tsky/client",
"type": "module",
"version": "0.0.1",
"license": "MIT",
Expand Down Expand Up @@ -29,7 +29,8 @@
"test:typescript": "tsc --noEmit"
},
"dependencies": {
"@atproto/api": "^0.13.18"
"@atcute/client": "^2.0.6",
"@tsky/lexicons": "workspace:*"
},
"devDependencies": {
"globals": "^15.12.0",
Expand Down
50 changes: 50 additions & 0 deletions packages/client/src/bsky/feed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type {
AppBskyFeedGetFeed,
AppBskyFeedGetTimeline,
} from '@tsky/lexicons';
import type { Client } from '~/tsky/client';
import { Paginator } from '~/tsky/paginator';

export class Feed {
constructor(private client: Client) {}

/**
* Get a hydrated feed from an actor's selected feed generator. Implemented by App View.
*/
async getFeed(
params: AppBskyFeedGetFeed.Params,
options?: AppBskyFeedGetFeed.Input,
): Promise<Paginator<AppBskyFeedGetFeed.Output>> {
return Paginator.init(async (cursor) => {
const res = await this.client.get('app.bsky.feed.getFeed', {
...(options ?? {}),
params: {
cursor,
...params,
},
});

return res.data;
});
}

/**
* Get a view of the requesting account's home timeline. This is expected to be some form of reverse-chronological feed.
*/
getTimeline(
params: AppBskyFeedGetTimeline.Params,
options?: AppBskyFeedGetTimeline.Input,
): Promise<Paginator<AppBskyFeedGetTimeline.Output>> {
return Paginator.init(async (cursor) => {
const res = await this.client.get('app.bsky.feed.getTimeline', {
...(options ?? {}),
params: {
cursor,
...params,
},
});

return res.data;
});
}
}
79 changes: 79 additions & 0 deletions packages/client/src/bsky/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { CredentialManager } from '@atcute/client';
import { describe, expect, it } from 'vitest';
import { Tsky } from '~/index';

const formatSecret = (secret: string | undefined) => {
if (!secret) {
throw new Error('Secret is required');
}

return secret.replace(/^tsky /g, '').trim();
};

const TEST_CREDENTIALS = {
alice: {
handle: 'alice.tsky.dev',
did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3',
password: 'alice_and_bob',
},
bob: {
handle: 'bob.tsky.dev',
did: 'did:plc:2ig7akkyfq256j42uxvc4g2h',
password: 'alice_and_bob',
},
};

async function getAliceTsky() {
const manager = new CredentialManager({ service: 'https://bsky.social' });
await manager.login({
identifier: TEST_CREDENTIALS.alice.handle,
password: TEST_CREDENTIALS.alice.password,
});

return new Tsky(manager);
}

describe('bsky', () => {
it('.profile()', async () => {
const tsky = await getAliceTsky();
const profile = await tsky.bsky.profile(TEST_CREDENTIALS.alice.did);

expect(profile).toBeDefined();
expect(profile).toHaveProperty('handle', TEST_CREDENTIALS.alice.handle);
});

describe('feed', () => {
it('.timeline()', async () => {
const tsky = await getAliceTsky();

const paginator = await tsky.bsky.feed.getTimeline({
limit: 30,
});

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');
});

it('.feed()', async () => {
const tsky = await getAliceTsky();

const paginator = await tsky.bsky.feed.getFeed({
// "Birds! 🦉" custom feed
// - https://bsky.app/profile/daryllmarie.bsky.social/feed/aaagllxbcbsje
feed: 'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.generator/aaagllxbcbsje',
limit: 30,
});

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); // we found some birds posts ;)
expect(paginator.values[0].feed[0]).toHaveProperty('post');
});
});
});
28 changes: 28 additions & 0 deletions packages/client/src/bsky/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { AppBskyActorDefs } from '@tsky/lexicons';
import { Feed } from '~/bsky/feed';
import type { Client } from '~/tsky/client';

export class Bsky {
client: Client;

constructor(client: Client) {
this.client = client;
}

/**
* Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth.
*/
async profile(
identifier: string,
): Promise<AppBskyActorDefs.ProfileViewDetailed> {
const res = await this.client.get('app.bsky.actor.getProfile', {
params: { actor: identifier },
});

return res.data;
}

get feed() {
return new Feed(this.client);
}
}
File renamed without changes.
56 changes: 56 additions & 0 deletions packages/client/src/tsky/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {
RPCOptions,
XRPC,
XRPCRequestOptions,
XRPCResponse,
} from '@atcute/client';
import type { Procedures, Queries } from '@tsky/lexicons';

// From @atcute/client
type OutputOf<T> = T extends {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
output: any;
}
? T['output']
: never;

export class Client<Q = Queries, P = Procedures> {
xrpc: XRPC;

constructor(xrpc: XRPC) {
this.xrpc = xrpc;
}

/**
* Makes a query (GET) request
* @param nsid Namespace ID of a query endpoint
* @param options Options to include like parameters
* @returns The response of the request
*/
async get<K extends keyof Q>(
nsid: K,
options: RPCOptions<Q[K]>,
): Promise<XRPCResponse<OutputOf<Q[K]>>> {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
return this.xrpc.get(nsid as any, options);
}

/**
* Makes a procedure (POST) request
* @param nsid Namespace ID of a procedure endpoint
* @param options Options to include like input body or parameters
* @returns The response of the request
*/
async call<K extends keyof P>(
nsid: K,
options: RPCOptions<P[K]>,
): Promise<XRPCResponse<OutputOf<P[K]>>> {
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
return this.xrpc.call(nsid as any, options);
}

/** Makes a request to the XRPC service */
async request(options: XRPCRequestOptions): Promise<XRPCResponse> {
return this.xrpc.request(options);
}
}
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
interface CursorResponse {
cursor?: string;
[key: string]: unknown;
}

export class Paginator<T extends CursorResponse> {
readonly values: T[] = [];

constructor(
private constructor(
private onNext: (cursor?: string) => Promise<T>,
defaultValues?: T[],
) {
if (defaultValues) {
this.values = defaultValues;
}
}

static async init<T extends CursorResponse>(
onNext: (cursor?: string) => Promise<T>,
defaultValues?: T[],
): Promise<Paginator<T>> {
const paginator = new Paginator<T>(onNext, defaultValues);

// load the first page
await paginator.next();

// TODO: Should we call this here to get the first value?
// this.next();
return paginator;
}

clone() {
Expand Down
18 changes: 18 additions & 0 deletions packages/client/src/tsky/tsky.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { CredentialManager } from '@atcute/client';
import { XRPC } from '@atcute/client';
import type { Queries } from '@tsky/lexicons';
import { Bsky } from '~/bsky';
import { Client } from './client';

export class Tsky {
client: Client<Queries>;

constructor(manager: CredentialManager) {
const xrpc = new XRPC({ handler: manager });
this.client = new Client(xrpc);
}

get bsky() {
return new Bsky(this.client);
}
}
Loading

0 comments on commit 4c4bf31

Please sign in to comment.