This repository has no description
1import { Container } from "@cloudflare/containers";
2
3export interface Env {
4 BOBBIN: DurableObjectNamespace<BobbinContainer>;
5 RATE_LIMITER: RateLimit;
6 BOBBIN_HYDRANT_URL: string;
7 BOBBIN_SLINGSHOT_URL: string;
8 BOBBIN_LOG: string;
9}
10
11export class BobbinContainer extends Container<Env> {
12 defaultPort = 8090;
13 enableInternet = true;
14 // Bobbin maintains an in-memory index rebuilt from Hydrant replay on every
15 // restart, so we want to avoid sleeping where possible. Override
16 // onActivityExpired() to keep the container alive indefinitely.
17 sleepAfter = "24h";
18
19 constructor(ctx: DurableObjectState<{}>, env: Env) {
20 super(ctx, env, {
21 envVars: {
22 BOBBIN_HYDRANT_URL: env.BOBBIN_HYDRANT_URL,
23 BOBBIN_SLINGSHOT_URL: env.BOBBIN_SLINGSHOT_URL,
24 BOBBIN_LOG: env.BOBBIN_LOG,
25 BOBBIN_LOG_FORMAT: "json",
26 },
27 });
28 }
29
30 async onActivityExpired(): Promise<void> {
31 // Keep the container running; bobbin's in-memory index is expensive to
32 // rebuild. Renew the timeout instead of stopping so we're pinged again later.
33 this.renewActivityTimeout();
34 }
35
36 onError(error: Error) {
37 console.error("bobbin container error:", error);
38 }
39}
40
41const INDEX = `This is bobbin, Tangled's stateless XRPC API service: https://tangled.org/tangled.org/core/tree/master/bobbin`;
42
43const CORS_HEADERS = {
44 "Access-Control-Allow-Origin": "*",
45 "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
46 "Access-Control-Allow-Headers": "Content-Type, Authorization, atproto-proxy",
47 "Access-Control-Max-Age": "86400",
48};
49
50function withCors(response: Response): Response {
51 const headers = new Headers(response.headers);
52 for (const [name, value] of Object.entries(CORS_HEADERS)) {
53 headers.set(name, value);
54 }
55
56 // Responses with these statuses must not carry a body; a non-null body
57 // crashes workerd, so null it out.
58 const body = [101, 204, 205, 304].includes(response.status)
59 ? null
60 : response.body;
61
62 return new Response(body, {
63 status: response.status,
64 statusText: response.statusText,
65 headers,
66 });
67}
68
69export default {
70 async fetch(request: Request, env: Env): Promise<Response> {
71 if (request.method === "OPTIONS") {
72 return new Response(null, { status: 204, headers: CORS_HEADERS });
73 }
74
75 const url = new URL(request.url);
76 if (url.pathname === "/" || url.pathname === "") {
77 return withCors(
78 new Response(INDEX, { headers: { "Content-Type": "text/plain" } }),
79 );
80 }
81
82 const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
83 const { success } = await env.RATE_LIMITER.limit({ key: ip });
84 if (!success) {
85 return withCors(
86 new Response(
87 JSON.stringify({
88 error: "RateLimitExceeded",
89 message: "too many requests, slow down",
90 }),
91 {
92 status: 429,
93 headers: {
94 "Content-Type": "application/json",
95 "Retry-After": "60",
96 },
97 },
98 ),
99 );
100 }
101
102 const container = env.BOBBIN.getByName("primary-eu-v2", {
103 locationHint: "weur",
104 });
105 const response = await container.fetch(request);
106 // A 101 is a protocol switch (e.g. WebSocket upgrade); return it untouched
107 // so we don't strip the connection off the response.
108 if (response.status === 101) {
109 return response;
110 }
111 return withCors(response);
112 },
113} satisfies ExportedHandler<Env>;