docs/assets/indexapistatus.png
docs/assets/indexapistatus.png
This is a binary file and will not be displayed.
docs/assets/viewapistatus.png
docs/assets/viewapistatus.png
This is a binary file and will not be displayed.
+33
indexserver.ts
+33
indexserver.ts
···
3
3
import { assertRecord, validateRecord } from "./utils/records.ts";
4
4
import {
5
5
buildBlobUrl,
6
+
getSlingshotRecord,
6
7
resolveIdentity,
7
8
searchParamsToJson,
8
9
withCors,
···
87
88
88
89
public handlesDid(did: string): boolean {
89
90
return this.userManager.handlesDid(did);
91
+
}
92
+
async unspeccedGetRegisteredUsers(): Promise<{
93
+
did: string;
94
+
role: string;
95
+
registrationdate: string;
96
+
onboardingstatus: string;
97
+
pfp?: string;
98
+
displayname: string;
99
+
handle: string;
100
+
}[]|undefined> {
101
+
const stmt = this.systemDB.prepare(`
102
+
SELECT *
103
+
FROM users;
104
+
`);
105
+
const result = stmt.all() as
106
+
{
107
+
did: string;
108
+
role: string;
109
+
registrationdate: string;
110
+
onboardingstatus: string;
111
+
}[];
112
+
const hydrated = await Promise.all( result.map(async (user)=>{
113
+
const identity = await resolveIdentity(user.did);
114
+
const profile = (await getSlingshotRecord(identity.did,"app.bsky.actor.profile","self")).value as ATPAPI.AppBskyActorProfile.Record;
115
+
const avatarcid = uncid(profile.avatar?.ref);
116
+
const avatar = avatarcid
117
+
? buildBlobUrl(identity.pds, identity.did, avatarcid)
118
+
: undefined;
119
+
return {...user,handle: identity.handle,pfp: avatar, displayname:profile.displayName ?? identity.handle }
120
+
}))
121
+
//const exists = result !== undefined;
122
+
return hydrated;
90
123
}
91
124
92
125
// We will move all the global functions into this class as methods...
+64
main-index.ts
+64
main-index.ts
···
67
67
// */
68
68
// "/xrpc/party.whey.app.bsky.feed.getListFeedPartial",
69
69
// ]);
70
+
const placeholderselfcheckstatus = {
71
+
"#skylite_index:/xrpc/app.bsky.actor.getProfile": "green",
72
+
"#skylite_index:/xrpc/app.bsky.actor.getProfiles": "green",
73
+
"#skylite_index:/xrpc/app.bsky.feed.getActorFeeds": "green",
74
+
"#skylite_index:/xrpc/app.bsky.feed.getFeedGenerator": "green",
75
+
"#skylite_index:/xrpc/app.bsky.feed.getFeedGenerators": "green",
76
+
"#skylite_index:/xrpc/app.bsky.feed.getPosts": "green",
77
+
"#skylite_index:/xrpc/app.bsky.graph.getLists": "black",
78
+
"#skylite_index:/xrpc/app.bsky.graph.getList": "black",
79
+
"#skylite_index:/xrpc/app.bsky.graph.getActorStarterPacks": "black",
80
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getActorLikesPartial": "green",
81
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getAuthorFeedPartial": "green",
82
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getLikesPartial": "orange",
83
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getPostThreadPartial": "green",
84
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getQuotesPartial": "orange",
85
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getRepostedByPartial": "orange",
86
+
"#skylite_index:/xrpc/party.whey.app.bsky.feed.getListFeedPartial": "black",
87
+
70
88
89
+
90
+
91
+
"constellation:/links": "green",
92
+
"constellation:/links/distinct-dids": "green",
93
+
"constellation:/links/count": "green",
94
+
"constellation:/links/count/distinct-dids": "green",
95
+
"constellation:/links/all": "green",
96
+
};
71
97
72
98
73
99
//console.log("ready to serve");
···
77
103
const url = new URL(req.url);
78
104
const pathname = url.pathname;
79
105
const searchParams = searchParamsToJson(url.searchParams);
106
+
107
+
const publicdir = "/public"
108
+
if (pathname.startsWith(publicdir)) {
109
+
const filepath = decodeURIComponent(pathname.slice(publicdir.length));
110
+
try {
111
+
const file = await Deno.open("./public" + filepath, { read: true });
112
+
return new Response(file.readable);
113
+
} catch {
114
+
return new Response("404 Not Found", { status: 404 });
115
+
}
116
+
}
117
+
118
+
const todopleasespecthis = "/_unspecced"
119
+
if (pathname.startsWith(todopleasespecthis)) {
120
+
const unspeccedroute = decodeURIComponent(pathname.slice(todopleasespecthis.length));
121
+
if (unspeccedroute === "/config") {
122
+
const safeconfig = {
123
+
inviteOnly: config.indexServer.inviteOnly,
124
+
//port: number,
125
+
did: config.indexServer.did,
126
+
host: config.indexServer.host,
127
+
}
128
+
return new Response(JSON.stringify(safeconfig), {
129
+
headers: withCors({ "content-type": "application/json; charset=utf-8" }),
130
+
});
131
+
}
132
+
if (unspeccedroute === "/users") {
133
+
const res = await genericIndexServer.unspeccedGetRegisteredUsers()
134
+
return new Response(JSON.stringify(res), {
135
+
headers: withCors({ "content-type": "application/json; charset=utf-8" }),
136
+
});
137
+
}
138
+
if (unspeccedroute === "/apitest") {
139
+
return new Response(JSON.stringify(placeholderselfcheckstatus), {
140
+
headers: withCors({ "content-type": "application/json; charset=utf-8" }),
141
+
});
142
+
}
143
+
}
80
144
81
145
if (html && js) {
82
146
if (pathname === "/" || pathname === "") {
+100
main-view.ts
+100
main-view.ts
···
50
50
// XRPC Method Implementations
51
51
// ------------------------------------------
52
52
53
+
const placeholderselfcheckstatus = {
54
+
"#bsky_appview:/xrpc/app.bsky.actor.getPreferences": "black",
55
+
"#bsky_appview:/xrpc/app.bsky.actor.getProfile": "green",
56
+
"#bsky_appview:/xrpc/app.bsky.actor.getProfiles": "green",
57
+
"#bsky_appview:/xrpc/app.bsky.actor.getSuggestions": "black",
58
+
"#bsky_appview:/xrpc/app.bsky.actor.putPreferences": "black",
59
+
"#bsky_appview:/xrpc/app.bsky.actor.searchActorsTypeahead": "black",
60
+
"#bsky_appview:/xrpc/app.bsky.actor.searchActors": "black",
61
+
"#bsky_appview:/xrpc/app.bsky.feed.describeFeedGenerator": "black",
62
+
"#bsky_appview:/xrpc/app.bsky.feed.getActorFeeds": "black",
63
+
"#bsky_appview:/xrpc/app.bsky.feed.getActorLikes": "black",
64
+
"#bsky_appview:/xrpc/app.bsky.feed.getAuthorFeed": "red",
65
+
"#bsky_appview:/xrpc/app.bsky.feed.getFeedGenerator": "black",
66
+
"#bsky_appview:/xrpc/app.bsky.feed.getFeedGenerators": "green", // arguably
67
+
"#bsky_appview:/xrpc/app.bsky.feed.getFeedSkeleton": "black",
68
+
"#bsky_appview:/xrpc/app.bsky.feed.getFeed": "red",
69
+
"#bsky_appview:/xrpc/app.bsky.feed.getLikes": "black",
70
+
"#bsky_appview:/xrpc/app.bsky.feed.getListFeed": "black",
71
+
"#bsky_appview:/xrpc/app.bsky.feed.getPostThread": "red",
72
+
"#bsky_appview:/xrpc/app.bsky.unspecced.getPostThreadV2": "red",
73
+
"#bsky_appview:/xrpc/app.bsky.feed.getPosts": "green",
74
+
"#bsky_appview:/xrpc/app.bsky.feed.getQuotes": "black",
75
+
"#bsky_appview:/xrpc/app.bsky.feed.getRepostedBy": "black",
76
+
"#bsky_appview:/xrpc/app.bsky.feed.getSuggestedFeeds": "black",
77
+
"#bsky_appview:/xrpc/app.bsky.feed.getTimeline": "black",
78
+
"#bsky_appview:/xrpc/app.bsky.feed.searchPosts": "black",
79
+
"#bsky_appview:/xrpc/app.bsky.feed.sendInteractions": "black",
80
+
"#bsky_appview:/xrpc/app.bsky.graph.getActorStarterPacks": "black",
81
+
"#bsky_appview:/xrpc/app.bsky.graph.getBlocks": "black",
82
+
"#bsky_appview:/xrpc/app.bsky.graph.getFollowers": "black",
83
+
"#bsky_appview:/xrpc/app.bsky.graph.getFollows": "black",
84
+
"#bsky_appview:/xrpc/app.bsky.graph.getKnownFollowers": "black",
85
+
"#bsky_appview:/xrpc/app.bsky.graph.getListBlocks": "black",
86
+
"#bsky_appview:/xrpc/app.bsky.graph.getListMutes": "black",
87
+
"#bsky_appview:/xrpc/app.bsky.graph.getList": "black",
88
+
"#bsky_appview:/xrpc/app.bsky.graph.getLists": "black",
89
+
"#bsky_appview:/xrpc/app.bsky.graph.getMutes": "black",
90
+
"#bsky_appview:/xrpc/app.bsky.graph.getRelationships": "black",
91
+
"#bsky_appview:/xrpc/app.bsky.graph.getStarterPack": "black",
92
+
"#bsky_appview:/xrpc/app.bsky.graph.getStarterPacks": "black",
93
+
"#bsky_appview:/xrpc/app.bsky.graph.getSuggestedFollowsByActor": "black",
94
+
"#bsky_appview:/xrpc/app.bsky.graph.muteActorList": "black",
95
+
"#bsky_appview:/xrpc/app.bsky.graph.muteActor": "black",
96
+
"#bsky_appview:/xrpc/app.bsky.graph.muteThread": "black",
97
+
"#bsky_appview:/xrpc/app.bsky.graph.searchStarterPacks": "black",
98
+
"#bsky_appview:/xrpc/app.bsky.graph.unmuteActorList": "black",
99
+
"#bsky_appview:/xrpc/app.bsky.graph.unmuteActor": "black",
100
+
"#bsky_appview:/xrpc/app.bsky.graph.unmuteThread": "black",
101
+
"#bsky_appview:/xrpc/app.bsky.labeler.getServices": "black",
102
+
"#bsky_appview:/xrpc/app.bsky.notification.getUnreadCount": "black",
103
+
"#bsky_appview:/xrpc/app.bsky.notification.listNotifications": "green",
104
+
"#bsky_appview:/xrpc/app.bsky.notification.putPreferences": "black",
105
+
"#bsky_appview:/xrpc/app.bsky.notification.registerPush": "black",
106
+
"#bsky_appview:/xrpc/app.bsky.notification.updateSeen": "black",
107
+
"#bsky_appview:/xrpc/app.bsky.video.getJobStatus": "black",
108
+
"#bsky_appview:/xrpc/app.bsky.video.getUploadLimits": "black",
109
+
"#bsky_appview:/xrpc/app.bsky.video.uploadVideo": "black",
110
+
"#bsky_appview:/xrpc/app.bsky.unspecced.getTrendingTopics":"red",
111
+
"#bsky_appview:/xrpc/app.bsky.unspecced.getConfig":"red",
112
+
}
113
+
53
114
Deno.serve(
54
115
{ port: config.viewServer.port },
55
116
async (req: Request): Promise<Response> => {
56
117
const url = new URL(req.url);
57
118
const pathname = url.pathname;
58
119
const searchParams = searchParamsToJson(url.searchParams);
120
+
121
+
const publicdir = "/public"
122
+
if (pathname.startsWith(publicdir)) {
123
+
const filepath = decodeURIComponent(pathname.slice(publicdir.length));
124
+
try {
125
+
const file = await Deno.open("." + filepath, { read: true });
126
+
return new Response(file.readable);
127
+
} catch {
128
+
return new Response("404 Not Found", { status: 404 });
129
+
}
130
+
}
131
+
132
+
const todopleasespecthis = "/_unspecced"
133
+
if (pathname.startsWith(todopleasespecthis)) {
134
+
const unspeccedroute = decodeURIComponent(pathname.slice(todopleasespecthis.length));
135
+
if (unspeccedroute === "/config") {
136
+
const safeconfig = {
137
+
inviteOnly: config.viewServer.inviteOnly,
138
+
//port: number,
139
+
did: config.viewServer.did,
140
+
host: config.viewServer.host,
141
+
indexPriority: config.viewServer.indexPriority,
142
+
}
143
+
return new Response(JSON.stringify(safeconfig), {
144
+
headers: withCors({ "content-type": "application/json; charset=utf-8" }),
145
+
});
146
+
}
147
+
if (unspeccedroute === "/users") {
148
+
const res = await genericViewServer.unspeccedGetRegisteredUsers()
149
+
return new Response(JSON.stringify(res), {
150
+
headers: withCors({ "content-type": "application/json; charset=utf-8" }),
151
+
});
152
+
}
153
+
if (unspeccedroute === "/apitest") {
154
+
return new Response(JSON.stringify(placeholderselfcheckstatus), {
155
+
headers: withCors({ "content-type": "application/json; charset=utf-8" }),
156
+
});
157
+
}
158
+
}
59
159
60
160
if (html && js) {
61
161
if (pathname === "/" || pathname === "") {
public/index.ico
public/index.ico
This is a binary file and will not be displayed.
public/view.ico
public/view.ico
This is a binary file and will not be displayed.
+7
-68
readme.md
+7
-68
readme.md
···
1
1
# skylite (pre alpha)
2
2
an attempt to make a lightweight, easily self-hostable, scoped Bluesky appview
3
3
4
+
(as of 28 aug 2025)
5
+
currently the state of the project is:
6
+

7
+

8
+
4
9
this project uses:
5
10
- live sync systems: [jetstream](https://github.com/bluesky-social/jetstream) and [spacedust](https://spacedust.microcosm.blue/)
6
11
- backfill: [listRecords](https://docs.bsky.app/docs/api/com-atproto-repo-list-records) and [constellation](https://constellation.microcosm.blue/)
7
12
- the backend server stuff: [sqlite](https://jsr.io/@db/sqlite) db, typescript with [codegen](https://www.npmjs.com/package/@atproto/lex-cli), and [deno](https://deno.com/)
8
13
- frontend: still deno and esbuild and tailwind and react and jsx and typescript (was fun getting these to run on deno)
9
14
10
-
## Status
11
-
(as of 27 aug 2025)
12
-
currently the state of the project is:
13
-
### Index Server
14
-
- Database:
15
-
- Works, though it still needs some more tuning and iteration
16
-
- Registration:
17
-
- not there yet. currently manually adding users
18
-
- onboarding backfill is probably next maybe
19
-
- got a fancy new ui (not finished)
20
-
- Indexing:
21
-
- Jetstream:
22
-
- its there, i just need to actually handle each and every record type and insert it to the db (like currently 2 out of 12 or so record collections are being inserted into the db)
23
-
- Spacedust:
24
-
- its a backlink index so i only needed one table, and so it is complete
25
-
- Server:
26
-
- Initial implementation is done
27
-
- uses per-user instantiation thing so it can add or remove users as needed
28
-
- pagination is not a thing yet \:\(
29
-
- does not implement the Ref / Partial routes yet (currently strips undefineds) (fixing this soon)
30
-
- also implements the entirety of the Constellation API routes as a bonus (under `/links/`)
31
-
- Lexicon:
32
-
- unsure about PostViewRef's optional fields
33
-
- theres 3 remaining profile-related routes thats still not defined yet
34
-
- some routes need more tweaks
35
-
- considering adding optional query params to request either skeleton only, partials, or full hydrated (might not be respected by the server though lol)
36
-
- considering making all of the api routes custom instead of the current situation of having some of the Index server routes be the original unmodified bsky.app routes
37
-
38
-
### View Server
39
-
- Registration:
40
-
- got a fancy new ui (not finished)
41
-
- no backfill yet
42
-
- Bsky API Routes:
43
-
- currently mostly just proxies api.bsky.app
44
-
- Notifications works ! thanks to spacedust
45
-
- Following feed is probably next
46
-
- Database:
47
-
- i havent split the DB between the Index server and View server yet
48
-
- Hydration (resolving `Ref`s, handling partials):
49
-
- It works! not implemented for all routes yet but it can find a route i think maybe idk
50
-
- it does now have a ranking system to decide which index server to be prioritized if multiple index servers indexes the same user account. (and also supports both api sets (Bluesky AppView API (legacy/fallback) and Bluesky Index Server API ))
51
-
52
15
## Running
53
16
this project is pre-alpha and not intended for general use yet. you are welcome to experiment if you dont mind errors or breaking changes.
54
17
···
63
26
deno task index
64
27
```
65
28
it should just work actually
66
-
implemented xrpc routes for `#skylite_index` are:
67
-
```ts
68
-
const indexServerRoutes = new Set([
69
-
"/xrpc/app.bsky.actor.getProfile",
70
-
"/xrpc/app.bsky.actor.getProfiles",
71
-
"/xrpc/app.bsky.feed.getActorFeeds",
72
-
"/xrpc/app.bsky.feed.getFeedGenerator",
73
-
"/xrpc/app.bsky.feed.getFeedGenerators",
74
-
"/xrpc/app.bsky.feed.getPosts",
75
-
"/xrpc/party.whey.app.bsky.feed.getActorLikesPartial",
76
-
"/xrpc/party.whey.app.bsky.feed.getAuthorFeedPartial",
77
-
"/xrpc/party.whey.app.bsky.feed.getLikesPartial",
78
-
"/xrpc/party.whey.app.bsky.feed.getPostThreadPartial",
79
-
"/xrpc/party.whey.app.bsky.feed.getQuotesPartial",
80
-
"/xrpc/party.whey.app.bsky.feed.getRepostedByPartial",
81
-
// i havent implemented these three yet
82
-
/*
83
-
app.bsky.graph.getLists // doesnt need to because theres no items[], and its self ProfileViewBasic
84
-
app.bsky.graph.getList // needs to be Partial-ed (items[] union with ProfileViewRef)
85
-
app.bsky.graph.getActorStarterPacks // maybe doesnt need to be Partial-ed because its self ProfileViewBasic
86
-
*/
87
-
88
-
// and the last one is a stub because its pretty hard to do
89
-
"/xrpc/party.whey.app.bsky.feed.getListFeedPartial",
90
-
]);
91
-
```
92
29
93
30
there is no way to register users to be indexed by the server yet (either Index nor View servers) so you can just manually add your account to the `system.db` file for now
94
31
···
100
37
expose your localhost to the web using a tunnel or something and use that url as the custom appview url
101
38
102
39
this should work on any bluesky client that supports changing the appview URL (im using an unreleased custom fork for development) as the view server implements the `#bsky_appview` routes for compatibility with existing clients
40
+
41
+
ive got a custom `social-app` fork here [https://github.com/rimar1337/social-app/tree/publicappview-colorable](https://github.com/rimar1337/social-app/tree/publicappview-colorable)
103
42
104
43
the view server has extra configurations that you need to understand.
105
44
the view server hydrates content by calling other servers (either an `#skylite_index` or `#bsky_appview`) and so you need to write the order of which servers are prioritized first for resolving the hydration endpoints
+33
viewserver.ts
+33
viewserver.ts
···
89
89
console.log("viewServer started.");
90
90
}
91
91
92
+
async unspeccedGetRegisteredUsers(): Promise<{
93
+
did: string;
94
+
role: string;
95
+
registrationdate: string;
96
+
onboardingstatus: string;
97
+
pfp?: string;
98
+
displayname: string;
99
+
handle: string;
100
+
}[]|undefined> {
101
+
const stmt = this.systemDB.prepare(`
102
+
SELECT *
103
+
FROM users;
104
+
`);
105
+
const result = stmt.all() as
106
+
{
107
+
did: string;
108
+
role: string;
109
+
registrationdate: string;
110
+
onboardingstatus: string;
111
+
}[];
112
+
const hydrated = await Promise.all( result.map(async (user)=>{
113
+
const identity = await resolveIdentity(user.did);
114
+
const profile = (await getSlingshotRecord(identity.did,"app.bsky.actor.profile","self")).value as ATPAPI.AppBskyActorProfile.Record;
115
+
const avatarcid = uncid(profile.avatar?.ref);
116
+
const avatar = avatarcid
117
+
? buildBlobUrl(identity.pds, identity.did, avatarcid)
118
+
: undefined;
119
+
return {...user,handle: identity.handle,pfp: avatar, displayname:profile.displayName ?? identity.handle }
120
+
}))
121
+
//const exists = result !== undefined;
122
+
return hydrated;
123
+
}
124
+
92
125
async viewServerHandler(req: Request): Promise<Response> {
93
126
const url = new URL(req.url);
94
127
const pathname = url.pathname;