@atproto/xrpc
Advanced tools
Comparing version 0.5.0 to 0.6.0-rc.0
# @atproto/xrpc | ||
## 0.6.0-rc.0 | ||
### Minor Changes | ||
- [#2483](https://github.com/bluesky-social/atproto/pull/2483) [`2ded0156b`](https://github.com/bluesky-social/atproto/commit/2ded0156b9adf33b9cce66583a375bff922d383b) Thanks [@matthieusieben](https://github.com/matthieusieben)! - **New Features**: | ||
1. Improved Separation of Concerns: We've restructured the XRPC HTTP call | ||
dispatcher into a distinct class. This means cleaner code organization and | ||
better clarity on responsibilities. | ||
2. Enhanced Evolutivity: With this refactor, the XRPC client is now more | ||
adaptable to various use cases. You can easily extend and customize the | ||
dispatcher perform session management, retries, and more. | ||
**Compatibility**: | ||
Most of the changes introduced in this version are backward-compatible. However, | ||
there are a couple of breaking changes you should be aware of: | ||
- Customizing `fetchHandler`: The ability to customize the fetchHandler on the | ||
XRPC Client and AtpAgent classes has been modified. Please review your code if | ||
you rely on custom fetch handlers. | ||
- Managing Sessions: Previously, you had the ability to manage sessions directly | ||
through AtpAgent instances. Now, session management must be handled through a | ||
dedicated `SessionManager` instance. If you were making authenticated | ||
requests, you'll need to update your code to use explicit session management. | ||
- The `fetch()` method, as well as WhatWG compliant `Request` and `Headers` | ||
constructors, must be globally available in your environment. | ||
- [#2483](https://github.com/bluesky-social/atproto/pull/2483) [`2ded0156b`](https://github.com/bluesky-social/atproto/commit/2ded0156b9adf33b9cce66583a375bff922d383b) Thanks [@matthieusieben](https://github.com/matthieusieben)! - Add the ability to use `fetch()` compatible `BodyInit` body when making XRPC calls. | ||
### Patch Changes | ||
- Updated dependencies [[`2ded0156b`](https://github.com/bluesky-social/atproto/commit/2ded0156b9adf33b9cce66583a375bff922d383b), [`2ded0156b`](https://github.com/bluesky-social/atproto/commit/2ded0156b9adf33b9cce66583a375bff922d383b)]: | ||
- @atproto/lexicon@0.4.1-rc.0 | ||
## 0.5.0 | ||
@@ -4,0 +39,0 @@ |
import { LexiconDoc, Lexicons } from '@atproto/lexicon'; | ||
import { FetchHandler, FetchHandlerResponse, Headers, CallOptions, QueryParams, XRPCResponse } from './types'; | ||
import { CallOptions, Gettable, QueryParams } from './types'; | ||
import { XrpcClient } from './xrpc-client'; | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
export declare class Client { | ||
fetch: FetchHandler; | ||
/** @deprecated */ | ||
get fetch(): never; | ||
/** @deprecated */ | ||
set fetch(_: never); | ||
lex: Lexicons; | ||
call(serviceUri: string | URL, methodNsid: string, params?: QueryParams, data?: unknown, opts?: CallOptions): Promise<XRPCResponse>; | ||
call(serviceUri: string | URL, methodNsid: string, params?: QueryParams, data?: BodyInit | null, opts?: CallOptions): Promise<import("./types").XRPCResponse>; | ||
service(serviceUri: string | URL): ServiceClient; | ||
@@ -12,12 +17,11 @@ addLexicon(doc: LexiconDoc): void; | ||
} | ||
export declare class ServiceClient { | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
export declare class ServiceClient extends XrpcClient { | ||
baseClient: Client; | ||
uri: URL; | ||
headers: Record<string, string>; | ||
protected headers: Map<string, Gettable<string | null>>; | ||
constructor(baseClient: Client, serviceUri: string | URL); | ||
setHeader(key: string, value: string): void; | ||
setHeader(key: string, value: Gettable<null | string>): void; | ||
unsetHeader(key: string): void; | ||
call(methodNsid: string, params?: QueryParams, data?: unknown, opts?: CallOptions): Promise<XRPCResponse>; | ||
} | ||
export declare function defaultFetchHandler(httpUri: string, httpMethod: string, httpHeaders: Headers, httpReqBody: unknown): Promise<FetchHandlerResponse>; | ||
//# sourceMappingURL=client.d.ts.map |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.defaultFetchHandler = exports.ServiceClient = exports.Client = void 0; | ||
exports.ServiceClient = exports.Client = void 0; | ||
const lexicon_1 = require("@atproto/lexicon"); | ||
const xrpc_client_1 = require("./xrpc-client"); | ||
const util_1 = require("./util"); | ||
const types_1 = require("./types"); | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
class Client { | ||
constructor() { | ||
Object.defineProperty(this, "fetch", { | ||
enumerable: true, | ||
configurable: true, | ||
writable: true, | ||
value: defaultFetchHandler | ||
}); | ||
Object.defineProperty(this, "lex", { | ||
@@ -22,2 +17,10 @@ enumerable: true, | ||
} | ||
/** @deprecated */ | ||
get fetch() { | ||
throw new Error('Client.fetch is no longer supported. Use an XrpcClient instead.'); | ||
} | ||
/** @deprecated */ | ||
set fetch(_) { | ||
throw new Error('Client.fetch is no longer supported. Use an XrpcClient instead.'); | ||
} | ||
// method calls | ||
@@ -46,4 +49,9 @@ // | ||
exports.Client = Client; | ||
class ServiceClient { | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
class ServiceClient extends xrpc_client_1.XrpcClient { | ||
constructor(baseClient, serviceUri) { | ||
super(async (input, init) => { | ||
const headers = (0, util_1.combineHeaders)(init.headers, this.headers); | ||
return fetch(new URL(input, this.uri), { ...init, headers }); | ||
}, baseClient.lex); | ||
Object.defineProperty(this, "baseClient", { | ||
@@ -53,3 +61,3 @@ enumerable: true, | ||
writable: true, | ||
value: void 0 | ||
value: baseClient | ||
}); | ||
@@ -66,81 +74,14 @@ Object.defineProperty(this, "uri", { | ||
writable: true, | ||
value: {} | ||
value: new Map() | ||
}); | ||
this.baseClient = baseClient; | ||
this.uri = typeof serviceUri === 'string' ? new URL(serviceUri) : serviceUri; | ||
} | ||
setHeader(key, value) { | ||
this.headers[key] = value; | ||
this.headers.set(key.toLowerCase(), value); | ||
} | ||
unsetHeader(key) { | ||
delete this.headers[key]; | ||
this.headers.delete(key.toLowerCase()); | ||
} | ||
async call(methodNsid, params, data, opts) { | ||
const def = this.baseClient.lex.getDefOrThrow(methodNsid); | ||
if (!def || (def.type !== 'query' && def.type !== 'procedure')) { | ||
throw new Error(`Invalid lexicon: ${methodNsid}. Must be a query or procedure.`); | ||
} | ||
const httpMethod = (0, util_1.getMethodSchemaHTTPMethod)(def); | ||
const httpUri = (0, util_1.constructMethodCallUri)(methodNsid, def, this.uri, params); | ||
const httpHeaders = (0, util_1.constructMethodCallHeaders)(def, data, { | ||
headers: { | ||
...this.headers, | ||
...opts?.headers, | ||
}, | ||
encoding: opts?.encoding, | ||
}); | ||
const res = await this.baseClient.fetch(httpUri, httpMethod, httpHeaders, data); | ||
const resCode = (0, util_1.httpResponseCodeToEnum)(res.status); | ||
if (resCode === types_1.ResponseType.Success) { | ||
try { | ||
this.baseClient.lex.assertValidXrpcOutput(methodNsid, res.body); | ||
} | ||
catch (e) { | ||
if (e instanceof lexicon_1.ValidationError) { | ||
throw new types_1.XRPCInvalidResponseError(methodNsid, e, res.body); | ||
} | ||
else { | ||
throw e; | ||
} | ||
} | ||
return new types_1.XRPCResponse(res.body, res.headers); | ||
} | ||
else { | ||
if (res.body && isErrorResponseBody(res.body)) { | ||
throw new types_1.XRPCError(resCode, res.body.error, res.body.message, res.headers); | ||
} | ||
else { | ||
throw new types_1.XRPCError(resCode); | ||
} | ||
} | ||
} | ||
} | ||
exports.ServiceClient = ServiceClient; | ||
async function defaultFetchHandler(httpUri, httpMethod, httpHeaders, httpReqBody) { | ||
try { | ||
// The duplex field is now required for streaming bodies, but not yet reflected | ||
// anywhere in docs or types. See whatwg/fetch#1438, nodejs/node#46221. | ||
const headers = (0, util_1.normalizeHeaders)(httpHeaders); | ||
const reqInit = { | ||
method: httpMethod, | ||
headers, | ||
body: (0, util_1.encodeMethodCallBody)(headers, httpReqBody), | ||
duplex: 'half', | ||
}; | ||
const res = await fetch(httpUri, reqInit); | ||
const resBody = await res.arrayBuffer(); | ||
return { | ||
status: res.status, | ||
headers: Object.fromEntries(res.headers.entries()), | ||
body: (0, util_1.httpResponseBodyParse)(res.headers.get('content-type'), resBody), | ||
}; | ||
} | ||
catch (e) { | ||
throw new types_1.XRPCError(types_1.ResponseType.Unknown, String(e)); | ||
} | ||
} | ||
exports.defaultFetchHandler = defaultFetchHandler; | ||
function isErrorResponseBody(v) { | ||
return types_1.errorResponseBody.safeParse(v).success; | ||
} | ||
//# sourceMappingURL=client.js.map |
@@ -0,6 +1,10 @@ | ||
export * from './client'; | ||
export * from './fetch-handler'; | ||
export * from './types'; | ||
export * from './client'; | ||
export * from './util'; | ||
export * from './xrpc-client'; | ||
import { Client } from './client'; | ||
/** @deprecated create a local {@link XrpcClient} instance instead */ | ||
declare const defaultInst: Client; | ||
export default defaultInst; | ||
//# sourceMappingURL=index.d.ts.map |
@@ -17,7 +17,11 @@ "use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
__exportStar(require("./client"), exports); | ||
__exportStar(require("./fetch-handler"), exports); | ||
__exportStar(require("./types"), exports); | ||
__exportStar(require("./client"), exports); | ||
__exportStar(require("./util"), exports); | ||
__exportStar(require("./xrpc-client"), exports); | ||
const client_1 = require("./client"); | ||
/** @deprecated create a local {@link XrpcClient} instance instead */ | ||
const defaultInst = new client_1.Client(); | ||
exports.default = defaultInst; | ||
//# sourceMappingURL=index.js.map |
import { z } from 'zod'; | ||
import { ValidationError } from '@atproto/lexicon'; | ||
export type QueryParams = Record<string, any>; | ||
export type Headers = Record<string, string>; | ||
export type HeadersMap = Record<string, string>; | ||
/** @deprecated not to be confused with the WHATWG Headers constructor */ | ||
export type Headers = HeadersMap; | ||
export type Gettable<T> = T | (() => T); | ||
export interface CallOptions { | ||
encoding?: string; | ||
headers?: Headers; | ||
signal?: AbortSignal; | ||
headers?: HeadersMap; | ||
} | ||
export interface FetchHandlerResponse { | ||
status: number; | ||
headers: Headers; | ||
body: ArrayBuffer | undefined; | ||
} | ||
export type FetchHandler = (httpUri: string, httpMethod: string, httpHeaders: Headers, httpReqBody: any) => Promise<FetchHandlerResponse>; | ||
export declare const errorResponseBody: z.ZodObject<{ | ||
@@ -42,3 +40,5 @@ error: z.ZodOptional<z.ZodString>; | ||
} | ||
export declare function httpResponseCodeToEnum(status: number): ResponseType; | ||
export declare const ResponseTypeNames: { | ||
1: string; | ||
2: string; | ||
@@ -58,3 +58,5 @@ 200: string; | ||
}; | ||
export declare function httpResponseCodeToName(status: number): string; | ||
export declare const ResponseTypeStrings: { | ||
1: string; | ||
2: string; | ||
@@ -74,2 +76,3 @@ 200: string; | ||
}; | ||
export declare function httpResponseCodeToString(status: number): string; | ||
export declare class XRPCResponse { | ||
@@ -82,7 +85,8 @@ data: any; | ||
export declare class XRPCError extends Error { | ||
error: string; | ||
headers?: HeadersMap | undefined; | ||
success: boolean; | ||
status: ResponseType; | ||
error?: string | undefined; | ||
success: boolean; | ||
headers?: Headers; | ||
constructor(status: ResponseType, error?: string | undefined, message?: string, headers?: Headers); | ||
constructor(statusCode: number, error?: string, message?: string, headers?: HeadersMap | undefined, options?: ErrorOptions); | ||
static from(cause: unknown, fallbackStatus?: ResponseType): XRPCError; | ||
} | ||
@@ -89,0 +93,0 @@ export declare class XRPCInvalidResponseError extends XRPCError { |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.XRPCInvalidResponseError = exports.XRPCError = exports.XRPCResponse = exports.ResponseTypeStrings = exports.ResponseTypeNames = exports.ResponseType = exports.errorResponseBody = void 0; | ||
exports.XRPCInvalidResponseError = exports.XRPCError = exports.XRPCResponse = exports.httpResponseCodeToString = exports.ResponseTypeStrings = exports.httpResponseCodeToName = exports.ResponseTypeNames = exports.httpResponseCodeToEnum = exports.ResponseType = exports.errorResponseBody = void 0; | ||
const zod_1 = require("zod"); | ||
@@ -26,3 +26,25 @@ exports.errorResponseBody = zod_1.z.object({ | ||
})(ResponseType || (exports.ResponseType = ResponseType = {})); | ||
function httpResponseCodeToEnum(status) { | ||
if (status in ResponseType) { | ||
return status; | ||
} | ||
else if (status >= 100 && status < 200) { | ||
return ResponseType.XRPCNotSupported; | ||
} | ||
else if (status >= 200 && status < 300) { | ||
return ResponseType.Success; | ||
} | ||
else if (status >= 300 && status < 400) { | ||
return ResponseType.XRPCNotSupported; | ||
} | ||
else if (status >= 400 && status < 500) { | ||
return ResponseType.InvalidRequest; | ||
} | ||
else { | ||
return ResponseType.InternalServerError; | ||
} | ||
} | ||
exports.httpResponseCodeToEnum = httpResponseCodeToEnum; | ||
exports.ResponseTypeNames = { | ||
[ResponseType.Unknown]: 'Unknown', | ||
[ResponseType.InvalidResponse]: 'InvalidResponse', | ||
@@ -42,3 +64,8 @@ [ResponseType.Success]: 'Success', | ||
}; | ||
function httpResponseCodeToName(status) { | ||
return exports.ResponseTypeNames[httpResponseCodeToEnum(status)]; | ||
} | ||
exports.httpResponseCodeToName = httpResponseCodeToName; | ||
exports.ResponseTypeStrings = { | ||
[ResponseType.Unknown]: 'Unknown', | ||
[ResponseType.InvalidResponse]: 'Invalid Response', | ||
@@ -58,2 +85,6 @@ [ResponseType.Success]: 'Success', | ||
}; | ||
function httpResponseCodeToString(status) { | ||
return exports.ResponseTypeStrings[httpResponseCodeToEnum(status)]; | ||
} | ||
exports.httpResponseCodeToString = httpResponseCodeToString; | ||
class XRPCResponse { | ||
@@ -83,15 +114,15 @@ constructor(data, headers) { | ||
class XRPCError extends Error { | ||
constructor(status, error, message, headers) { | ||
super(message || error || exports.ResponseTypeStrings[status]); | ||
Object.defineProperty(this, "status", { | ||
constructor(statusCode, error = httpResponseCodeToName(statusCode), message, headers, options) { | ||
super(message || error || httpResponseCodeToString(statusCode), options); | ||
Object.defineProperty(this, "error", { | ||
enumerable: true, | ||
configurable: true, | ||
writable: true, | ||
value: status | ||
value: error | ||
}); | ||
Object.defineProperty(this, "error", { | ||
Object.defineProperty(this, "headers", { | ||
enumerable: true, | ||
configurable: true, | ||
writable: true, | ||
value: error | ||
value: headers | ||
}); | ||
@@ -104,3 +135,3 @@ Object.defineProperty(this, "success", { | ||
}); | ||
Object.defineProperty(this, "headers", { | ||
Object.defineProperty(this, "status", { | ||
enumerable: true, | ||
@@ -111,7 +142,25 @@ configurable: true, | ||
}); | ||
if (!this.error) { | ||
this.error = exports.ResponseTypeNames[status]; | ||
this.status = httpResponseCodeToEnum(statusCode); | ||
// Pre 2022 runtimes won't handle the "options" constructor argument | ||
const cause = options?.cause; | ||
if (this.cause === undefined && cause !== undefined) { | ||
this.cause = cause; | ||
} | ||
this.headers = headers; | ||
} | ||
static from(cause, fallbackStatus) { | ||
if (cause instanceof XRPCError) { | ||
return cause; | ||
} | ||
// Extract status code from "http-errors" like errors | ||
const statusCode = cause instanceof Error | ||
? ('statusCode' in cause ? cause.statusCode : undefined) ?? | ||
('status' in cause ? cause.status : undefined) | ||
: undefined; | ||
const status = typeof statusCode === 'number' | ||
? httpResponseCodeToEnum(statusCode) | ||
: fallbackStatus ?? ResponseType.Unknown; | ||
const error = exports.ResponseTypeNames[status]; | ||
const message = cause instanceof Error ? cause.message : String(cause); | ||
return new XRPCError(status, error, message, undefined, { cause }); | ||
} | ||
} | ||
@@ -121,3 +170,3 @@ exports.XRPCError = XRPCError; | ||
constructor(lexiconNsid, validationError, responseBody) { | ||
super(ResponseType.InvalidResponse, exports.ResponseTypeStrings[ResponseType.InvalidResponse], `The server gave an invalid response and may be out of date.`); | ||
super(ResponseType.InvalidResponse, exports.ResponseTypeStrings[ResponseType.InvalidResponse], `The server gave an invalid response and may be out of date.`, undefined, { cause: validationError }); | ||
Object.defineProperty(this, "lexiconNsid", { | ||
@@ -124,0 +173,0 @@ enumerable: true, |
import { LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon'; | ||
import { CallOptions, Headers, QueryParams, ResponseType } from './types'; | ||
import { CallOptions, ErrorResponseBody, Gettable, QueryParams } from './types'; | ||
export declare function isErrorResponseBody(v: unknown): v is ErrorResponseBody; | ||
export declare function getMethodSchemaHTTPMethod(schema: LexXrpcProcedure | LexXrpcQuery): "post" | "get"; | ||
export declare function constructMethodCallUri(nsid: string, schema: LexXrpcProcedure | LexXrpcQuery, serviceUri: URL, params?: QueryParams): string; | ||
export declare function constructMethodCallUrl(nsid: string, schema: LexXrpcProcedure | LexXrpcQuery, params?: QueryParams): string; | ||
export declare function encodeQueryParam(type: 'string' | 'float' | 'integer' | 'boolean' | 'datetime' | 'array' | 'unknown', value: any): string; | ||
export declare function normalizeHeaders(headers: Headers): Headers; | ||
export declare function constructMethodCallHeaders(schema: LexXrpcProcedure | LexXrpcQuery, data?: any, opts?: CallOptions): Headers; | ||
export declare function encodeMethodCallBody(headers: Headers, data?: any): ArrayBuffer | undefined; | ||
export declare function httpResponseCodeToEnum(status: number): ResponseType; | ||
export declare function constructMethodCallHeaders(schema: LexXrpcProcedure | LexXrpcQuery, data?: unknown, opts?: CallOptions): Headers; | ||
export declare function combineHeaders(headersInit: undefined | HeadersInit, defaultHeaders?: Iterable<[string, undefined | Gettable<null | string>]>): undefined | HeadersInit; | ||
export declare function isBodyInit(value: unknown): value is BodyInit; | ||
export declare function isIterable(value: unknown): value is Iterable<unknown> | AsyncIterable<unknown>; | ||
export declare function encodeMethodCallBody(headers: Headers, data?: unknown): BodyInit | undefined; | ||
export declare function httpResponseBodyParse(mimeType: string | null, data: ArrayBuffer | undefined): any; | ||
//# sourceMappingURL=util.d.ts.map |
367
dist/util.js
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.httpResponseBodyParse = exports.httpResponseCodeToEnum = exports.encodeMethodCallBody = exports.constructMethodCallHeaders = exports.normalizeHeaders = exports.encodeQueryParam = exports.constructMethodCallUri = exports.getMethodSchemaHTTPMethod = void 0; | ||
exports.httpResponseBodyParse = exports.encodeMethodCallBody = exports.isIterable = exports.isBodyInit = exports.combineHeaders = exports.constructMethodCallHeaders = exports.encodeQueryParam = exports.constructMethodCallUrl = exports.constructMethodCallUri = exports.getMethodSchemaHTTPMethod = exports.isErrorResponseBody = void 0; | ||
const lexicon_1 = require("@atproto/lexicon"); | ||
const types_1 = require("./types"); | ||
const ReadableStream = globalThis.ReadableStream || | ||
class { | ||
constructor() { | ||
// This anonymous class will never pass any "instanceof" check and cannot | ||
// be instantiated. | ||
throw new Error('ReadableStream is not supported in this environment'); | ||
} | ||
}; | ||
function isErrorResponseBody(v) { | ||
return types_1.errorResponseBody.safeParse(v).success; | ||
} | ||
exports.isErrorResponseBody = isErrorResponseBody; | ||
function getMethodSchemaHTTPMethod(schema) { | ||
@@ -14,27 +26,36 @@ if (schema.type === 'procedure') { | ||
function constructMethodCallUri(nsid, schema, serviceUri, params) { | ||
const uri = new URL(serviceUri); | ||
uri.pathname = `/xrpc/${nsid}`; | ||
// given parameters | ||
if (params) { | ||
for (const [key, value] of Object.entries(params)) { | ||
const paramSchema = schema.parameters?.properties?.[key]; | ||
if (!paramSchema) { | ||
throw new Error(`Invalid query parameter: ${key}`); | ||
} | ||
if (value !== undefined) { | ||
if (paramSchema.type === 'array') { | ||
const vals = []; | ||
vals.concat(value).forEach((val) => { | ||
uri.searchParams.append(key, encodeQueryParam(paramSchema.items.type, val)); | ||
}); | ||
const uri = new URL(constructMethodCallUrl(nsid, schema, params), serviceUri); | ||
return uri.toString(); | ||
} | ||
exports.constructMethodCallUri = constructMethodCallUri; | ||
function constructMethodCallUrl(nsid, schema, params) { | ||
const pathname = `/xrpc/${encodeURIComponent(nsid)}`; | ||
if (!params) | ||
return pathname; | ||
const searchParams = []; | ||
for (const [key, value] of Object.entries(params)) { | ||
const paramSchema = schema.parameters?.properties?.[key]; | ||
if (!paramSchema) { | ||
throw new Error(`Invalid query parameter: ${key}`); | ||
} | ||
if (value !== undefined) { | ||
if (paramSchema.type === 'array') { | ||
const values = Array.isArray(value) ? value : [value]; | ||
for (const val of values) { | ||
searchParams.push([ | ||
key, | ||
encodeQueryParam(paramSchema.items.type, val), | ||
]); | ||
} | ||
else { | ||
uri.searchParams.set(key, encodeQueryParam(paramSchema.type, value)); | ||
} | ||
} | ||
else { | ||
searchParams.push([key, encodeQueryParam(paramSchema.type, value)]); | ||
} | ||
} | ||
} | ||
return uri.toString(); | ||
if (!searchParams.length) | ||
return pathname; | ||
return `${pathname}?${new URLSearchParams(searchParams).toString()}`; | ||
} | ||
exports.constructMethodCallUri = constructMethodCallUri; | ||
exports.constructMethodCallUrl = constructMethodCallUrl; | ||
function encodeQueryParam(type, value) { | ||
@@ -62,20 +83,61 @@ if (type === 'string' || type === 'unknown') { | ||
exports.encodeQueryParam = encodeQueryParam; | ||
function normalizeHeaders(headers) { | ||
const normalized = {}; | ||
for (const [header, value] of Object.entries(headers)) { | ||
normalized[header.toLowerCase()] = value; | ||
function constructMethodCallHeaders(schema, data, opts) { | ||
// Not using `new Headers(opts?.headers)` to avoid duplicating headers values | ||
// due to inconsistent casing in headers name. In case of multiple headers | ||
// with the same name (but using a different case), the last one will be used. | ||
// new Headers({ 'content-type': 'foo', 'Content-Type': 'bar' }).get('content-type') | ||
// => 'foo, bar' | ||
const headers = new Headers(); | ||
if (opts?.headers) { | ||
for (const name in opts.headers) { | ||
if (headers.has(name)) { | ||
throw new TypeError(`Duplicate header: ${name}`); | ||
} | ||
const value = opts.headers[name]; | ||
if (value != null) { | ||
headers.set(name, value); | ||
} | ||
} | ||
} | ||
return normalized; | ||
} | ||
exports.normalizeHeaders = normalizeHeaders; | ||
function constructMethodCallHeaders(schema, data, opts) { | ||
const headers = opts?.headers || {}; | ||
if (schema.type === 'procedure') { | ||
if (opts?.encoding) { | ||
headers['Content-Type'] = opts.encoding; | ||
headers.set('content-type', opts.encoding); | ||
} | ||
if (data && typeof data === 'object') { | ||
if (!headers['Content-Type']) { | ||
headers['Content-Type'] = 'application/json'; | ||
else if (!headers.has('content-type') && typeof data !== 'undefined') { | ||
// Special handling of BodyInit types before falling back to JSON encoding | ||
if (data instanceof ArrayBuffer || | ||
data instanceof ReadableStream || | ||
ArrayBuffer.isView(data)) { | ||
headers.set('content-type', 'application/octet-stream'); | ||
} | ||
else if (data instanceof FormData) { | ||
// Note: The multipart form data boundary is missing from the header | ||
// we set here, making that header invalid. This special case will be | ||
// handled in encodeMethodCallBody() | ||
headers.set('content-type', 'multipart/form-data'); | ||
} | ||
else if (data instanceof URLSearchParams) { | ||
headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8'); | ||
} | ||
else if (isBlobLike(data)) { | ||
headers.set('content-type', data.type || 'application/octet-stream'); | ||
} | ||
else if (typeof data === 'string') { | ||
headers.set('content-type', 'text/plain;charset=UTF-8'); | ||
} | ||
// At this point, data is not a valid BodyInit type. | ||
else if (isIterable(data)) { | ||
headers.set('content-type', 'application/octet-stream'); | ||
} | ||
else if (typeof data === 'boolean' || | ||
typeof data === 'number' || | ||
typeof data === 'string' || | ||
typeof data === 'object' // covers "null" | ||
) { | ||
headers.set('content-type', 'application/json'); | ||
} | ||
else { | ||
// symbol, function, bigint | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `Unsupported data type: ${typeof data}`); | ||
} | ||
} | ||
@@ -86,67 +148,218 @@ } | ||
exports.constructMethodCallHeaders = constructMethodCallHeaders; | ||
function combineHeaders(headersInit, defaultHeaders) { | ||
if (!defaultHeaders) | ||
return headersInit; | ||
let headers = undefined; | ||
for (const [key, getter] of defaultHeaders) { | ||
// Ignore undefined values (allowed for convenience when using | ||
// Object.entries). | ||
if (getter === undefined) | ||
continue; | ||
// Lazy initialization of the headers object | ||
headers ?? (headers = new Headers(headersInit)); | ||
if (headers.has(key)) | ||
continue; | ||
const value = typeof getter === 'function' ? getter() : getter; | ||
if (typeof value === 'string') | ||
headers.set(key, value); | ||
else if (value === null) | ||
headers.delete(key); | ||
else | ||
throw new TypeError(`Invalid "${key}" header value: ${typeof value}`); | ||
} | ||
return headers ?? headersInit; | ||
} | ||
exports.combineHeaders = combineHeaders; | ||
function isBlobLike(value) { | ||
if (value == null) | ||
return false; | ||
if (typeof value !== 'object') | ||
return false; | ||
if (typeof Blob === 'function' && value instanceof Blob) | ||
return true; | ||
// Support for Blobs provided by libraries that don't use the native Blob | ||
// (e.g. fetch-blob from node-fetch). | ||
// https://github.com/node-fetch/fetch-blob/blob/a1a182e5978811407bef4ea1632b517567dda01f/index.js#L233-L244 | ||
const tag = value[Symbol.toStringTag]; | ||
if (tag === 'Blob' || tag === 'File') { | ||
return 'stream' in value && typeof value.stream === 'function'; | ||
} | ||
return false; | ||
} | ||
function isBodyInit(value) { | ||
switch (typeof value) { | ||
case 'string': | ||
return true; | ||
case 'object': | ||
return (value instanceof ArrayBuffer || | ||
value instanceof FormData || | ||
value instanceof URLSearchParams || | ||
value instanceof ReadableStream || | ||
ArrayBuffer.isView(value) || | ||
isBlobLike(value)); | ||
default: | ||
return false; | ||
} | ||
} | ||
exports.isBodyInit = isBodyInit; | ||
function isIterable(value) { | ||
return (value != null && | ||
typeof value === 'object' && | ||
(Symbol.iterator in value || Symbol.asyncIterator in value)); | ||
} | ||
exports.isIterable = isIterable; | ||
function encodeMethodCallBody(headers, data) { | ||
if (!headers['content-type'] || typeof data === 'undefined') { | ||
// Silently ignore the body if there is no content-type header. | ||
const contentType = headers.get('content-type'); | ||
if (!contentType) { | ||
return undefined; | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
if (typeof data === 'undefined') { | ||
// This error would be returned by the server, but we can catch it earlier | ||
// to avoid un-necessary requests. Note that a content-length of 0 does not | ||
// necessary mean that the body is "empty" (e.g. an empty txt file). | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `A request body is expected but none was provided`); | ||
} | ||
if (isBodyInit(data)) { | ||
if (data instanceof FormData && contentType === 'multipart/form-data') { | ||
// fetch() will encode FormData payload itself, but it won't override the | ||
// content-type header if already present. This would cause the boundary | ||
// to be missing from the content-type header, resulting in a 400 error. | ||
// Deleting the content-type header here to let fetch() re-create it. | ||
headers.delete('content-type'); | ||
} | ||
// Will be encoded by the fetch API. | ||
return data; | ||
} | ||
if (headers['content-type'].startsWith('text/')) { | ||
return new TextEncoder().encode(data.toString()); | ||
if (isIterable(data)) { | ||
// Note that some environments support using Iterable & AsyncIterable as the | ||
// body (e.g. Node's fetch), but not all of them do (browsers). | ||
return iterableToReadableStream(data); | ||
} | ||
if (headers['content-type'].startsWith('application/json')) { | ||
return new TextEncoder().encode((0, lexicon_1.stringifyLex)(data)); | ||
if (contentType.startsWith('text/')) { | ||
return new TextEncoder().encode(String(data)); | ||
} | ||
return data; | ||
if (contentType.startsWith('application/json')) { | ||
const json = (0, lexicon_1.stringifyLex)(data); | ||
// Server would return a 400 error if the JSON is invalid (e.g. trying to | ||
// JSONify a function, or an object that implements toJSON() poorly). | ||
if (json === undefined) { | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `Failed to encode request body as JSON`); | ||
} | ||
return new TextEncoder().encode(json); | ||
} | ||
// At this point, "data" is not a valid BodyInit value, and we don't know how | ||
// to encode it into one. Passing it to fetch would result in an error. Let's | ||
// throw our own error instead. | ||
const type = !data || typeof data !== 'object' | ||
? typeof data | ||
: data.constructor !== Object && | ||
typeof data.constructor === 'function' && | ||
typeof data.constructor?.name === 'string' | ||
? data.constructor.name | ||
: 'object'; | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidRequest, `Unable to encode ${type} as ${contentType} data`); | ||
} | ||
exports.encodeMethodCallBody = encodeMethodCallBody; | ||
function httpResponseCodeToEnum(status) { | ||
let resCode; | ||
if (status in types_1.ResponseType) { | ||
resCode = status; | ||
/** | ||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static} | ||
*/ | ||
function iterableToReadableStream(iterable) { | ||
// Use the native ReadableStream.from() if available. | ||
if ('from' in ReadableStream && typeof ReadableStream.from === 'function') { | ||
return ReadableStream.from(iterable); | ||
} | ||
else if (status >= 100 && status < 200) { | ||
resCode = types_1.ResponseType.XRPCNotSupported; | ||
// Note, in environments where ReadableStream is not available either, we | ||
// *could* load the iterable into memory and create an Arraybuffer from it. | ||
// However, this would be a bad idea for large iterables. In order to keep | ||
// things simple, we'll just allow the anonymous ReadableStream constructor | ||
// to throw an error in those environments, hinting the user of the lib to find | ||
// an alternate solution in that case (e.g. use a Blob if available). | ||
let generator; | ||
return new ReadableStream({ | ||
type: 'bytes', | ||
start() { | ||
// Wrap the iterable in an async generator to handle both sync and async | ||
// iterables, and make sure that the return() method exists. | ||
generator = (async function* () { | ||
yield* iterable; | ||
})(); | ||
}, | ||
async pull(controller) { | ||
const { done, value } = await generator.next(); | ||
if (done) { | ||
controller.close(); | ||
} | ||
else { | ||
try { | ||
const buf = toUint8Array(value); | ||
if (buf) | ||
controller.enqueue(buf); | ||
} | ||
catch (cause) { | ||
// ReadableStream won't call cancel() if the stream is errored. | ||
await generator.return(); | ||
controller.error(new TypeError('Converting iterable body to ReadableStream requires Buffer, ArrayBuffer or string values', { cause })); | ||
} | ||
} | ||
}, | ||
async cancel() { | ||
await generator.return(); | ||
}, | ||
}); | ||
} | ||
// Browsers don't have Buffer. This syntax is to avoid bundlers from including | ||
// a Buffer polyfill in the bundle if it's not used elsewhere. | ||
const globalName = `${{ toString: () => 'Buf' }}fer`; | ||
const Buffer = typeof globalThis[globalName] === 'function' | ||
? globalThis[globalName] | ||
: undefined; | ||
const toUint8Array = Buffer | ||
? (value) => { | ||
// @ts-expect-error Buffer.from will throw if value is not a valid input | ||
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); | ||
return buf.byteLength ? new Uint8Array(buf) : undefined; | ||
} | ||
else if (status >= 200 && status < 300) { | ||
resCode = types_1.ResponseType.Success; | ||
} | ||
else if (status >= 300 && status < 400) { | ||
resCode = types_1.ResponseType.XRPCNotSupported; | ||
} | ||
else if (status >= 400 && status < 500) { | ||
resCode = types_1.ResponseType.InvalidRequest; | ||
} | ||
else { | ||
resCode = types_1.ResponseType.InternalServerError; | ||
} | ||
return resCode; | ||
} | ||
exports.httpResponseCodeToEnum = httpResponseCodeToEnum; | ||
: (value) => { | ||
if (value instanceof ArrayBuffer) { | ||
const buf = new Uint8Array(value); | ||
return buf.byteLength ? buf : undefined; | ||
} | ||
// Simulate Buffer.from() behavior for strings and and coercion | ||
if (typeof value === 'string') { | ||
return value.length ? new TextEncoder().encode(value) : undefined; | ||
} | ||
else if (typeof value?.valueOf === 'function') { | ||
const coerced = value.valueOf(); | ||
if (coerced instanceof ArrayBuffer) { | ||
const buf = new Uint8Array(coerced); | ||
return buf.byteLength ? buf : undefined; | ||
} | ||
else if (typeof coerced === 'string') { | ||
return coerced.length ? new TextEncoder().encode(coerced) : undefined; | ||
} | ||
} | ||
throw new TypeError(`Unable to convert "${typeof value}" to Uint8Array`); | ||
}; | ||
function httpResponseBodyParse(mimeType, data) { | ||
if (mimeType) { | ||
if (mimeType.includes('application/json') && data?.byteLength) { | ||
try { | ||
try { | ||
if (mimeType) { | ||
if (mimeType.includes('application/json')) { | ||
const str = new TextDecoder().decode(data); | ||
return (0, lexicon_1.jsonStringToLex)(str); | ||
} | ||
catch (e) { | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidResponse, `Failed to parse response body: ${String(e)}`); | ||
} | ||
} | ||
if (mimeType.startsWith('text/') && data?.byteLength) { | ||
try { | ||
if (mimeType.startsWith('text/')) { | ||
return new TextDecoder().decode(data); | ||
} | ||
catch (e) { | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidResponse, `Failed to parse response body: ${String(e)}`); | ||
} | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
return new Uint8Array(data); | ||
} | ||
return data; | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
return new Uint8Array(data); | ||
catch (cause) { | ||
throw new types_1.XRPCError(types_1.ResponseType.InvalidResponse, undefined, `Failed to parse response body: ${String(cause)}`, undefined, { cause }); | ||
} | ||
return data; | ||
} | ||
exports.httpResponseBodyParse = httpResponseBodyParse; | ||
//# sourceMappingURL=util.js.map |
{ | ||
"name": "@atproto/xrpc", | ||
"version": "0.5.0", | ||
"version": "0.6.0-rc.0", | ||
"license": "MIT", | ||
@@ -19,4 +19,4 @@ "description": "atproto HTTP API (XRPC) client library", | ||
"dependencies": { | ||
"zod": "^3.21.4", | ||
"@atproto/lexicon": "^0.4.0" | ||
"zod": "^3.23.8", | ||
"@atproto/lexicon": "^0.4.1-rc.0" | ||
}, | ||
@@ -23,0 +23,0 @@ "devDependencies": { |
@@ -12,5 +12,5 @@ # @atproto/xrpc: atproto HTTP API Client | ||
import { LexiconDoc } from '@atproto/lexicon' | ||
import xrpc from '@atproto/xrpc' | ||
import { XrpcClient } from '@atproto/xrpc' | ||
const pingLexicon: LexiconDoc = { | ||
const pingLexicon = { | ||
lexicon: 1, | ||
@@ -36,6 +36,10 @@ id: 'io.example.ping', | ||
}, | ||
} | ||
xrpc.addLexicon(pingLexicon) | ||
} satisfies LexiconDoc | ||
const res1 = await xrpc.call('https://example.com', 'io.example.ping', { | ||
const xrpc = new XrpcClient('https://ping.example.com', [ | ||
// Any number of lexicon here | ||
pingLexicon, | ||
]) | ||
const res1 = await xrpc.call('io.example.ping', { | ||
message: 'hello world', | ||
@@ -45,32 +49,53 @@ }) | ||
res1.body // => {message: 'hello world'} | ||
const res2 = await xrpc | ||
.service('https://example.com') | ||
.call('io.example.ping', { message: 'hello world' }) | ||
res2.encoding // => 'application/json' | ||
res2.body // => {message: 'hello world'} | ||
``` | ||
const writeJsonLexicon: LexiconDoc = { | ||
lexicon: 1, | ||
id: 'io.example.writeJsonFile', | ||
defs: { | ||
main: { | ||
type: 'procedure', | ||
description: 'Write a JSON file', | ||
parameters: { | ||
type: 'params', | ||
properties: { fileName: { type: 'string' } }, | ||
}, | ||
input: { | ||
encoding: 'application/json', | ||
}, | ||
}, | ||
### With a custom fetch handler | ||
```typescript | ||
import { XrpcClient } from '@atproto/xrpc' | ||
const session = { | ||
serviceUrl: 'https://ping.example.com', | ||
token: '<my-token>', | ||
async refreshToken() { | ||
const { token } = await fetch('https://auth.example.com/refresh', { | ||
method: 'POST', | ||
headers: { Authorization: `Bearer ${this.token}` }, | ||
}).then((res) => res.json()) | ||
this.token = token | ||
return token | ||
}, | ||
} | ||
xrpc.addLexicon(writeJsonLexicon) | ||
const res3 = await xrpc.service('https://example.com').call( | ||
'io.example.writeJsonFile', | ||
{ fileName: 'foo.json' }, // query parameters | ||
{ hello: 'world', thisIs: 'the file to write' }, // input body | ||
) | ||
const sessionBasedFetch: FetchHandler = async ( | ||
url: string, | ||
init: RequestInit, | ||
) => { | ||
const headers = new Headers(init.headers) | ||
headers.set('Authorization', `Bearer ${session.token}`) | ||
const response = await fetch(new URL(url, session.serviceUrl), { | ||
...init, | ||
headers, | ||
}) | ||
if (response.status === 401) { | ||
// Refresh token, then try again. | ||
const newToken = await session.refreshToken() | ||
headers.set('Authorization', `Bearer ${newToken}`) | ||
return fetch(new URL(url, session.serviceUrl), { ...init, headers }) | ||
} | ||
return response | ||
} | ||
const xrpc = new XrpcClient(sessionBasedFetch, [ | ||
// Any number of lexicon here | ||
pingLexicon, | ||
]) | ||
// | ||
``` | ||
@@ -77,0 +102,0 @@ |
@@ -1,27 +0,22 @@ | ||
import { LexiconDoc, Lexicons, ValidationError } from '@atproto/lexicon' | ||
import { | ||
getMethodSchemaHTTPMethod, | ||
constructMethodCallUri, | ||
constructMethodCallHeaders, | ||
encodeMethodCallBody, | ||
httpResponseCodeToEnum, | ||
httpResponseBodyParse, | ||
normalizeHeaders, | ||
} from './util' | ||
import { | ||
FetchHandler, | ||
FetchHandlerResponse, | ||
Headers, | ||
CallOptions, | ||
QueryParams, | ||
ResponseType, | ||
errorResponseBody, | ||
ErrorResponseBody, | ||
XRPCResponse, | ||
XRPCError, | ||
XRPCInvalidResponseError, | ||
} from './types' | ||
import { LexiconDoc, Lexicons } from '@atproto/lexicon' | ||
import { CallOptions, Gettable, QueryParams } from './types' | ||
import { XrpcClient } from './xrpc-client' | ||
import { combineHeaders } from './util' | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
export class Client { | ||
fetch: FetchHandler = defaultFetchHandler | ||
/** @deprecated */ | ||
get fetch(): never { | ||
throw new Error( | ||
'Client.fetch is no longer supported. Use an XrpcClient instead.', | ||
) | ||
} | ||
/** @deprecated */ | ||
set fetch(_: never) { | ||
throw new Error( | ||
'Client.fetch is no longer supported. Use an XrpcClient instead.', | ||
) | ||
} | ||
lex = new Lexicons() | ||
@@ -36,3 +31,3 @@ | ||
params?: QueryParams, | ||
data?: unknown, | ||
data?: BodyInit | null, | ||
opts?: CallOptions, | ||
@@ -65,107 +60,25 @@ ) { | ||
export class ServiceClient { | ||
baseClient: Client | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
export class ServiceClient extends XrpcClient { | ||
uri: URL | ||
headers: Record<string, string> = {} | ||
protected headers = new Map<string, Gettable<null | string>>() | ||
constructor(baseClient: Client, serviceUri: string | URL) { | ||
this.baseClient = baseClient | ||
constructor( | ||
public baseClient: Client, | ||
serviceUri: string | URL, | ||
) { | ||
super(async (input, init) => { | ||
const headers = combineHeaders(init.headers, this.headers) | ||
return fetch(new URL(input, this.uri), { ...init, headers }) | ||
}, baseClient.lex) | ||
this.uri = typeof serviceUri === 'string' ? new URL(serviceUri) : serviceUri | ||
} | ||
setHeader(key: string, value: string): void { | ||
this.headers[key] = value | ||
setHeader(key: string, value: Gettable<null | string>): void { | ||
this.headers.set(key.toLowerCase(), value) | ||
} | ||
unsetHeader(key: string): void { | ||
delete this.headers[key] | ||
this.headers.delete(key.toLowerCase()) | ||
} | ||
async call( | ||
methodNsid: string, | ||
params?: QueryParams, | ||
data?: unknown, | ||
opts?: CallOptions, | ||
) { | ||
const def = this.baseClient.lex.getDefOrThrow(methodNsid) | ||
if (!def || (def.type !== 'query' && def.type !== 'procedure')) { | ||
throw new Error( | ||
`Invalid lexicon: ${methodNsid}. Must be a query or procedure.`, | ||
) | ||
} | ||
const httpMethod = getMethodSchemaHTTPMethod(def) | ||
const httpUri = constructMethodCallUri(methodNsid, def, this.uri, params) | ||
const httpHeaders = constructMethodCallHeaders(def, data, { | ||
headers: { | ||
...this.headers, | ||
...opts?.headers, | ||
}, | ||
encoding: opts?.encoding, | ||
}) | ||
const res = await this.baseClient.fetch( | ||
httpUri, | ||
httpMethod, | ||
httpHeaders, | ||
data, | ||
) | ||
const resCode = httpResponseCodeToEnum(res.status) | ||
if (resCode === ResponseType.Success) { | ||
try { | ||
this.baseClient.lex.assertValidXrpcOutput(methodNsid, res.body) | ||
} catch (e: any) { | ||
if (e instanceof ValidationError) { | ||
throw new XRPCInvalidResponseError(methodNsid, e, res.body) | ||
} else { | ||
throw e | ||
} | ||
} | ||
return new XRPCResponse(res.body, res.headers) | ||
} else { | ||
if (res.body && isErrorResponseBody(res.body)) { | ||
throw new XRPCError( | ||
resCode, | ||
res.body.error, | ||
res.body.message, | ||
res.headers, | ||
) | ||
} else { | ||
throw new XRPCError(resCode) | ||
} | ||
} | ||
} | ||
} | ||
export async function defaultFetchHandler( | ||
httpUri: string, | ||
httpMethod: string, | ||
httpHeaders: Headers, | ||
httpReqBody: unknown, | ||
): Promise<FetchHandlerResponse> { | ||
try { | ||
// The duplex field is now required for streaming bodies, but not yet reflected | ||
// anywhere in docs or types. See whatwg/fetch#1438, nodejs/node#46221. | ||
const headers = normalizeHeaders(httpHeaders) | ||
const reqInit: RequestInit & { duplex: string } = { | ||
method: httpMethod, | ||
headers, | ||
body: encodeMethodCallBody(headers, httpReqBody), | ||
duplex: 'half', | ||
} | ||
const res = await fetch(httpUri, reqInit) | ||
const resBody = await res.arrayBuffer() | ||
return { | ||
status: res.status, | ||
headers: Object.fromEntries(res.headers.entries()), | ||
body: httpResponseBodyParse(res.headers.get('content-type'), resBody), | ||
} | ||
} catch (e) { | ||
throw new XRPCError(ResponseType.Unknown, String(e)) | ||
} | ||
} | ||
function isErrorResponseBody(v: unknown): v is ErrorResponseBody { | ||
return errorResponseBody.safeParse(v).success | ||
} |
@@ -0,6 +1,10 @@ | ||
export * from './client' | ||
export * from './fetch-handler' | ||
export * from './types' | ||
export * from './client' | ||
export * from './util' | ||
export * from './xrpc-client' | ||
import { Client } from './client' | ||
/** @deprecated create a local {@link XrpcClient} instance instead */ | ||
const defaultInst = new Client() | ||
export default defaultInst |
101
src/types.ts
@@ -5,22 +5,15 @@ import { z } from 'zod' | ||
export type QueryParams = Record<string, any> | ||
export type Headers = Record<string, string> | ||
export type HeadersMap = Record<string, string> | ||
/** @deprecated not to be confused with the WHATWG Headers constructor */ | ||
export type Headers = HeadersMap | ||
export type Gettable<T> = T | (() => T) | ||
export interface CallOptions { | ||
encoding?: string | ||
headers?: Headers | ||
signal?: AbortSignal | ||
headers?: HeadersMap | ||
} | ||
export interface FetchHandlerResponse { | ||
status: number | ||
headers: Headers | ||
body: ArrayBuffer | undefined | ||
} | ||
export type FetchHandler = ( | ||
httpUri: string, | ||
httpMethod: string, | ||
httpHeaders: Headers, | ||
httpReqBody: any, | ||
) => Promise<FetchHandlerResponse> | ||
export const errorResponseBody = z.object({ | ||
@@ -49,3 +42,20 @@ error: z.string().optional(), | ||
export function httpResponseCodeToEnum(status: number): ResponseType { | ||
if (status in ResponseType) { | ||
return status | ||
} else if (status >= 100 && status < 200) { | ||
return ResponseType.XRPCNotSupported | ||
} else if (status >= 200 && status < 300) { | ||
return ResponseType.Success | ||
} else if (status >= 300 && status < 400) { | ||
return ResponseType.XRPCNotSupported | ||
} else if (status >= 400 && status < 500) { | ||
return ResponseType.InvalidRequest | ||
} else { | ||
return ResponseType.InternalServerError | ||
} | ||
} | ||
export const ResponseTypeNames = { | ||
[ResponseType.Unknown]: 'Unknown', | ||
[ResponseType.InvalidResponse]: 'InvalidResponse', | ||
@@ -66,3 +76,8 @@ [ResponseType.Success]: 'Success', | ||
export function httpResponseCodeToName(status: number): string { | ||
return ResponseTypeNames[httpResponseCodeToEnum(status)] | ||
} | ||
export const ResponseTypeStrings = { | ||
[ResponseType.Unknown]: 'Unknown', | ||
[ResponseType.InvalidResponse]: 'Invalid Response', | ||
@@ -83,6 +98,13 @@ [ResponseType.Success]: 'Success', | ||
export function httpResponseCodeToString(status: number): string { | ||
return ResponseTypeStrings[httpResponseCodeToEnum(status)] | ||
} | ||
export class XRPCResponse { | ||
success = true | ||
constructor(public data: any, public headers: Headers) {} | ||
constructor( | ||
public data: any, | ||
public headers: Headers, | ||
) {} | ||
} | ||
@@ -92,16 +114,45 @@ | ||
success = false | ||
headers?: Headers | ||
public status: ResponseType | ||
constructor( | ||
public status: ResponseType, | ||
public error?: string, | ||
statusCode: number, | ||
public error: string = httpResponseCodeToName(statusCode), | ||
message?: string, | ||
headers?: Headers, | ||
public headers?: Headers, | ||
options?: ErrorOptions, | ||
) { | ||
super(message || error || ResponseTypeStrings[status]) | ||
if (!this.error) { | ||
this.error = ResponseTypeNames[status] | ||
super(message || error || httpResponseCodeToString(statusCode), options) | ||
this.status = httpResponseCodeToEnum(statusCode) | ||
// Pre 2022 runtimes won't handle the "options" constructor argument | ||
const cause = options?.cause | ||
if (this.cause === undefined && cause !== undefined) { | ||
this.cause = cause | ||
} | ||
this.headers = headers | ||
} | ||
static from(cause: unknown, fallbackStatus?: ResponseType): XRPCError { | ||
if (cause instanceof XRPCError) { | ||
return cause | ||
} | ||
// Extract status code from "http-errors" like errors | ||
const statusCode: unknown = | ||
cause instanceof Error | ||
? ('statusCode' in cause ? cause.statusCode : undefined) ?? | ||
('status' in cause ? cause.status : undefined) | ||
: undefined | ||
const status: ResponseType = | ||
typeof statusCode === 'number' | ||
? httpResponseCodeToEnum(statusCode) | ||
: fallbackStatus ?? ResponseType.Unknown | ||
const error = ResponseTypeNames[status] | ||
const message = cause instanceof Error ? cause.message : String(cause) | ||
return new XRPCError(status, error, message, undefined, { cause }) | ||
} | ||
} | ||
@@ -119,4 +170,6 @@ | ||
`The server gave an invalid response and may be out of date.`, | ||
undefined, | ||
{ cause: validationError }, | ||
) | ||
} | ||
} |
433
src/util.ts
@@ -9,3 +9,5 @@ import { | ||
CallOptions, | ||
Headers, | ||
errorResponseBody, | ||
ErrorResponseBody, | ||
Gettable, | ||
QueryParams, | ||
@@ -16,2 +18,16 @@ ResponseType, | ||
const ReadableStream = | ||
globalThis.ReadableStream || | ||
(class { | ||
constructor() { | ||
// This anonymous class will never pass any "instanceof" check and cannot | ||
// be instantiated. | ||
throw new Error('ReadableStream is not supported in this environment') | ||
} | ||
} as typeof globalThis.ReadableStream) | ||
export function isErrorResponseBody(v: unknown): v is ErrorResponseBody { | ||
return errorResponseBody.safeParse(v).success | ||
} | ||
export function getMethodSchemaHTTPMethod( | ||
@@ -32,24 +48,32 @@ schema: LexXrpcProcedure | LexXrpcQuery, | ||
): string { | ||
const uri = new URL(serviceUri) | ||
uri.pathname = `/xrpc/${nsid}` | ||
const uri = new URL(constructMethodCallUrl(nsid, schema, params), serviceUri) | ||
return uri.toString() | ||
} | ||
// given parameters | ||
if (params) { | ||
for (const [key, value] of Object.entries(params)) { | ||
const paramSchema = schema.parameters?.properties?.[key] | ||
if (!paramSchema) { | ||
throw new Error(`Invalid query parameter: ${key}`) | ||
} | ||
if (value !== undefined) { | ||
if (paramSchema.type === 'array') { | ||
const vals: typeof value[] = [] | ||
vals.concat(value).forEach((val) => { | ||
uri.searchParams.append( | ||
key, | ||
encodeQueryParam(paramSchema.items.type, val), | ||
) | ||
}) | ||
} else { | ||
uri.searchParams.set(key, encodeQueryParam(paramSchema.type, value)) | ||
export function constructMethodCallUrl( | ||
nsid: string, | ||
schema: LexXrpcProcedure | LexXrpcQuery, | ||
params?: QueryParams, | ||
): string { | ||
const pathname = `/xrpc/${encodeURIComponent(nsid)}` | ||
if (!params) return pathname | ||
const searchParams: [string, string][] = [] | ||
for (const [key, value] of Object.entries(params)) { | ||
const paramSchema = schema.parameters?.properties?.[key] | ||
if (!paramSchema) { | ||
throw new Error(`Invalid query parameter: ${key}`) | ||
} | ||
if (value !== undefined) { | ||
if (paramSchema.type === 'array') { | ||
const values = Array.isArray(value) ? value : [value] | ||
for (const val of values) { | ||
searchParams.push([ | ||
key, | ||
encodeQueryParam(paramSchema.items.type, val), | ||
]) | ||
} | ||
} else { | ||
searchParams.push([key, encodeQueryParam(paramSchema.type, value)]) | ||
} | ||
@@ -59,3 +83,5 @@ } | ||
return uri.toString() | ||
if (!searchParams.length) return pathname | ||
return `${pathname}?${new URLSearchParams(searchParams).toString()}` | ||
} | ||
@@ -92,25 +118,71 @@ | ||
export function normalizeHeaders(headers: Headers): Headers { | ||
const normalized: Headers = {} | ||
for (const [header, value] of Object.entries(headers)) { | ||
normalized[header.toLowerCase()] = value | ||
} | ||
return normalized | ||
} | ||
export function constructMethodCallHeaders( | ||
schema: LexXrpcProcedure | LexXrpcQuery, | ||
data?: any, | ||
data?: unknown, | ||
opts?: CallOptions, | ||
): Headers { | ||
const headers: Headers = opts?.headers || {} | ||
// Not using `new Headers(opts?.headers)` to avoid duplicating headers values | ||
// due to inconsistent casing in headers name. In case of multiple headers | ||
// with the same name (but using a different case), the last one will be used. | ||
// new Headers({ 'content-type': 'foo', 'Content-Type': 'bar' }).get('content-type') | ||
// => 'foo, bar' | ||
const headers = new Headers() | ||
if (opts?.headers) { | ||
for (const name in opts.headers) { | ||
if (headers.has(name)) { | ||
throw new TypeError(`Duplicate header: ${name}`) | ||
} | ||
const value = opts.headers[name] | ||
if (value != null) { | ||
headers.set(name, value) | ||
} | ||
} | ||
} | ||
if (schema.type === 'procedure') { | ||
if (opts?.encoding) { | ||
headers['Content-Type'] = opts.encoding | ||
} | ||
if (data && typeof data === 'object') { | ||
if (!headers['Content-Type']) { | ||
headers['Content-Type'] = 'application/json' | ||
headers.set('content-type', opts.encoding) | ||
} else if (!headers.has('content-type') && typeof data !== 'undefined') { | ||
// Special handling of BodyInit types before falling back to JSON encoding | ||
if ( | ||
data instanceof ArrayBuffer || | ||
data instanceof ReadableStream || | ||
ArrayBuffer.isView(data) | ||
) { | ||
headers.set('content-type', 'application/octet-stream') | ||
} else if (data instanceof FormData) { | ||
// Note: The multipart form data boundary is missing from the header | ||
// we set here, making that header invalid. This special case will be | ||
// handled in encodeMethodCallBody() | ||
headers.set('content-type', 'multipart/form-data') | ||
} else if (data instanceof URLSearchParams) { | ||
headers.set( | ||
'content-type', | ||
'application/x-www-form-urlencoded;charset=UTF-8', | ||
) | ||
} else if (isBlobLike(data)) { | ||
headers.set('content-type', data.type || 'application/octet-stream') | ||
} else if (typeof data === 'string') { | ||
headers.set('content-type', 'text/plain;charset=UTF-8') | ||
} | ||
// At this point, data is not a valid BodyInit type. | ||
else if (isIterable(data)) { | ||
headers.set('content-type', 'application/octet-stream') | ||
} else if ( | ||
typeof data === 'boolean' || | ||
typeof data === 'number' || | ||
typeof data === 'string' || | ||
typeof data === 'object' // covers "null" | ||
) { | ||
headers.set('content-type', 'application/json') | ||
} else { | ||
// symbol, function, bigint | ||
throw new XRPCError( | ||
ResponseType.InvalidRequest, | ||
`Unsupported data type: ${typeof data}`, | ||
) | ||
} | ||
} | ||
@@ -121,39 +193,240 @@ } | ||
export function combineHeaders( | ||
headersInit: undefined | HeadersInit, | ||
defaultHeaders?: Iterable<[string, undefined | Gettable<null | string>]>, | ||
): undefined | HeadersInit { | ||
if (!defaultHeaders) return headersInit | ||
let headers: Headers | undefined = undefined | ||
for (const [key, getter] of defaultHeaders) { | ||
// Ignore undefined values (allowed for convenience when using | ||
// Object.entries). | ||
if (getter === undefined) continue | ||
// Lazy initialization of the headers object | ||
headers ??= new Headers(headersInit) | ||
if (headers.has(key)) continue | ||
const value = typeof getter === 'function' ? getter() : getter | ||
if (typeof value === 'string') headers.set(key, value) | ||
else if (value === null) headers.delete(key) | ||
else throw new TypeError(`Invalid "${key}" header value: ${typeof value}`) | ||
} | ||
return headers ?? headersInit | ||
} | ||
function isBlobLike(value: unknown): value is Blob { | ||
if (value == null) return false | ||
if (typeof value !== 'object') return false | ||
if (typeof Blob === 'function' && value instanceof Blob) return true | ||
// Support for Blobs provided by libraries that don't use the native Blob | ||
// (e.g. fetch-blob from node-fetch). | ||
// https://github.com/node-fetch/fetch-blob/blob/a1a182e5978811407bef4ea1632b517567dda01f/index.js#L233-L244 | ||
const tag = value[Symbol.toStringTag] | ||
if (tag === 'Blob' || tag === 'File') { | ||
return 'stream' in value && typeof value.stream === 'function' | ||
} | ||
return false | ||
} | ||
export function isBodyInit(value: unknown): value is BodyInit { | ||
switch (typeof value) { | ||
case 'string': | ||
return true | ||
case 'object': | ||
return ( | ||
value instanceof ArrayBuffer || | ||
value instanceof FormData || | ||
value instanceof URLSearchParams || | ||
value instanceof ReadableStream || | ||
ArrayBuffer.isView(value) || | ||
isBlobLike(value) | ||
) | ||
default: | ||
return false | ||
} | ||
} | ||
export function isIterable( | ||
value: unknown, | ||
): value is Iterable<unknown> | AsyncIterable<unknown> { | ||
return ( | ||
value != null && | ||
typeof value === 'object' && | ||
(Symbol.iterator in value || Symbol.asyncIterator in value) | ||
) | ||
} | ||
export function encodeMethodCallBody( | ||
headers: Headers, | ||
data?: any, | ||
): ArrayBuffer | undefined { | ||
if (!headers['content-type'] || typeof data === 'undefined') { | ||
data?: unknown, | ||
): BodyInit | undefined { | ||
// Silently ignore the body if there is no content-type header. | ||
const contentType = headers.get('content-type') | ||
if (!contentType) { | ||
return undefined | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
if (typeof data === 'undefined') { | ||
// This error would be returned by the server, but we can catch it earlier | ||
// to avoid un-necessary requests. Note that a content-length of 0 does not | ||
// necessary mean that the body is "empty" (e.g. an empty txt file). | ||
throw new XRPCError( | ||
ResponseType.InvalidRequest, | ||
`A request body is expected but none was provided`, | ||
) | ||
} | ||
if (isBodyInit(data)) { | ||
if (data instanceof FormData && contentType === 'multipart/form-data') { | ||
// fetch() will encode FormData payload itself, but it won't override the | ||
// content-type header if already present. This would cause the boundary | ||
// to be missing from the content-type header, resulting in a 400 error. | ||
// Deleting the content-type header here to let fetch() re-create it. | ||
headers.delete('content-type') | ||
} | ||
// Will be encoded by the fetch API. | ||
return data | ||
} | ||
if (headers['content-type'].startsWith('text/')) { | ||
return new TextEncoder().encode(data.toString()) | ||
if (isIterable(data)) { | ||
// Note that some environments support using Iterable & AsyncIterable as the | ||
// body (e.g. Node's fetch), but not all of them do (browsers). | ||
return iterableToReadableStream(data) | ||
} | ||
if (headers['content-type'].startsWith('application/json')) { | ||
return new TextEncoder().encode(stringifyLex(data)) | ||
if (contentType.startsWith('text/')) { | ||
return new TextEncoder().encode(String(data)) | ||
} | ||
return data | ||
if (contentType.startsWith('application/json')) { | ||
const json = stringifyLex(data) | ||
// Server would return a 400 error if the JSON is invalid (e.g. trying to | ||
// JSONify a function, or an object that implements toJSON() poorly). | ||
if (json === undefined) { | ||
throw new XRPCError( | ||
ResponseType.InvalidRequest, | ||
`Failed to encode request body as JSON`, | ||
) | ||
} | ||
return new TextEncoder().encode(json) | ||
} | ||
// At this point, "data" is not a valid BodyInit value, and we don't know how | ||
// to encode it into one. Passing it to fetch would result in an error. Let's | ||
// throw our own error instead. | ||
const type = | ||
!data || typeof data !== 'object' | ||
? typeof data | ||
: data.constructor !== Object && | ||
typeof data.constructor === 'function' && | ||
typeof data.constructor?.name === 'string' | ||
? data.constructor.name | ||
: 'object' | ||
throw new XRPCError( | ||
ResponseType.InvalidRequest, | ||
`Unable to encode ${type} as ${contentType} data`, | ||
) | ||
} | ||
export function httpResponseCodeToEnum(status: number): ResponseType { | ||
let resCode: ResponseType | ||
if (status in ResponseType) { | ||
resCode = status | ||
} else if (status >= 100 && status < 200) { | ||
resCode = ResponseType.XRPCNotSupported | ||
} else if (status >= 200 && status < 300) { | ||
resCode = ResponseType.Success | ||
} else if (status >= 300 && status < 400) { | ||
resCode = ResponseType.XRPCNotSupported | ||
} else if (status >= 400 && status < 500) { | ||
resCode = ResponseType.InvalidRequest | ||
} else { | ||
resCode = ResponseType.InternalServerError | ||
/** | ||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/from_static} | ||
*/ | ||
function iterableToReadableStream( | ||
iterable: Iterable<unknown> | AsyncIterable<unknown>, | ||
): ReadableStream<Uint8Array> { | ||
// Use the native ReadableStream.from() if available. | ||
if ('from' in ReadableStream && typeof ReadableStream.from === 'function') { | ||
return ReadableStream.from(iterable) | ||
} | ||
return resCode | ||
// Note, in environments where ReadableStream is not available either, we | ||
// *could* load the iterable into memory and create an Arraybuffer from it. | ||
// However, this would be a bad idea for large iterables. In order to keep | ||
// things simple, we'll just allow the anonymous ReadableStream constructor | ||
// to throw an error in those environments, hinting the user of the lib to find | ||
// an alternate solution in that case (e.g. use a Blob if available). | ||
let generator: AsyncGenerator<unknown, void, undefined> | ||
return new ReadableStream<Uint8Array>({ | ||
type: 'bytes', | ||
start() { | ||
// Wrap the iterable in an async generator to handle both sync and async | ||
// iterables, and make sure that the return() method exists. | ||
generator = (async function* () { | ||
yield* iterable | ||
})() | ||
}, | ||
async pull(controller: ReadableStreamDefaultController) { | ||
const { done, value } = await generator.next() | ||
if (done) { | ||
controller.close() | ||
} else { | ||
try { | ||
const buf = toUint8Array(value) | ||
if (buf) controller.enqueue(buf) | ||
} catch (cause) { | ||
// ReadableStream won't call cancel() if the stream is errored. | ||
await generator.return() | ||
controller.error( | ||
new TypeError( | ||
'Converting iterable body to ReadableStream requires Buffer, ArrayBuffer or string values', | ||
{ cause }, | ||
), | ||
) | ||
} | ||
} | ||
}, | ||
async cancel() { | ||
await generator.return() | ||
}, | ||
}) | ||
} | ||
// Browsers don't have Buffer. This syntax is to avoid bundlers from including | ||
// a Buffer polyfill in the bundle if it's not used elsewhere. | ||
const globalName = `${{ toString: () => 'Buf' }}fer` as 'Buffer' | ||
const Buffer = | ||
typeof globalThis[globalName] === 'function' | ||
? globalThis[globalName] | ||
: undefined | ||
const toUint8Array: (value: unknown) => Uint8Array | undefined = Buffer | ||
? (value) => { | ||
// @ts-expect-error Buffer.from will throw if value is not a valid input | ||
const buf = Buffer.isBuffer(value) ? value : Buffer.from(value) | ||
return buf.byteLength ? new Uint8Array(buf) : undefined | ||
} | ||
: (value) => { | ||
if (value instanceof ArrayBuffer) { | ||
const buf = new Uint8Array(value) | ||
return buf.byteLength ? buf : undefined | ||
} | ||
// Simulate Buffer.from() behavior for strings and and coercion | ||
if (typeof value === 'string') { | ||
return value.length ? new TextEncoder().encode(value) : undefined | ||
} else if (typeof value?.valueOf === 'function') { | ||
const coerced = value.valueOf() | ||
if (coerced instanceof ArrayBuffer) { | ||
const buf = new Uint8Array(coerced) | ||
return buf.byteLength ? buf : undefined | ||
} else if (typeof coerced === 'string') { | ||
return coerced.length ? new TextEncoder().encode(coerced) : undefined | ||
} | ||
} | ||
throw new TypeError(`Unable to convert "${typeof value}" to Uint8Array`) | ||
} | ||
export function httpResponseBodyParse( | ||
@@ -163,29 +436,25 @@ mimeType: string | null, | ||
): any { | ||
if (mimeType) { | ||
if (mimeType.includes('application/json') && data?.byteLength) { | ||
try { | ||
try { | ||
if (mimeType) { | ||
if (mimeType.includes('application/json')) { | ||
const str = new TextDecoder().decode(data) | ||
return jsonStringToLex(str) | ||
} catch (e) { | ||
throw new XRPCError( | ||
ResponseType.InvalidResponse, | ||
`Failed to parse response body: ${String(e)}`, | ||
) | ||
} | ||
} | ||
if (mimeType.startsWith('text/') && data?.byteLength) { | ||
try { | ||
if (mimeType.startsWith('text/')) { | ||
return new TextDecoder().decode(data) | ||
} catch (e) { | ||
throw new XRPCError( | ||
ResponseType.InvalidResponse, | ||
`Failed to parse response body: ${String(e)}`, | ||
) | ||
} | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
return new Uint8Array(data) | ||
} | ||
return data | ||
} catch (cause) { | ||
throw new XRPCError( | ||
ResponseType.InvalidResponse, | ||
undefined, | ||
`Failed to parse response body: ${String(cause)}`, | ||
undefined, | ||
{ cause }, | ||
) | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
return new Uint8Array(data) | ||
} | ||
return data | ||
} |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
100721
36
1727
108
4
Updated@atproto/lexicon@^0.4.1-rc.0
Updatedzod@^3.23.8