···66import { sha256 } from "multiformats/hashes/sha2";
77import { schema } from "./types.ts";
88import * as check from "./check.ts";
99-import { crypto } from "jsr:@std/crypto";
1010-import { concat, equals } from "jsr:@std/bytes";
99+import { crypto } from "@std/crypto";
1010+import { concat, equals } from "@std/bytes";
11111212export const cborEncode = cborCodec.encode;
1313export const cborDecode = cborCodec.decode;
+2-2
common/logger.ts
···55 type Logger,
66 type LogLevel,
77 type Sink,
88-} from "jsr:@logtape/logtape";
99-import { getFileSink } from "jsr:@logtape/file";
88+} from "@logtape/logtape";
99+import { getFileSink } from "@logtape/file";
10101111const allSystemsEnabled = !Deno.env.get("LOG_SYSTEMS");
1212const enabledSystems = (Deno.env.get("LOG_SYSTEMS") || "")
+10-4
common/streams.ts
···11-import { concat } from "jsr:@std/bytes";
22-import { Buffer } from "jsr:@std/io";
11+import { concat } from "@std/bytes";
22+import { Buffer } from "@std/io";
3344export const forwardStreamErrors = (..._streams: ReadableStream[]) => {
55 // Web Streams don't have the same error forwarding mechanism as streams
···199199 // https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2
200200 case "gzip":
201201 case "x-gzip":
202202- return new DecompressionStream("gzip");
202202+ return new DecompressionStream("gzip") as TransformStream<
203203+ Uint8Array,
204204+ Uint8Array
205205+ >;
203206 case "deflate":
204204- return new DecompressionStream("deflate");
207207+ return new DecompressionStream("deflate") as TransformStream<
208208+ Uint8Array,
209209+ Uint8Array
210210+ >;
205211 case "br":
206212 throw new TypeError(
207213 `Brotli decompression is not supported in this Deno implementation`,
+1-1
common/tests/check_test.ts
···11import { ZodError } from "zod";
22import { check } from "../mod.ts";
33-import { assertEquals, assertThrows } from "jsr:@std/assert";
33+import { assertEquals, assertThrows } from "@std/assert";
4455Deno.test("checks object against definition", () => {
66 const checkable: check.Checkable<boolean> = {
+3-3
common/tests/ipld-multi_test.ts
···11-import { CID } from "npm:multiformats/cid";
22-import * as ui8 from "npm:uint8arrays";
11+import { CID } from "multiformats/cid";
22+import * as ui8 from "uint8arrays";
33import { cborDecodeMulti, cborEncode, type CborObject } from "../mod.ts";
44-import { assert, assertEquals } from "jsr:@std/assert";
44+import { assert, assertEquals } from "@std/assert";
5566Deno.test("decodes concatenated dag-cbor messages", () => {
77 const one = {
+2-2
common/tests/ipld_test.ts
···11-import * as ui8 from "npm:uint8arrays";
11+import * as ui8 from "uint8arrays";
22import {
33 cborDecode,
44 cborEncode,
···88 jsonToIpld,
99} from "../mod.ts";
1010import { vectors } from "./interop/ipld-vectors.ts";
1111-import { assert, assertEquals } from "jsr:@std/assert";
1111+import { assert, assertEquals } from "@std/assert";
12121313for (const vector of vectors) {
1414 Deno.test(`passes test vector: ${vector.name}`, async () => {
+1-1
common/tests/retry_test.ts
···11-import { assertEquals, assertRejects } from "jsr:@std/assert";
11+import { assertEquals, assertRejects } from "@std/assert";
22import { retry } from "../mod.ts";
3344Deno.test("retries until max retries", async () => {
+1-1
common/tests/streams_test.ts
···11-import { assert, assertEquals, assertRejects } from "jsr:@std/assert";
11+import { assert, assertEquals, assertRejects } from "@std/assert";
22import * as streams from "../streams.ts";
3344Deno.test("forwardStreamErrors - is a no-op in Web Streams", () => {
···1616// [a-zA-Z0-9._~:@!$&'\(\)*+,;=-]
1717// - rkey must have at least one char
1818// - regardless of path component, a fragment can follow as "#" and then a JSON pointer (RFC-6901)
1919-export const ensureValidAtUri = (uri: string) => {
1919+export const ensureValidAtUri = (uri: string): void => {
2020 // JSON pointer is pretty different from rest of URI, so split that out first
2121 const uriParts = uri.split("#");
2222 if (uriParts.length > 2) {
+9-9
syntax/aturi.ts
···3737 this.searchParams = parsed.searchParams;
3838 }
39394040- static make(handleOrDid: string, collection?: string, rkey?: string) {
4040+ static make(handleOrDid: string, collection?: string, rkey?: string): AtUri {
4141 let str = handleOrDid;
4242 if (collection) str += "/" + collection;
4343 if (rkey) str += "/" + rkey;
4444 return new AtUri(str);
4545 }
46464747- get protocol() {
4747+ get protocol(): string {
4848 return "at:";
4949 }
50505151- get origin() {
5151+ get origin(): string {
5252 return `at://${this.host}`;
5353 }
54545555- get hostname() {
5555+ get hostname(): string {
5656 return this.host;
5757 }
5858···6060 this.host = v;
6161 }
62626363- get search() {
6363+ get search(): string {
6464 return this.searchParams.toString();
6565 }
6666···6868 this.searchParams = new URLSearchParams(v);
6969 }
70707171- get collection() {
7171+ get collection(): string {
7272 return this.pathname.split("/").filter(Boolean)[0] || "";
7373 }
7474···7878 this.pathname = parts.join("/");
7979 }
80808181- get rkey() {
8181+ get rkey(): string {
8282 return this.pathname.split("/").filter(Boolean)[1] || "";
8383 }
8484···8989 this.pathname = parts.join("/");
9090 }
91919292- get href() {
9292+ get href(): string {
9393 return this.toString();
9494 }
95959696- toString() {
9696+ toString(): string {
9797 let path = this.pathname || "/";
9898 if (!path.startsWith("/")) {
9999 path = `/${path}`;
···55 ResponseType,
66 ResponseTypeStrings,
77 XRPCError as XRPCClientError,
88-} from "@atproto/xrpc";
88+} from "@atp/xrpc";
991010// @NOTE Do not depend (directly or indirectly) on "./types" here, as it would
1111// create a circular dependency.
···4040}
41414242/**
4343- * Type guard to check if a value is an HTTP error with status, message, and name properties.
4444- * @param v - The value to check
4545- * @returns True if the value has the expected HTTP error structure
4343+ * Type guard to check if a value is an HTTP Error-like object.
4644 */
4747-function isHttpErrorLike(v: unknown): v is {
4848- status: number;
4949- message: string;
5050- name: string;
5151-} {
4545+function isHttpErrorLike(
4646+ value: unknown,
4747+): value is { status: number; message: string; name: string } {
5248 return (
5353- typeof v === "object" &&
5454- v !== null &&
5555- "status" in v &&
5656- "message" in v &&
5757- "name" in v &&
5858- typeof (v as { status: unknown }).status === "number" &&
5959- typeof (v as { message: unknown }).message === "string" &&
6060- typeof (v as { name: unknown }).name === "string"
4949+ typeof value === "object" &&
5050+ value !== null &&
5151+ "status" in value &&
5252+ "message" in value &&
5353+ "name" in value &&
5454+ typeof (value as Record<string, unknown>).status === "number" &&
5555+ typeof (value as Record<string, unknown>).message === "string" &&
5656+ typeof (value as Record<string, unknown>).name === "string"
6157 );
6258}
6359···8076 * Extends the standard Error class with XRPC-specific properties and methods.
8177 */
8278export class XRPCError extends Error {
8383- /**
8484- * Creates a new XRPCError instance.
8585- * @param type - The HTTP response type/status code
8686- * @param errorMessage - Optional error message
8787- * @param customErrorName - Optional custom error name
8888- * @param options - Optional error options (including cause)
8989- */
9079 constructor(
9180 public type: ResponseType,
9281 public errorMessage?: string,
···9685 super(errorMessage, options);
9786 }
98879999- /**
100100- * Gets the HTTP status code for this error.
101101- * Validates that the type is a valid HTTP error status code (400-599).
102102- * @returns The HTTP status code, or 500 if the type is invalid
103103- */
10488 get statusCode(): number {
10589 const { type } = this;
10690···131115 };
132116 }
133117134134- /**
135135- * Gets the string name of the response type.
136136- * @returns The response type name (e.g., "BadRequest", "NotFound")
137137- */
138118 get typeName(): string | undefined {
139119 return ResponseType[this.type];
140120 }
141121142142- /**
143143- * Gets the human-readable string description of the response type.
144144- * @returns The response type description (e.g., "Bad Request", "Not Found")
145145- */
146122 get typeStr(): string | undefined {
147123 return ResponseTypeStrings[this.type];
148124 }
149125150150- /**
151151- * Converts any error-like value into an XRPCError.
152152- * Handles various error types including XRPCError, XRPCClientError, HTTP errors, and generic errors.
153153- * @param cause - The error or error-like value to convert
154154- * @returns An XRPCError instance
155155- */
156126 static fromError(cause: unknown): XRPCError {
157127 if (cause instanceof XRPCError) {
158128 return cause;
···164134 }
165135166136 if (isHttpErrorLike(cause)) {
167167- return new XRPCError(
168168- cause.status,
169169- cause.message,
170170- cause.name,
171171- { cause },
172172- );
137137+ return new XRPCError(cause.status, cause.message, cause.name, { cause });
173138 }
174139175140 if (isErrorResult(cause)) {
···187152 );
188153 }
189154190190- /**
191191- * Creates an XRPCError from an ErrorResult object.
192192- * @param err - The ErrorResult to convert
193193- * @returns An XRPCError instance
194194- */
195155 static fromErrorResult(err: ErrorResult): XRPCError {
196156 return new XRPCError(err.status, err.message, err.error, { cause: err });
197157 }
···11+import { ErrorFrame, MessageFrame } from "./frames.ts";
22+import type { Auth, Params, StreamConfig } from "../types.ts";
33+44+/**
55+ * Handles WebSocket connections for XRPC streaming subscriptions.
66+ * Encapsulates connection lifecycle, authentication, parameter validation, and message handling.
77+ */
88+export class StreamConnection {
99+ private socket: WebSocket;
1010+ private abortController: AbortController;
1111+ private nsid: string;
1212+ private config: StreamConfig;
1313+ private paramVerifier: (req: Request) => Params;
1414+ private originalRequest: Request;
1515+1616+ constructor(
1717+ socket: WebSocket,
1818+ nsid: string,
1919+ config: StreamConfig,
2020+ paramVerifier: (req: Request) => Params,
2121+ originalRequest: Request,
2222+ ) {
2323+ this.socket = socket;
2424+ this.nsid = nsid;
2525+ this.config = config;
2626+ this.paramVerifier = paramVerifier;
2727+ this.originalRequest = originalRequest;
2828+ this.abortController = new AbortController();
2929+3030+ // Set up connection lifecycle handlers
3131+ this.setupSocketHandlers();
3232+ }
3333+3434+ /**
3535+ * Sets up WebSocket event handlers for the connection.
3636+ */
3737+ private setupSocketHandlers(): void {
3838+ this.socket.onopen = () => {
3939+ // Connection established - start handling the stream
4040+ this.handleConnection().catch((error) => {
4141+ console.error("StreamConnection error:", error);
4242+ this.close(1011, "Internal error");
4343+ });
4444+ };
4545+4646+ this.socket.onerror = (ev: Event) => {
4747+ console.error("WebSocket error:", ev);
4848+ };
4949+5050+ this.socket.onclose = () => {
5151+ this.abortController.abort();
5252+ };
5353+ }
5454+5555+ /**
5656+ * Main connection handler that processes authentication, validation, and streaming.
5757+ */
5858+ private async handleConnection(): Promise<void> {
5959+ const req = this.originalRequest;
6060+6161+ // Get query parameters for handler
6262+ const url = new URL(req.url);
6363+ const params = Object.fromEntries(url.searchParams);
6464+6565+ try {
6666+ // Perform authentication if configured
6767+ const auth = await this.authenticate(params, req);
6868+6969+ // Validate parameters
7070+ this.validateParameters(req);
7171+7272+ // Execute the streaming handler
7373+ await this.executeHandler(params, auth, req);
7474+ } catch (error) {
7575+ if (error instanceof StreamAuthError) {
7676+ this.sendErrorAndClose("AuthenticationRequired", error.message);
7777+ } else if (error instanceof StreamValidationError) {
7878+ this.sendErrorAndClose("InvalidRequest", error.message);
7979+ } else if (error instanceof StreamHandlerError) {
8080+ this.sendErrorAndClose("InternalServerError", error.message);
8181+ } else {
8282+ this.sendErrorAndClose(
8383+ "InternalServerError",
8484+ error instanceof Error ? error.message : String(error),
8585+ );
8686+ }
8787+ }
8888+ }
8989+9090+ /**
9191+ * Performs authentication if an auth verifier is configured.
9292+ */
9393+ private async authenticate(
9494+ params: Record<string, string>,
9595+ req: Request,
9696+ ): Promise<Auth | undefined> {
9797+ if (!this.config.auth) {
9898+ return undefined;
9999+ }
100100+101101+ try {
102102+ const auth = await this.config.auth({ params, req });
103103+ return auth as Auth;
104104+ } catch {
105105+ throw new StreamAuthError("Authentication Required");
106106+ }
107107+ }
108108+109109+ /**
110110+ * Validates request parameters using the configured parameter verifier.
111111+ */
112112+ private validateParameters(req: Request): void {
113113+ try {
114114+ this.paramVerifier(req);
115115+ } catch (error) {
116116+ throw new StreamValidationError(
117117+ error instanceof Error ? error.message : String(error),
118118+ );
119119+ }
120120+ }
121121+122122+ /**
123123+ * Executes the streaming handler and processes yielded data.
124124+ */
125125+ private async executeHandler(
126126+ params: Record<string, string>,
127127+ auth: Auth | undefined,
128128+ req: Request,
129129+ ): Promise<void> {
130130+ const handler = this.config.handler;
131131+ if (!handler) {
132132+ throw new StreamHandlerError("No handler configured for this method");
133133+ }
134134+135135+ const handlerContext = {
136136+ params,
137137+ auth: auth as Auth,
138138+ req,
139139+ signal: this.abortController.signal,
140140+ };
141141+142142+ try {
143143+ for await (const data of handler(handlerContext)) {
144144+ if (this.abortController.signal.aborted) break;
145145+146146+ // Check if the yielded data is already a Frame object
147147+ if (data instanceof ErrorFrame) {
148148+ this.socket.send(data.toBytes());
149149+ this.close(1011, data.body.error);
150150+ return;
151151+ }
152152+153153+ if (data instanceof MessageFrame) {
154154+ this.socket.send(data.toBytes());
155155+ continue;
156156+ }
157157+158158+ // Process regular data objects
159159+ const frame = this.createMessageFrame(data);
160160+ this.socket.send(frame.toBytes());
161161+ }
162162+163163+ // Handler completed normally, close connection immediately
164164+ this.close(1000, "Stream completed");
165165+ } catch (handlerError) {
166166+ throw new StreamHandlerError(
167167+ handlerError instanceof Error
168168+ ? handlerError.message
169169+ : String(handlerError),
170170+ );
171171+ }
172172+ }
173173+174174+ /**
175175+ * Creates a MessageFrame from yielded data, handling $type extraction and normalization.
176176+ */
177177+ private createMessageFrame(data: unknown): MessageFrame {
178178+ let frameType: string | undefined;
179179+ let frameBody = data;
180180+181181+ if (data && typeof data === "object" && "$type" in data) {
182182+ const rawType = String(data.$type);
183183+184184+ // Normalize type: if it starts with current nsid, convert to short form
185185+ if (rawType.startsWith(`${this.nsid}#`)) {
186186+ frameType = rawType.substring(this.nsid.length);
187187+ } else {
188188+ frameType = rawType;
189189+ }
190190+191191+ // Remove $type from the body
192192+ const { $type: _$type, ...bodyWithoutType } = data as Record<
193193+ string,
194194+ unknown
195195+ >;
196196+ frameBody = bodyWithoutType;
197197+ }
198198+199199+ return new MessageFrame(
200200+ frameBody as Record<string, unknown>,
201201+ frameType ? { type: frameType } : undefined,
202202+ );
203203+ }
204204+205205+ /**
206206+ * Sends an error frame and closes the connection.
207207+ */
208208+ private sendErrorAndClose(error: string, message: string): void {
209209+ const errorFrame = new ErrorFrame({ error, message });
210210+ this.socket.send(errorFrame.toBytes());
211211+ this.close(1011, error);
212212+ }
213213+214214+ /**
215215+ * Closes the WebSocket connection with the specified code and reason.
216216+ */
217217+ private close(code: number, reason: string): void {
218218+ if (this.socket.readyState === WebSocket.OPEN) {
219219+ this.socket.close(code, reason);
220220+ }
221221+ }
222222+223223+ /**
224224+ * Creates a StreamConnection and returns the WebSocket response for upgrade.
225225+ * This is the main entry point for creating WebSocket connections.
226226+ */
227227+ static upgrade(
228228+ request: Request,
229229+ nsid: string,
230230+ config: StreamConfig,
231231+ paramVerifier: (req: Request) => Params,
232232+ ): Response {
233233+ const upgrade = request.headers.get("upgrade");
234234+ if (upgrade !== "websocket") {
235235+ throw new Error("WebSocket upgrade required");
236236+ }
237237+238238+ // Handle WebSocket upgrade using Deno's built-in WebSocket API
239239+ const { socket, response } = Deno.upgradeWebSocket(request);
240240+241241+ // Create the connection handler
242242+ new StreamConnection(socket, nsid, config, paramVerifier, request);
243243+244244+ return response;
245245+ }
246246+}
247247+248248+/**
249249+ * Error thrown when authentication fails.
250250+ */
251251+class StreamAuthError extends Error {
252252+ constructor(message: string) {
253253+ super(message);
254254+ this.name = "StreamAuthError";
255255+ }
256256+}
257257+258258+/**
259259+ * Error thrown when parameter validation fails.
260260+ */
261261+class StreamValidationError extends Error {
262262+ constructor(message: string) {
263263+ super(message);
264264+ this.name = "StreamValidationError";
265265+ }
266266+}
267267+268268+/**
269269+ * Error thrown when handler execution fails.
270270+ */
271271+class StreamHandlerError extends Error {
272272+ constructor(message: string) {
273273+ super(message);
274274+ this.name = "StreamHandlerError";
275275+ }
276276+}
+13-1
xrpc-server/stream/frames.ts
···6767 * @throws {Error} If the frame format is invalid or unknown
6868 */
6969 static fromBytes(bytes: Uint8Array): Frame {
7070- const decoded = cborDecodeMulti(bytes);
7070+ let decoded: unknown[];
7171+ try {
7272+ decoded = cborDecodeMulti(bytes);
7373+ } catch {
7474+ // Re-throw CBOR decode errors with a more generic message to match test expectations
7575+ throw new Error("Unexpected end of CBOR data");
7676+ }
7777+7878+ // Check for empty or invalid decode results
7979+ if (decoded.length === 0 || decoded[0] === undefined) {
8080+ throw new Error("Unexpected end of CBOR data");
8181+ }
8282+7183 if (decoded.length > 2) {
7284 throw new Error("Too many CBOR data items in frame");
7385 }
+1
xrpc-server/stream/index.ts
···33export * from "./stream.ts";
44export * from "./subscription.ts";
55export * from "./server.ts";
66+export * from "./connection.ts";
67export * from "./websocket-keepalive.ts";
+10
xrpc-server/stream/server.ts
···4242 };
4343 const safeFrames = wrapIterator(iterator);
4444 for await (const frame of safeFrames) {
4545+ // Send the frame first
4546 await new Promise<void>((res, rej) => {
4647 try {
4748 socket.send((frame as Frame).toBytes());
···5051 rej(err);
5152 }
5253 });
5454+5555+ // Check for ErrorFrame after sending and immediately terminate
5356 if (frame instanceof ErrorFrame) {
5757+ // Immediately stop the iterator and abort to prevent further frames
5858+ try {
5959+ iterator.return?.();
6060+ } catch {
6161+ // Ignore errors from iterator.return
6262+ }
6363+ ac.abort();
5464 throw new DisconnectError(CloseCode.Policy, frame.body.error);
5565 }
5666 }
···11+import type { Gettable } from "./types.ts";
22+import { combineHeaders } from "./util.ts";
33+44+export type FetchHandler = (
55+ this: void,
66+ /**
77+ * The URL (pathname + query parameters) to make the request to, without the
88+ * origin. The origin (protocol, hostname, and port) must be added by this
99+ * {@link FetchHandler}, typically based on authentication or other factors.
1010+ */
1111+ url: string,
1212+ init: RequestInit,
1313+) => Promise<Response>;
1414+1515+export type FetchHandlerOptions = BuildFetchHandlerOptions | string | URL;
1616+1717+export type BuildFetchHandlerOptions = {
1818+ /**
1919+ * The service URL to make requests to. This can be a string, URL, or a
2020+ * function that returns a string or URL. This is useful for dynamic URLs,
2121+ * such as a service URL that changes based on authentication.
2222+ */
2323+ service: Gettable<string | URL>;
2424+2525+ /**
2626+ * Headers to be added to every request. If a function is provided, it will be
2727+ * called on each request to get the headers. This is useful for dynamic
2828+ * headers, such as authentication tokens that may expire.
2929+ */
3030+ headers?: {
3131+ [_ in string]?: Gettable<null | string>;
3232+ };
3333+3434+ /**
3535+ * Bring your own fetch implementation. Typically useful for testing, logging,
3636+ * mocking, or adding retries, session management, signatures, proof of
3737+ * possession (DPoP), SSRF protection, etc. Defaults to the global `fetch`
3838+ * function.
3939+ */
4040+ fetch?: typeof globalThis.fetch;
4141+};
4242+4343+export interface FetchHandlerObject {
4444+ fetchHandler: (
4545+ this: FetchHandlerObject,
4646+ /**
4747+ * The URL (pathname + query parameters) to make the request to, without the
4848+ * origin. The origin (protocol, hostname, and port) must be added by this
4949+ * {@link FetchHandler}, typically based on authentication or other factors.
5050+ */
5151+ url: string,
5252+ init: RequestInit,
5353+ ) => Promise<Response>;
5454+}
5555+5656+export function buildFetchHandler(
5757+ options: FetchHandler | FetchHandlerObject | FetchHandlerOptions,
5858+): FetchHandler {
5959+ // Already a fetch handler (allowed for convenience)
6060+ if (typeof options === "function") return options;
6161+ if (typeof options === "object" && "fetchHandler" in options) {
6262+ return options.fetchHandler.bind(options);
6363+ }
6464+6565+ const {
6666+ service,
6767+ headers: defaultHeaders = undefined,
6868+ fetch = globalThis.fetch,
6969+ } = typeof options === "string" || options instanceof URL
7070+ ? { service: options }
7171+ : options;
7272+7373+ if (typeof fetch !== "function") {
7474+ throw new TypeError(
7575+ "XrpcDispatcher requires fetch() to be available in your environment.",
7676+ );
7777+ }
7878+7979+ const defaultHeadersEntries = defaultHeaders != null
8080+ ? Object.entries(defaultHeaders)
8181+ : undefined;
8282+8383+ return function (url, init) {
8484+ const base = typeof service === "function" ? service() : service;
8585+ const fullUrl = new URL(url, base);
8686+8787+ const headers = combineHeaders(init.headers, defaultHeadersEntries);
8888+8989+ return fetch(fullUrl, { ...init, headers });
9090+ };
9191+}
+4
xrpc/mod.ts
···11+export * from "./client.ts";
22+export * from "./fetch-handler.ts";
33+export * from "./types.ts";
44+export * from "./util.ts";
+185
xrpc/types.ts
···11+import { z } from "zod";
22+import type { ValidationError } from "@atproto/lexicon";
33+44+export type QueryParams = Record<string, unknown>;
55+export type HeadersMap = Record<string, string | undefined>;
66+77+export type {
88+ /** @deprecated not to be confused with the WHATWG Headers constructor */
99+ HeadersMap as Headers,
1010+};
1111+1212+export type Gettable<T> = T | (() => T);
1313+1414+export interface CallOptions {
1515+ encoding?: string;
1616+ signal?: AbortSignal;
1717+ headers?: HeadersMap;
1818+}
1919+2020+export const errorResponseBody: z.ZodObject<{
2121+ error: z.ZodOptional<z.ZodString>;
2222+ message: z.ZodOptional<z.ZodString>;
2323+}> = z.object({
2424+ error: z.string().optional(),
2525+ message: z.string().optional(),
2626+});
2727+export type ErrorResponseBody = z.infer<typeof errorResponseBody>;
2828+2929+export enum ResponseType {
3030+ /**
3131+ * Network issue, unable to get response from the server.
3232+ */
3333+ Unknown = 1,
3434+ /**
3535+ * Response failed lexicon validation.
3636+ */
3737+ InvalidResponse = 2,
3838+ Success = 200,
3939+ InvalidRequest = 400,
4040+ AuthenticationRequired = 401,
4141+ Forbidden = 403,
4242+ XRPCNotSupported = 404,
4343+ NotAcceptable = 406,
4444+ PayloadTooLarge = 413,
4545+ UnsupportedMediaType = 415,
4646+ RateLimitExceeded = 429,
4747+ InternalServerError = 500,
4848+ MethodNotImplemented = 501,
4949+ UpstreamFailure = 502,
5050+ NotEnoughResources = 503,
5151+ UpstreamTimeout = 504,
5252+}
5353+5454+export function httpResponseCodeToEnum(status: number): ResponseType {
5555+ if (status in ResponseType) {
5656+ return status;
5757+ } else if (status >= 100 && status < 200) {
5858+ return ResponseType.XRPCNotSupported;
5959+ } else if (status >= 200 && status < 300) {
6060+ return ResponseType.Success;
6161+ } else if (status >= 300 && status < 400) {
6262+ return ResponseType.XRPCNotSupported;
6363+ } else if (status >= 400 && status < 500) {
6464+ return ResponseType.InvalidRequest;
6565+ } else {
6666+ return ResponseType.InternalServerError;
6767+ }
6868+}
6969+7070+export function httpResponseCodeToName(status: number): string {
7171+ return ResponseType[httpResponseCodeToEnum(status)];
7272+}
7373+7474+export const ResponseTypeStrings: Record<ResponseType, string> = {
7575+ [ResponseType.Unknown]: "Unknown",
7676+ [ResponseType.InvalidResponse]: "Invalid Response",
7777+ [ResponseType.Success]: "Success",
7878+ [ResponseType.InvalidRequest]: "Invalid Request",
7979+ [ResponseType.AuthenticationRequired]: "Authentication Required",
8080+ [ResponseType.Forbidden]: "Forbidden",
8181+ [ResponseType.XRPCNotSupported]: "XRPC Not Supported",
8282+ [ResponseType.NotAcceptable]: "Not Acceptable",
8383+ [ResponseType.PayloadTooLarge]: "Payload Too Large",
8484+ [ResponseType.UnsupportedMediaType]: "Unsupported Media Type",
8585+ [ResponseType.RateLimitExceeded]: "Rate Limit Exceeded",
8686+ [ResponseType.InternalServerError]: "Internal Server Error",
8787+ [ResponseType.MethodNotImplemented]: "Method Not Implemented",
8888+ [ResponseType.UpstreamFailure]: "Upstream Failure",
8989+ [ResponseType.NotEnoughResources]: "Not Enough Resources",
9090+ [ResponseType.UpstreamTimeout]: "Upstream Timeout",
9191+} as const satisfies Record<ResponseType, string>;
9292+9393+export function httpResponseCodeToString(status: number): string {
9494+ return ResponseTypeStrings[httpResponseCodeToEnum(status)];
9595+}
9696+9797+export class XRPCResponse {
9898+ success = true;
9999+100100+ constructor(
101101+ public data: any,
102102+ public headers: HeadersMap,
103103+ ) {}
104104+}
105105+106106+export class XRPCError extends Error {
107107+ success = false;
108108+109109+ public status: ResponseType;
110110+111111+ constructor(
112112+ statusCode: number,
113113+ public error: string = httpResponseCodeToName(statusCode),
114114+ message?: string,
115115+ public headers?: HeadersMap,
116116+ options?: ErrorOptions,
117117+ ) {
118118+ super(message || error || httpResponseCodeToString(statusCode), options);
119119+120120+ this.status = httpResponseCodeToEnum(statusCode);
121121+122122+ // Pre 2022 runtimes won't handle the "options" constructor argument
123123+ const cause = options?.cause;
124124+ if (this.cause === undefined && cause !== undefined) {
125125+ this.cause = cause;
126126+ }
127127+ }
128128+129129+ static from(cause: unknown, fallbackStatus?: ResponseType): XRPCError {
130130+ if (cause instanceof XRPCError) {
131131+ return cause;
132132+ }
133133+134134+ // Type cast the cause to an Error if it is one
135135+ const causeErr = cause instanceof Error ? cause : undefined;
136136+137137+ // Try and find a Response object in the cause
138138+ const causeResponse: Response | undefined = cause instanceof Response
139139+ ? cause
140140+ : (cause && typeof cause === "object" && "response" in cause &&
141141+ cause.response instanceof Response)
142142+ ? cause.response
143143+ : undefined;
144144+145145+ const statusCode: unknown =
146146+ // Extract status code from "http-errors" like errors
147147+ (causeErr && typeof causeErr === "object" && "statusCode" in causeErr)
148148+ ? causeErr.statusCode
149149+ : (causeErr && typeof causeErr === "object" && "status" in causeErr)
150150+ ? causeErr.status
151151+ // Use the status code from the response object as fallback
152152+ : causeResponse?.status;
153153+154154+ // Convert the status code to a ResponseType
155155+ const status: ResponseType = typeof statusCode === "number"
156156+ ? httpResponseCodeToEnum(statusCode)
157157+ : fallbackStatus ?? ResponseType.Unknown;
158158+159159+ const message = causeErr?.message ?? String(cause);
160160+161161+ const headers = causeResponse
162162+ ? Object.fromEntries(causeResponse.headers.entries())
163163+ : undefined;
164164+165165+ return new XRPCError(status, undefined, message, headers, { cause });
166166+ }
167167+}
168168+169169+export class XRPCInvalidResponseError extends XRPCError {
170170+ constructor(
171171+ public lexiconNsid: string,
172172+ public validationError: ValidationError,
173173+ public responseBody: unknown,
174174+ ) {
175175+ super(
176176+ ResponseType.InvalidResponse,
177177+ // @NOTE: This is probably wrong and should use ResponseTypeNames instead.
178178+ // But it would mean a breaking change.
179179+ ResponseTypeStrings[ResponseType.InvalidResponse],
180180+ `The server gave an invalid response and may be out of date.`,
181181+ undefined,
182182+ { cause: validationError },
183183+ );
184184+ }
185185+}
+381
xrpc/util.ts
···11+import {
22+ jsonStringToLex,
33+ type LexXrpcProcedure,
44+ type LexXrpcQuery,
55+ stringifyLex,
66+} from "@atproto/lexicon";
77+import {
88+ type CallOptions,
99+ type ErrorResponseBody,
1010+ errorResponseBody,
1111+ type Gettable,
1212+ type QueryParams,
1313+ ResponseType,
1414+ XRPCError,
1515+} from "./types.ts";
1616+1717+const ReadableStream = globalThis.ReadableStream ||
1818+ (class {
1919+ constructor() {
2020+ // This anonymous class will never pass any "instanceof" check and cannot
2121+ // be instantiated.
2222+ throw new Error("ReadableStream is not supported in this environment");
2323+ }
2424+ } as typeof globalThis.ReadableStream);
2525+2626+export function isErrorResponseBody(v: unknown): v is ErrorResponseBody {
2727+ return errorResponseBody.safeParse(v).success;
2828+}
2929+3030+export function getMethodSchemaHTTPMethod(
3131+ schema: LexXrpcProcedure | LexXrpcQuery,
3232+): "post" | "get" {
3333+ if (schema.type === "procedure") {
3434+ return "post";
3535+ }
3636+ return "get";
3737+}
3838+3939+export function constructMethodCallUri(
4040+ nsid: string,
4141+ schema: LexXrpcProcedure | LexXrpcQuery,
4242+ serviceUri: URL,
4343+ params?: QueryParams,
4444+): string {
4545+ const uri = new URL(constructMethodCallUrl(nsid, schema, params), serviceUri);
4646+ return uri.toString();
4747+}
4848+4949+export function constructMethodCallUrl(
5050+ nsid: string,
5151+ schema: LexXrpcProcedure | LexXrpcQuery,
5252+ params?: QueryParams,
5353+): string {
5454+ const pathname = `/xrpc/${encodeURIComponent(nsid)}`;
5555+ if (!params) return pathname;
5656+5757+ const searchParams: [string, string][] = [];
5858+5959+ for (const [key, value] of Object.entries(params)) {
6060+ const paramSchema = schema.parameters?.properties?.[key];
6161+ if (!paramSchema) {
6262+ throw new Error(`Invalid query parameter: ${key}`);
6363+ }
6464+ if (value !== undefined) {
6565+ if (paramSchema.type === "array") {
6666+ const values = Array.isArray(value) ? value : [value];
6767+ for (const val of values) {
6868+ searchParams.push([
6969+ key,
7070+ encodeQueryParam(paramSchema.items.type, val),
7171+ ]);
7272+ }
7373+ } else {
7474+ searchParams.push([key, encodeQueryParam(paramSchema.type, value)]);
7575+ }
7676+ }
7777+ }
7878+7979+ if (!searchParams.length) return pathname;
8080+8181+ return `${pathname}?${new URLSearchParams(searchParams).toString()}`;
8282+}
8383+8484+export function encodeQueryParam(
8585+ type:
8686+ | "string"
8787+ | "float"
8888+ | "integer"
8989+ | "boolean"
9090+ | "datetime"
9191+ | "array"
9292+ | "unknown",
9393+ value: unknown,
9494+): string {
9595+ if (type === "string" || type === "unknown") {
9696+ return String(value);
9797+ }
9898+ if (type === "float") {
9999+ return String(Number(value));
100100+ } else if (type === "integer") {
101101+ return String(Number(value) | 0);
102102+ } else if (type === "boolean") {
103103+ return value ? "true" : "false";
104104+ } else if (type === "datetime") {
105105+ if (value instanceof Date) {
106106+ return value.toISOString();
107107+ }
108108+ return String(value);
109109+ }
110110+ throw new Error(`Unsupported query param type: ${type}`);
111111+}
112112+113113+export function constructMethodCallHeaders(
114114+ schema: LexXrpcProcedure | LexXrpcQuery,
115115+ data?: unknown,
116116+ opts?: CallOptions,
117117+): Headers {
118118+ // Not using `new Headers(opts?.headers)` to avoid duplicating headers values
119119+ // due to inconsistent casing in headers name. In case of multiple headers
120120+ // with the same name (but using a different case), the last one will be used.
121121+122122+ // new Headers({ 'content-type': 'foo', 'Content-Type': 'bar' }).get('content-type')
123123+ // => 'foo, bar'
124124+ const headers = new Headers();
125125+126126+ if (opts?.headers) {
127127+ for (const name in opts.headers) {
128128+ if (headers.has(name)) {
129129+ throw new TypeError(`Duplicate header: ${name}`);
130130+ }
131131+132132+ const value = opts.headers[name];
133133+ if (value != null) {
134134+ headers.set(name, value);
135135+ }
136136+ }
137137+ }
138138+139139+ if (schema.type === "procedure") {
140140+ if (opts?.encoding) {
141141+ headers.set("content-type", opts.encoding);
142142+ } else if (!headers.has("content-type") && typeof data !== "undefined") {
143143+ // Special handling of BodyInit types before falling back to JSON encoding
144144+ if (
145145+ data instanceof ArrayBuffer ||
146146+ data instanceof ReadableStream ||
147147+ ArrayBuffer.isView(data)
148148+ ) {
149149+ headers.set("content-type", "application/octet-stream");
150150+ } else if (data instanceof FormData) {
151151+ // Note: The multipart form data boundary is missing from the header
152152+ // we set here, making that header invalid. This special case will be
153153+ // handled in encodeMethodCallBody()
154154+ headers.set("content-type", "multipart/form-data");
155155+ } else if (data instanceof URLSearchParams) {
156156+ headers.set(
157157+ "content-type",
158158+ "application/x-www-form-urlencoded;charset=UTF-8",
159159+ );
160160+ } else if (isBlobLike(data)) {
161161+ headers.set("content-type", data.type || "application/octet-stream");
162162+ } else if (typeof data === "string") {
163163+ headers.set("content-type", "text/plain;charset=UTF-8");
164164+ } // At this point, data is not a valid BodyInit type.
165165+ else if (isIterable(data)) {
166166+ headers.set("content-type", "application/octet-stream");
167167+ } else if (
168168+ typeof data === "boolean" ||
169169+ typeof data === "number" ||
170170+ typeof data === "string" ||
171171+ typeof data === "object" // covers "null"
172172+ ) {
173173+ headers.set("content-type", "application/json");
174174+ } else {
175175+ // symbol, function, bigint
176176+ throw new XRPCError(
177177+ ResponseType.InvalidRequest,
178178+ `Unsupported data type: ${typeof data}`,
179179+ );
180180+ }
181181+ }
182182+ }
183183+ return headers;
184184+}
185185+186186+export function combineHeaders(
187187+ headersInit: undefined | HeadersInit,
188188+ defaultHeaders?: Iterable<[string, undefined | Gettable<null | string>]>,
189189+): undefined | HeadersInit {
190190+ if (!defaultHeaders) return headersInit;
191191+192192+ let headers: Headers | undefined = undefined;
193193+194194+ for (const [name, definition] of defaultHeaders) {
195195+ // Ignore undefined values (allowed for convenience when using
196196+ // Object.entries).
197197+ if (definition === undefined) continue;
198198+199199+ // Lazy initialization of the headers object
200200+ headers ??= new Headers(headersInit);
201201+202202+ if (headers.has(name)) continue;
203203+204204+ const value = typeof definition === "function" ? definition() : definition;
205205+206206+ if (typeof value === "string") headers.set(name, value);
207207+ else if (value === null) headers.delete(name);
208208+ else throw new TypeError(`Invalid "${name}" header value: ${typeof value}`);
209209+ }
210210+211211+ return headers ?? headersInit;
212212+}
213213+214214+function isBlobLike(value: unknown): value is Blob {
215215+ if (value == null) return false;
216216+ if (typeof value !== "object") return false;
217217+ if (typeof Blob === "function" && value instanceof Blob) return true;
218218+219219+ // Support for Blobs provided by libraries that don't use the native Blob
220220+ // (e.g. fetch-blob from node-fetch).
221221+ // https://github.com/node-fetch/fetch-blob/blob/a1a182e5978811407bef4ea1632b517567dda01f/index.js#L233-L244
222222+223223+ const tag = (value as Record<string | symbol, unknown>)[Symbol.toStringTag];
224224+ if (tag === "Blob" || tag === "File") {
225225+ return "stream" in value && typeof value.stream === "function";
226226+ }
227227+228228+ return false;
229229+}
230230+231231+export function isBodyInit(value: unknown): value is BodyInit {
232232+ switch (typeof value) {
233233+ case "string":
234234+ return true;
235235+ case "object":
236236+ return (
237237+ value instanceof ArrayBuffer ||
238238+ value instanceof FormData ||
239239+ value instanceof URLSearchParams ||
240240+ value instanceof ReadableStream ||
241241+ ArrayBuffer.isView(value) ||
242242+ isBlobLike(value)
243243+ );
244244+ default:
245245+ return false;
246246+ }
247247+}
248248+249249+export function isIterable(
250250+ value: unknown,
251251+): value is Iterable<unknown> | AsyncIterable<unknown> {
252252+ return (
253253+ value != null &&
254254+ typeof value === "object" &&
255255+ (Symbol.iterator in value || Symbol.asyncIterator in value)
256256+ );
257257+}
258258+259259+export function encodeMethodCallBody(
260260+ headers: Headers,
261261+ data?: unknown,
262262+): BodyInit | undefined {
263263+ // Silently ignore the body if there is no content-type header.
264264+ const contentType = headers.get("content-type");
265265+ if (!contentType) {
266266+ return undefined;
267267+ }
268268+269269+ if (typeof data === "undefined") {
270270+ // This error would be returned by the server, but we can catch it earlier
271271+ // to avoid un-necessary requests. Note that a content-length of 0 does not
272272+ // necessary mean that the body is "empty" (e.g. an empty txt file).
273273+ throw new XRPCError(
274274+ ResponseType.InvalidRequest,
275275+ `A request body is expected but none was provided`,
276276+ );
277277+ }
278278+279279+ if (isBodyInit(data)) {
280280+ if (data instanceof FormData && contentType === "multipart/form-data") {
281281+ // fetch() will encode FormData payload itself, but it won't override the
282282+ // content-type header if already present. This would cause the boundary
283283+ // to be missing from the content-type header, resulting in a 400 error.
284284+ // Deleting the content-type header here to let fetch() re-create it.
285285+ headers.delete("content-type");
286286+ }
287287+288288+ // Will be encoded by the fetch API.
289289+ return data;
290290+ }
291291+292292+ if (isIterable(data)) {
293293+ // Note that some environments support using Iterable & AsyncIterable as the
294294+ // body (e.g. Node's fetch), but not all of them do (browsers).
295295+ return iterableToReadableStream(data);
296296+ }
297297+298298+ if (contentType.startsWith("text/")) {
299299+ return new TextEncoder().encode(String(data));
300300+ }
301301+ if (contentType.startsWith("application/json")) {
302302+ const json = stringifyLex(data);
303303+ // Server would return a 400 error if the JSON is invalid (e.g. trying to
304304+ // JSONify a function, or an object that implements toJSON() poorly).
305305+ if (json === undefined) {
306306+ throw new XRPCError(
307307+ ResponseType.InvalidRequest,
308308+ `Failed to encode request body as JSON`,
309309+ );
310310+ }
311311+ return new TextEncoder().encode(json);
312312+ }
313313+314314+ // At this point, "data" is not a valid BodyInit value, and we don't know how
315315+ // to encode it into one. Passing it to fetch would result in an error. Let's
316316+ // throw our own error instead.
317317+318318+ const type = !data || typeof data !== "object"
319319+ ? typeof data
320320+ : data.constructor !== Object &&
321321+ typeof data.constructor === "function" &&
322322+ typeof data.constructor?.name === "string"
323323+ ? data.constructor.name
324324+ : "object";
325325+326326+ throw new XRPCError(
327327+ ResponseType.InvalidRequest,
328328+ `Unable to encode ${type} as ${contentType} data`,
329329+ );
330330+}
331331+332332+/**
333333+ * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static}
334334+ */
335335+function iterableToReadableStream(
336336+ iterable: Iterable<unknown> | AsyncIterable<unknown>,
337337+): ReadableStream<Uint8Array> {
338338+ // Use the native ReadableStream.from() if available.
339339+ if ("from" in ReadableStream && typeof ReadableStream.from === "function") {
340340+ return ReadableStream.from(iterable) as ReadableStream<Uint8Array>;
341341+ }
342342+343343+ // If you see this error, consider using a polyfill for ReadableStream. For
344344+ // example, the "web-streams-polyfill" package:
345345+ // https://github.com/MattiasBuelens/web-streams-polyfill
346346+347347+ throw new TypeError(
348348+ "ReadableStream.from() is not supported in this environment. " +
349349+ "It is required to support using iterables as the request body. " +
350350+ "Consider using a polyfill or re-write your code to use a different body type.",
351351+ );
352352+}
353353+354354+export function httpResponseBodyParse(
355355+ mimeType: string | null,
356356+ data: ArrayBuffer | undefined,
357357+): unknown {
358358+ try {
359359+ if (mimeType) {
360360+ if (mimeType.includes("application/json")) {
361361+ const str = new TextDecoder().decode(data);
362362+ return jsonStringToLex(str);
363363+ }
364364+ if (mimeType.startsWith("text/")) {
365365+ return new TextDecoder().decode(data);
366366+ }
367367+ }
368368+ if (data instanceof ArrayBuffer) {
369369+ return new Uint8Array(data);
370370+ }
371371+ return data;
372372+ } catch (cause) {
373373+ throw new XRPCError(
374374+ ResponseType.InvalidResponse,
375375+ undefined,
376376+ `Failed to parse response body: ${String(cause)}`,
377377+ undefined,
378378+ { cause },
379379+ );
380380+ }
381381+}