a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(xrpc-server): add handleHealthCheck router option

when set, routes `/xrpc/_health` to the provided handler. this endpoint
is non-standard; callers opt in and own the response shape (status code,
body, headers). without the option, `_health` falls through to
`handleNotFound`.

Mary 6b62a410 14cacc77

+81
+6
.changeset/xrpc-server-health-check.md
··· 1 + --- 2 + '@atcute/xrpc-server': minor 3 + --- 4 + 5 + add `handleHealthCheck` router option. when set, `/xrpc/_health` dispatches to it; this endpoint is 6 + non-standard, so callers opt in and own the response body and status.
+14
packages/servers/xrpc-server/README.md
··· 147 147 `ForbiddenError`, `RateLimitExceededError`, `InternalServerError`, `UpstreamFailureError`, 148 148 `NotEnoughResourcesError`, `UpstreamTimeoutError`. 149 149 150 + ### health check 151 + 152 + the router can optionally answer `/xrpc/_health` if you pass `handleHealthCheck`. this endpoint is 153 + non-standard — consumers decide the response shape and status. 154 + 155 + ```ts 156 + const router = new XRPCRouter({ 157 + async handleHealthCheck() { 158 + const healthy = await pingDatabase(); 159 + return Response.json({ status: healthy ? 'ok' : 'degraded' }, { status: healthy ? 200 : 503 }); 160 + }, 161 + }); 162 + ``` 163 + 150 164 ### subscriptions 151 165 152 166 subscriptions provide real-time streaming over WebSocket. they require a runtime-specific adapter:
+38
packages/servers/xrpc-server/lib/main/router.test.ts
··· 32 32 expect(response.status).toBe(404); 33 33 }); 34 34 35 + it('falls through to handleNotFound when handleHealthCheck is not set', async () => { 36 + const router = new XRPCRouter(); 37 + 38 + const request = new Request('http://example.com/xrpc/_health', { method: 'GET' }); 39 + const response = await router.fetch(request); 40 + 41 + expect(response.status).toBe(404); 42 + }); 43 + 44 + it('invokes handleHealthCheck on /xrpc/_health', async () => { 45 + const mock = vi.fn(() => Response.json({ status: 'ok', version: '1.0' })); 46 + const router = new XRPCRouter({ handleHealthCheck: mock }); 47 + 48 + const request = new Request('http://example.com/xrpc/_health', { method: 'GET' }); 49 + const response = await router.fetch(request); 50 + 51 + expect(mock).toHaveBeenCalledExactlyOnceWith(request); 52 + expect(response.status).toBe(200); 53 + expect(await response.json()).toEqual({ status: 'ok', version: '1.0' }); 54 + }); 55 + 56 + it('runs handleHealthCheck through handleException when it throws', async () => { 57 + const router = new XRPCRouter({ 58 + handleHealthCheck: () => { 59 + throw new Error('boom'); 60 + }, 61 + }); 62 + 63 + const request = new Request('http://example.com/xrpc/_health', { method: 'GET' }); 64 + const response = await router.fetch(request); 65 + 66 + expect(response.status).toBe(500); 67 + expect(await response.json()).toEqual({ 68 + error: 'InternalServerError', 69 + message: 'an exception happened whilst processing this request', 70 + }); 71 + }); 72 + 35 73 it('accepts HEAD requests on query routes', async () => { 36 74 const querySchema = v.query('com.example.query', { 37 75 params: null,
+23
packages/servers/xrpc-server/lib/main/router.ts
··· 38 38 export type FetchMiddleware = Middleware<[request: Request], Promise<Response>>; 39 39 40 40 export type NotFoundHandler = (request: Request) => Promisable<Response>; 41 + export type HealthCheckHandler = (request: Request) => Promisable<Response>; 41 42 export type ExceptionHandler = (error: unknown, request: Request) => Promisable<Response>; 42 43 export type SubscriptionExceptionHandler = (error: unknown, request: Request) => void; 43 44 ··· 67 68 export interface XRPCRouterOptions { 68 69 middlewares?: FetchMiddleware[]; 69 70 handleNotFound?: NotFoundHandler; 71 + /** 72 + * optional handler for `/xrpc/_health`. when provided, the router answers 73 + * health-check requests by invoking this handler; when absent, the path 74 + * falls through to `handleNotFound`. `_health` is not part of the atproto 75 + * XRPC spec, so callers opt in explicitly. 76 + */ 77 + handleHealthCheck?: HealthCheckHandler; 70 78 handleException?: ExceptionHandler; 71 79 handleSubscriptionException?: SubscriptionExceptionHandler; 72 80 websocket?: WebSocketAdapter; ··· 75 83 export class XRPCRouter { 76 84 #handlers: Record<string, InternalRouteData> = {}; 77 85 #handleNotFound: NotFoundHandler; 86 + #handleHealthCheck?: HealthCheckHandler; 78 87 #handleException: ExceptionHandler; 79 88 #handleSubscriptionException: SubscriptionExceptionHandler; 80 89 #websocket?: WebSocketAdapter; ··· 85 94 middlewares = [], 86 95 handleException = defaultExceptionHandler, 87 96 handleNotFound = defaultNotFoundHandler, 97 + handleHealthCheck, 88 98 handleSubscriptionException = defaultSubscriptionExceptionHandler, 89 99 websocket, 90 100 }: XRPCRouterOptions = {}) { ··· 93 103 this.fetch = (request) => runner(request); 94 104 this.#handleException = handleException; 95 105 this.#handleNotFound = handleNotFound; 106 + this.#handleHealthCheck = handleHealthCheck; 96 107 this.#handleSubscriptionException = handleSubscriptionException; 97 108 this.#websocket = websocket; 98 109 } ··· 106 117 } 107 118 108 119 const nsid = pathname.slice('/xrpc/'.length); 120 + 121 + if (nsid === '_health' && this.#handleHealthCheck !== undefined) { 122 + try { 123 + return await this.#handleHealthCheck(request); 124 + } catch (err) { 125 + if (request.signal.aborted) { 126 + return new Response(null, { status: 499 }); 127 + } 128 + 129 + return this.#handleException(err, request); 130 + } 131 + } 109 132 110 133 const route = this.#handlers[nsid]; 111 134 if (route === undefined) {