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 {
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 async onActivityExpired(): Promise<void> {
20 // Keep the container running; bobbin's in-memory index is expensive to
21 // rebuild. Renew the timeout instead of stopping so we're pinged again later.
22 this.renewActivityTimeout();
23 }
24
25 onError(error: Error) {
26 console.error("bobbin container error:", error);
27 }
28}
29
30const INDEX = `This is bobbin, Tangled's stateless XRPC API service: https://tangled.org/tangled.org/core/tree/master/bobbin`;
31
32const CORS_HEADERS = {
33 "Access-Control-Allow-Origin": "*",
34 "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS",
35 "Access-Control-Allow-Headers": "Content-Type, Authorization",
36 "Access-Control-Max-Age": "86400",
37};
38
39function withCors(response: Response): Response {
40 const headers = new Headers(response.headers);
41 for (const [name, value] of Object.entries(CORS_HEADERS)) {
42 headers.set(name, value);
43 }
44
45 // Responses with these statuses must not carry a body; a non-null body
46 // crashes workerd, so null it out.
47 const body = [101, 204, 205, 304].includes(response.status)
48 ? null
49 : response.body;
50
51 return new Response(body, {
52 status: response.status,
53 statusText: response.statusText,
54 headers,
55 });
56}
57
58export default {
59 async fetch(request: Request, env: Env): Promise<Response> {
60 if (request.method === "OPTIONS") {
61 return new Response(null, { status: 204, headers: CORS_HEADERS });
62 }
63
64 const url = new URL(request.url);
65 if (url.pathname === "/" || url.pathname === "") {
66 return withCors(
67 new Response(INDEX, { headers: { "Content-Type": "text/plain" } }),
68 );
69 }
70
71 const ip = request.headers.get("cf-connecting-ip") ?? "unknown";
72 const { success } = await env.RATE_LIMITER.limit({ key: ip });
73 if (!success) {
74 return withCors(
75 new Response(
76 JSON.stringify({
77 error: "RateLimitExceeded",
78 message: "too many requests, slow down",
79 }),
80 {
81 status: 429,
82 headers: {
83 "Content-Type": "application/json",
84 "Retry-After": "60",
85 },
86 },
87 ),
88 );
89 }
90
91 const container = env.BOBBIN.getByName("primary");
92 await container.startAndWaitForPorts({
93 startOptions: {
94 envVars: {
95 BOBBIN_HYDRANT_URL: env.BOBBIN_HYDRANT_URL,
96 BOBBIN_SLINGSHOT_URL: env.BOBBIN_SLINGSHOT_URL,
97 BOBBIN_LOG: env.BOBBIN_LOG,
98 BOBBIN_LOG_FORMAT: "json",
99 },
100 },
101 });
102 const response = await container.fetch(request);
103 // A 101 is a protocol switch (e.g. WebSocket upgrade); return it untouched
104 // so we don't strip the connection off the response.
105 if (response.status === 101) {
106 return response;
107 }
108 return withCors(response);
109 },
110} satisfies ExportedHandler<Env>;