Skip to content

Commit 4c4bf31

Browse files
anbratenshuuji3
andauthored
feat: add client (#32)
Co-authored-by: TAKAHASHI Shuuji <shuuji3@gmail.com>
1 parent bdf6c6e commit 4c4bf31

30 files changed

+6136
-6899
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ jobs:
3939

4040
- name: 💪 Type check
4141
run: pnpm -r test:typecheck
42-
42+
4343
- name: 📦 Build
4444
run: pnpm -r build
45-
45+
4646
# - name: 🚢 Continuous Release
4747
# run: pnpm dlx pkg-pr-new publish './packages/core'
4848
# if: github.event_name == 'pull_request'

README.md

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,37 @@ bun add tsky
3434

3535
## Usage
3636

37+
Use an identity & password login:
38+
39+
```ts
40+
import { Tsky } from 'tsky';
41+
import { CredentialManager } from '@atcute/client';
42+
43+
const manager = new CredentialManager({ service: 'https://bsky.social' });
44+
await manager.login({
45+
identifier: 'alice.tsky.dev',
46+
password: 'password',
47+
});
48+
```
49+
50+
or the [@atcute/oauth-browser-client](https://github.com/mary-ext/atcute/tree/trunk/packages/oauth/browser-client):
51+
3752
```ts
38-
import { Tsky } from 'tsky'
53+
import { Tsky } from 'tsky';
54+
import { OAuthUserAgent, finalizeAuthorization } from '@atcute/oauth-browser-client';
3955

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

41-
const app = new AppBskyNS(); // TODO
42-
const tsky = new Tsky(app);
58+
const manager = new OAuthUserAgent(session);
59+
```
60+
61+
and then initialize the tsky client:
62+
63+
```ts
64+
const tsky = new Tsky(manager);
4365

44-
const profile = await tsky.profile('did:plc:giohuovwawlijq7jkuysq5dd');
66+
// get the profile of a user
67+
const profile = await tsky.bsky.profile('did:plc:giohuovwawlijq7jkuysq5dd');
4568

4669
console.log(profile.handle);
4770
```

package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
"url": "git+https://github.com/tsky-dev/tsky.git"
1212
},
1313
"scripts": {
14-
"dev": "pnpm run --filter @tsky/core dev",
15-
"build": "pnpm run --filter @tsky/core build",
14+
"dev": "pnpm run -r dev",
15+
"build": "pnpm run -r build",
1616
"docs:dev": "pnpm run --filter @tsky/docs dev",
1717
"docs:build": "pnpm run --filter @tsky/docs build",
1818
"docs:preview": "pnpm run --filter @tsky/docs preview",
@@ -21,7 +21,8 @@
2121
"lint": "biome lint .",
2222
"lint:fix": "biome lint --write .",
2323
"check": "biome check",
24-
"check:fix": "biome check --write ."
24+
"check:fix": "biome check --write .",
25+
"typecheck": "pnpm run -r typecheck"
2526
},
2627
"devDependencies": {
2728
"@biomejs/biome": "^1.9.4",
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "@tsky/core",
2+
"name": "@tsky/client",
33
"type": "module",
44
"version": "0.0.1",
55
"license": "MIT",
@@ -29,7 +29,8 @@
2929
"test:typescript": "tsc --noEmit"
3030
},
3131
"dependencies": {
32-
"@atproto/api": "^0.13.18"
32+
"@atcute/client": "^2.0.6",
33+
"@tsky/lexicons": "workspace:*"
3334
},
3435
"devDependencies": {
3536
"globals": "^15.12.0",

packages/client/src/bsky/feed.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type {
2+
AppBskyFeedGetFeed,
3+
AppBskyFeedGetTimeline,
4+
} from '@tsky/lexicons';
5+
import type { Client } from '~/tsky/client';
6+
import { Paginator } from '~/tsky/paginator';
7+
8+
export class Feed {
9+
constructor(private client: Client) {}
10+
11+
/**
12+
* Get a hydrated feed from an actor's selected feed generator. Implemented by App View.
13+
*/
14+
async getFeed(
15+
params: AppBskyFeedGetFeed.Params,
16+
options?: AppBskyFeedGetFeed.Input,
17+
): Promise<Paginator<AppBskyFeedGetFeed.Output>> {
18+
return Paginator.init(async (cursor) => {
19+
const res = await this.client.get('app.bsky.feed.getFeed', {
20+
...(options ?? {}),
21+
params: {
22+
cursor,
23+
...params,
24+
},
25+
});
26+
27+
return res.data;
28+
});
29+
}
30+
31+
/**
32+
* Get a view of the requesting account's home timeline. This is expected to be some form of reverse-chronological feed.
33+
*/
34+
getTimeline(
35+
params: AppBskyFeedGetTimeline.Params,
36+
options?: AppBskyFeedGetTimeline.Input,
37+
): Promise<Paginator<AppBskyFeedGetTimeline.Output>> {
38+
return Paginator.init(async (cursor) => {
39+
const res = await this.client.get('app.bsky.feed.getTimeline', {
40+
...(options ?? {}),
41+
params: {
42+
cursor,
43+
...params,
44+
},
45+
});
46+
47+
return res.data;
48+
});
49+
}
50+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { CredentialManager } from '@atcute/client';
2+
import { describe, expect, it } from 'vitest';
3+
import { Tsky } from '~/index';
4+
5+
const formatSecret = (secret: string | undefined) => {
6+
if (!secret) {
7+
throw new Error('Secret is required');
8+
}
9+
10+
return secret.replace(/^tsky /g, '').trim();
11+
};
12+
13+
const TEST_CREDENTIALS = {
14+
alice: {
15+
handle: 'alice.tsky.dev',
16+
did: 'did:plc:jguhdmnjclquqf5lsvkyxqy3',
17+
password: 'alice_and_bob',
18+
},
19+
bob: {
20+
handle: 'bob.tsky.dev',
21+
did: 'did:plc:2ig7akkyfq256j42uxvc4g2h',
22+
password: 'alice_and_bob',
23+
},
24+
};
25+
26+
async function getAliceTsky() {
27+
const manager = new CredentialManager({ service: 'https://bsky.social' });
28+
await manager.login({
29+
identifier: TEST_CREDENTIALS.alice.handle,
30+
password: TEST_CREDENTIALS.alice.password,
31+
});
32+
33+
return new Tsky(manager);
34+
}
35+
36+
describe('bsky', () => {
37+
it('.profile()', async () => {
38+
const tsky = await getAliceTsky();
39+
const profile = await tsky.bsky.profile(TEST_CREDENTIALS.alice.did);
40+
41+
expect(profile).toBeDefined();
42+
expect(profile).toHaveProperty('handle', TEST_CREDENTIALS.alice.handle);
43+
});
44+
45+
describe('feed', () => {
46+
it('.timeline()', async () => {
47+
const tsky = await getAliceTsky();
48+
49+
const paginator = await tsky.bsky.feed.getTimeline({
50+
limit: 30,
51+
});
52+
53+
expect(paginator).toBeDefined();
54+
expect(paginator.values).toBeDefined();
55+
expect(paginator.values).toBeInstanceOf(Array);
56+
expect(paginator.values.length).toBe(1); // we should get the first page from the paginator
57+
expect(paginator.values[0].feed.length).toBeGreaterThan(0); // alice has some posts ;)
58+
expect(paginator.values[0].feed[0]).toHaveProperty('post');
59+
});
60+
61+
it('.feed()', async () => {
62+
const tsky = await getAliceTsky();
63+
64+
const paginator = await tsky.bsky.feed.getFeed({
65+
// "Birds! 🦉" custom feed
66+
// - https://bsky.app/profile/daryllmarie.bsky.social/feed/aaagllxbcbsje
67+
feed: 'at://did:plc:ffkgesg3jsv2j7aagkzrtcvt/app.bsky.feed.generator/aaagllxbcbsje',
68+
limit: 30,
69+
});
70+
71+
expect(paginator).toBeDefined();
72+
expect(paginator.values).toBeDefined();
73+
expect(paginator.values).toBeInstanceOf(Array);
74+
expect(paginator.values.length).toBe(1); // we should get the first page from the paginator
75+
expect(paginator.values[0].feed.length).toBeGreaterThan(0); // we found some birds posts ;)
76+
expect(paginator.values[0].feed[0]).toHaveProperty('post');
77+
});
78+
});
79+
});

packages/client/src/bsky/index.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { AppBskyActorDefs } from '@tsky/lexicons';
2+
import { Feed } from '~/bsky/feed';
3+
import type { Client } from '~/tsky/client';
4+
5+
export class Bsky {
6+
client: Client;
7+
8+
constructor(client: Client) {
9+
this.client = client;
10+
}
11+
12+
/**
13+
* Get detailed profile view of an actor. Does not require auth, but contains relevant metadata with auth.
14+
*/
15+
async profile(
16+
identifier: string,
17+
): Promise<AppBskyActorDefs.ProfileViewDetailed> {
18+
const res = await this.client.get('app.bsky.actor.getProfile', {
19+
params: { actor: identifier },
20+
});
21+
22+
return res.data;
23+
}
24+
25+
get feed() {
26+
return new Feed(this.client);
27+
}
28+
}

packages/client/src/tsky/client.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type {
2+
RPCOptions,
3+
XRPC,
4+
XRPCRequestOptions,
5+
XRPCResponse,
6+
} from '@atcute/client';
7+
import type { Procedures, Queries } from '@tsky/lexicons';
8+
9+
// From @atcute/client
10+
type OutputOf<T> = T extends {
11+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
12+
output: any;
13+
}
14+
? T['output']
15+
: never;
16+
17+
export class Client<Q = Queries, P = Procedures> {
18+
xrpc: XRPC;
19+
20+
constructor(xrpc: XRPC) {
21+
this.xrpc = xrpc;
22+
}
23+
24+
/**
25+
* Makes a query (GET) request
26+
* @param nsid Namespace ID of a query endpoint
27+
* @param options Options to include like parameters
28+
* @returns The response of the request
29+
*/
30+
async get<K extends keyof Q>(
31+
nsid: K,
32+
options: RPCOptions<Q[K]>,
33+
): Promise<XRPCResponse<OutputOf<Q[K]>>> {
34+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
35+
return this.xrpc.get(nsid as any, options);
36+
}
37+
38+
/**
39+
* Makes a procedure (POST) request
40+
* @param nsid Namespace ID of a procedure endpoint
41+
* @param options Options to include like input body or parameters
42+
* @returns The response of the request
43+
*/
44+
async call<K extends keyof P>(
45+
nsid: K,
46+
options: RPCOptions<P[K]>,
47+
): Promise<XRPCResponse<OutputOf<P[K]>>> {
48+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
49+
return this.xrpc.call(nsid as any, options);
50+
}
51+
52+
/** Makes a request to the XRPC service */
53+
async request(options: XRPCRequestOptions): Promise<XRPCResponse> {
54+
return this.xrpc.request(options);
55+
}
56+
}

0 commit comments

Comments
 (0)