(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import type { UserProfile } from "../types";
2
3const API_URL =
4 process.env.API_URL || `http://localhost:${process.env.API_PORT || 8081}`;
5
6function serverFetch(path: string, cookie?: string): Promise<Response> {
7 const headers: Record<string, string> = {
8 "Content-Type": "application/json",
9 };
10 if (cookie) headers["Cookie"] = cookie;
11 return fetch(`${API_URL}${path}`, { headers });
12}
13
14const sessionCache = new Map<string, { user: UserProfile; expires: number }>();
15
16export function clearSessionCacheForCookie(cookie: string) {
17 const cacheKey = cookie.match(/margin_session=([^;]+)/)?.[1] || "";
18 if (cacheKey) sessionCache.delete(cacheKey);
19}
20
21export async function getSession(cookie: string): Promise<UserProfile | null> {
22 try {
23 const cacheKey = cookie.match(/margin_session=([^;]+)/)?.[1] || "";
24 const cached = sessionCache.get(cacheKey);
25 if (cached && Date.now() < cached.expires) {
26 return cached.user;
27 }
28
29 const res = await serverFetch("/auth/session", cookie);
30 if (!res.ok) return null;
31 const data = await res.json();
32 if (!data.authenticated && !data.did) return null;
33
34 const profile: UserProfile = {
35 did: data.did,
36 handle: data.handle,
37 displayName: data.displayName,
38 avatar: data.avatar,
39 description: data.description,
40 website: data.website,
41 links: data.links,
42 followersCount: data.followersCount,
43 followsCount: data.followsCount,
44 postsCount: data.postsCount,
45 };
46
47 if (cacheKey) {
48 sessionCache.set(cacheKey, {
49 user: profile,
50 expires: Date.now() + 5 * 60_000,
51 });
52 if (sessionCache.size > 100) {
53 const now = Date.now();
54 for (const [k, v] of sessionCache) {
55 if (now > v.expires) sessionCache.delete(k);
56 }
57 }
58 }
59
60 const controller = new AbortController();
61 const timeout = setTimeout(() => controller.abort(), 3000);
62
63 Promise.allSettled([
64 fetch(
65 `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(data.did)}`,
66 { signal: controller.signal },
67 ),
68 serverFetch(`/api/profile/${data.did}`, cookie),
69 ])
70 .then(([bskyRes, marginRes]) => {
71 clearTimeout(timeout);
72
73 if (bskyRes.status === "fulfilled" && bskyRes.value.ok) {
74 bskyRes.value
75 .json()
76 .then((bsky) => {
77 if (bsky.avatar) profile.avatar = bsky.avatar;
78 if (bsky.displayName) profile.displayName = bsky.displayName;
79 })
80 .catch(() => {
81 /* ignore */
82 });
83 }
84
85 if (marginRes.status === "fulfilled" && marginRes.value.ok) {
86 marginRes.value
87 .json()
88 .then((mp) => {
89 if (mp?.description) profile.description = mp.description;
90 if (mp?.followersCount)
91 profile.followersCount = mp.followersCount;
92 if (mp?.followsCount) profile.followsCount = mp.followsCount;
93 if (mp?.postsCount) profile.postsCount = mp.postsCount;
94 if (mp?.website) profile.website = mp.website;
95 if (mp?.links) profile.links = mp.links;
96 })
97 .catch(() => {
98 /* ignore */
99 });
100 }
101 })
102 .catch(() => {
103 clearTimeout(timeout);
104 });
105
106 return profile;
107 } catch {
108 return null;
109 }
110}