@atproto/xrpc
Advanced tools
Comparing version 0.5.0 to 0.5.1-rc.0
import { LexiconDoc, Lexicons } from '@atproto/lexicon'; | ||
import { FetchHandler, FetchHandlerResponse, Headers, CallOptions, QueryParams, XRPCResponse } from './types'; | ||
import { CallOptions, 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,3 +17,4 @@ addLexicon(doc: LexiconDoc): void; | ||
} | ||
export declare class ServiceClient { | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
export declare class ServiceClient extends XrpcClient { | ||
baseClient: Client; | ||
@@ -20,5 +26,3 @@ uri: URL; | ||
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, Object.entries(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 | ||
}); | ||
@@ -68,3 +76,2 @@ Object.defineProperty(this, "uri", { | ||
}); | ||
this.baseClient = baseClient; | ||
this.uri = typeof serviceUri === 'string' ? new URL(serviceUri) : serviceUri; | ||
@@ -78,70 +85,4 @@ } | ||
} | ||
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 |
302
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,153 @@ } | ||
exports.constructMethodCallHeaders = constructMethodCallHeaders; | ||
function encodeMethodCallBody(headers, data) { | ||
if (!headers['content-type'] || typeof data === 'undefined') { | ||
return undefined; | ||
function combineHeaders(headersInit, defaultHeaders) { | ||
if (!defaultHeaders) | ||
return headersInit; | ||
let headers = undefined; | ||
for (const [name, definition] of defaultHeaders) { | ||
// Ignore undefined values (allowed for convenience when using | ||
// Object.entries). | ||
if (definition === undefined) | ||
continue; | ||
// Lazy initialization of the headers object | ||
headers ?? (headers = new Headers(headersInit)); | ||
if (headers.has(name)) | ||
continue; | ||
const value = typeof definition === 'function' ? definition() : definition; | ||
if (typeof value === 'string') | ||
headers.set(name, value); | ||
else if (value === null) | ||
headers.delete(name); | ||
else | ||
throw new TypeError(`Invalid "${name}" header value: ${typeof value}`); | ||
} | ||
if (data instanceof ArrayBuffer) { | ||
return data; | ||
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'; | ||
} | ||
if (headers['content-type'].startsWith('text/')) { | ||
return new TextEncoder().encode(data.toString()); | ||
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; | ||
} | ||
if (headers['content-type'].startsWith('application/json')) { | ||
return new TextEncoder().encode((0, lexicon_1.stringifyLex)(data)); | ||
} | ||
return data; | ||
} | ||
exports.encodeMethodCallBody = encodeMethodCallBody; | ||
function httpResponseCodeToEnum(status) { | ||
let resCode; | ||
if (status in types_1.ResponseType) { | ||
resCode = status; | ||
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) { | ||
// Silently ignore the body if there is no content-type header. | ||
const contentType = headers.get('content-type'); | ||
if (!contentType) { | ||
return undefined; | ||
} | ||
else if (status >= 100 && status < 200) { | ||
resCode = types_1.ResponseType.XRPCNotSupported; | ||
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`); | ||
} | ||
else if (status >= 200 && status < 300) { | ||
resCode = types_1.ResponseType.Success; | ||
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; | ||
} | ||
else if (status >= 300 && status < 400) { | ||
resCode = types_1.ResponseType.XRPCNotSupported; | ||
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); | ||
} | ||
else if (status >= 400 && status < 500) { | ||
resCode = types_1.ResponseType.InvalidRequest; | ||
if (contentType.startsWith('text/')) { | ||
return new TextEncoder().encode(String(data)); | ||
} | ||
else { | ||
resCode = types_1.ResponseType.InternalServerError; | ||
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); | ||
} | ||
return resCode; | ||
// 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.httpResponseCodeToEnum = httpResponseCodeToEnum; | ||
exports.encodeMethodCallBody = encodeMethodCallBody; | ||
/** | ||
* @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); | ||
} | ||
// If you see this error, consider using a polyfill for ReadableStream. For | ||
// example, the "web-streams-polyfill" package: | ||
// https://github.com/MattiasBuelens/web-streams-polyfill | ||
throw new TypeError('ReadableStream.from() is not supported in this environment. ' + | ||
'It is required to support using iterables as the request body. ' + | ||
'Consider using a polyfill or re-write your code to use a different body type.'); | ||
} | ||
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.5.1-rc.0", | ||
"license": "MIT", | ||
@@ -19,3 +19,3 @@ "description": "atproto HTTP API (XRPC) client library", | ||
"dependencies": { | ||
"zod": "^3.21.4", | ||
"zod": "^3.23.8", | ||
"@atproto/lexicon": "^0.4.0" | ||
@@ -22,0 +22,0 @@ }, |
@@ -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, 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,9 +60,15 @@ ) { | ||
export class ServiceClient { | ||
baseClient: Client | ||
/** @deprecated Use {@link XrpcClient} instead */ | ||
export class ServiceClient extends XrpcClient { | ||
uri: URL | ||
headers: Record<string, 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, Object.entries(this.headers)) | ||
return fetch(new URL(input, this.uri), { ...init, headers }) | ||
}, baseClient.lex) | ||
this.uri = typeof serviceUri === 'string' ? new URL(serviceUri) : serviceUri | ||
@@ -83,90 +84,2 @@ } | ||
} | ||
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 }, | ||
) | ||
} | ||
} |
364
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,37 +193,169 @@ } | ||
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 [name, definition] of defaultHeaders) { | ||
// Ignore undefined values (allowed for convenience when using | ||
// Object.entries). | ||
if (definition === undefined) continue | ||
// Lazy initialization of the headers object | ||
headers ??= new Headers(headersInit) | ||
if (headers.has(name)) continue | ||
const value = typeof definition === 'function' ? definition() : definition | ||
if (typeof value === 'string') headers.set(name, value) | ||
else if (value === null) headers.delete(name) | ||
else throw new TypeError(`Invalid "${name}" 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 | ||
// If you see this error, consider using a polyfill for ReadableStream. For | ||
// example, the "web-streams-polyfill" package: | ||
// https://github.com/MattiasBuelens/web-streams-polyfill | ||
throw new TypeError( | ||
'ReadableStream.from() is not supported in this environment. ' + | ||
'It is required to support using iterables as the request body. ' + | ||
'Consider using a polyfill or re-write your code to use a different body type.', | ||
) | ||
} | ||
@@ -163,29 +367,25 @@ | ||
): 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
91520
36
1604
108
4
Updatedzod@^3.23.8