@atcute/client 2.0.3 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -5
- package/dist/lexicons.d.ts +3 -0
- package/lib/credential-manager.ts +312 -0
- package/lib/fetch-handler.ts +30 -0
- package/lib/index.ts +3 -0
- package/lib/lexicons.ts +1841 -0
- package/lib/rpc.ts +254 -0
- package/lib/utils/did.ts +73 -0
- package/lib/utils/http.ts +27 -0
- package/lib/utils/jwt.ts +76 -0
- package/package.json +11 -7
package/lib/rpc.ts
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { At, Procedures, Queries } from './lexicons.js';
|
|
2
|
+
|
|
3
|
+
import { buildFetchHandler, type FetchHandler, type FetchHandlerObject } from './fetch-handler.js';
|
|
4
|
+
import { mergeHeaders } from './utils/http.js';
|
|
5
|
+
|
|
6
|
+
export type HeadersObject = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
/** Response from XRPC service */
|
|
9
|
+
export interface XRPCResponse<T = any> {
|
|
10
|
+
data: T;
|
|
11
|
+
headers: HeadersObject;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Options for constructing an XRPC error */
|
|
15
|
+
export interface XRPCErrorOptions {
|
|
16
|
+
kind?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
headers?: HeadersObject;
|
|
19
|
+
cause?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Error coming from the XRPC service */
|
|
23
|
+
export class XRPCError extends Error {
|
|
24
|
+
override name = 'XRPCError';
|
|
25
|
+
|
|
26
|
+
/** Response status */
|
|
27
|
+
status: number;
|
|
28
|
+
/** Response headers */
|
|
29
|
+
headers: HeadersObject;
|
|
30
|
+
/** Error kind */
|
|
31
|
+
kind?: string;
|
|
32
|
+
/** Error description */
|
|
33
|
+
description?: string;
|
|
34
|
+
|
|
35
|
+
constructor(status: number, { kind, description, headers, cause }: XRPCErrorOptions = {}) {
|
|
36
|
+
super(`${kind || 'UnspecifiedKind'} > ${description || `Unspecified error description`}`, { cause });
|
|
37
|
+
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.kind = kind;
|
|
40
|
+
this.description = description;
|
|
41
|
+
this.headers = headers || {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Service proxy options */
|
|
46
|
+
export interface XRPCProxyOptions {
|
|
47
|
+
type: 'atproto_pds' | 'atproto_labeler' | 'bsky_fg' | 'bsky_notif' | ({} & string);
|
|
48
|
+
service: At.DID;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Options for constructing an XRPC */
|
|
52
|
+
export interface XRPCOptions {
|
|
53
|
+
handler: FetchHandler | FetchHandlerObject;
|
|
54
|
+
proxy?: XRPCProxyOptions;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** XRPC request options */
|
|
58
|
+
export interface XRPCRequestOptions {
|
|
59
|
+
type: 'get' | 'post';
|
|
60
|
+
nsid: string;
|
|
61
|
+
headers?: HeadersInit;
|
|
62
|
+
params?: Record<string, unknown>;
|
|
63
|
+
data?: FormData | Blob | ArrayBufferView | Record<string, unknown>;
|
|
64
|
+
signal?: AbortSignal;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** XRPC response */
|
|
68
|
+
export interface XRPCResponse<T = any> {
|
|
69
|
+
data: T;
|
|
70
|
+
headers: HeadersObject;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Base options for the query/procedure request */
|
|
74
|
+
interface BaseRPCOptions {
|
|
75
|
+
/** Request headers to make */
|
|
76
|
+
headers?: HeadersInit;
|
|
77
|
+
/** Signal for aborting the request */
|
|
78
|
+
signal?: AbortSignal;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Options for the query/procedure request */
|
|
82
|
+
export type RPCOptions<T> = BaseRPCOptions &
|
|
83
|
+
(T extends { params: any } ? { params: T['params'] } : {}) &
|
|
84
|
+
(T extends { input: any } ? { data: T['input'] } : {});
|
|
85
|
+
|
|
86
|
+
type OutputOf<T> = T extends { output: any } ? T['output'] : never;
|
|
87
|
+
|
|
88
|
+
export class XRPC {
|
|
89
|
+
handle: FetchHandler;
|
|
90
|
+
proxy: XRPCProxyOptions | undefined;
|
|
91
|
+
|
|
92
|
+
constructor({ handler, proxy }: XRPCOptions) {
|
|
93
|
+
this.handle = buildFetchHandler(handler);
|
|
94
|
+
this.proxy = proxy;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Makes a query (GET) request
|
|
99
|
+
* @param nsid Namespace ID of a query endpoint
|
|
100
|
+
* @param options Options to include like parameters
|
|
101
|
+
* @returns The response of the request
|
|
102
|
+
*/
|
|
103
|
+
get<K extends keyof Queries>(
|
|
104
|
+
nsid: K,
|
|
105
|
+
options: RPCOptions<Queries[K]>,
|
|
106
|
+
): Promise<XRPCResponse<OutputOf<Queries[K]>>> {
|
|
107
|
+
return this.request({ type: 'get', nsid: nsid, ...(options as any) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Makes a procedure (POST) request
|
|
112
|
+
* @param nsid Namespace ID of a procedure endpoint
|
|
113
|
+
* @param options Options to include like input body or parameters
|
|
114
|
+
* @returns The response of the request
|
|
115
|
+
*/
|
|
116
|
+
call<K extends keyof Procedures>(
|
|
117
|
+
nsid: K,
|
|
118
|
+
options: RPCOptions<Procedures[K]>,
|
|
119
|
+
): Promise<XRPCResponse<OutputOf<Procedures[K]>>> {
|
|
120
|
+
return this.request({ type: 'post', nsid: nsid, ...(options as any) });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Makes a request to the XRPC service */
|
|
124
|
+
async request(options: XRPCRequestOptions): Promise<XRPCResponse> {
|
|
125
|
+
const data = options.data;
|
|
126
|
+
|
|
127
|
+
const url = `/xrpc/${options.nsid}` + constructSearchParams(options.params);
|
|
128
|
+
const isInputJson = isJsonValue(data);
|
|
129
|
+
|
|
130
|
+
const response = await this.handle(url, {
|
|
131
|
+
method: options.type,
|
|
132
|
+
signal: options.signal,
|
|
133
|
+
body: isInputJson ? JSON.stringify(data) : data,
|
|
134
|
+
headers: mergeHeaders(options.headers, {
|
|
135
|
+
'content-type': isInputJson ? 'application/json' : null,
|
|
136
|
+
'atproto-proxy': constructProxyHeader(this.proxy),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const responseStatus = response.status;
|
|
141
|
+
const responseHeaders = Object.fromEntries(response.headers);
|
|
142
|
+
const responseType = responseHeaders['content-type'];
|
|
143
|
+
|
|
144
|
+
let promise: Promise<unknown> | undefined;
|
|
145
|
+
let ret: unknown;
|
|
146
|
+
|
|
147
|
+
if (responseType) {
|
|
148
|
+
if (responseType.startsWith('application/json')) {
|
|
149
|
+
promise = response.json();
|
|
150
|
+
} else if (responseType.startsWith('text/')) {
|
|
151
|
+
promise = response.text();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
ret = await (promise || response.arrayBuffer().then((buffer) => new Uint8Array(buffer)));
|
|
157
|
+
} catch (err) {
|
|
158
|
+
throw new XRPCError(2, {
|
|
159
|
+
cause: err,
|
|
160
|
+
kind: 'InvalidResponse',
|
|
161
|
+
description: `Failed to parse response body`,
|
|
162
|
+
headers: responseHeaders,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (responseStatus === 200) {
|
|
167
|
+
return {
|
|
168
|
+
data: ret,
|
|
169
|
+
headers: responseHeaders,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (isErrorResponse(ret)) {
|
|
174
|
+
throw new XRPCError(responseStatus, {
|
|
175
|
+
kind: ret.error,
|
|
176
|
+
description: ret.message,
|
|
177
|
+
headers: responseHeaders,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
throw new XRPCError(responseStatus, { headers: responseHeaders });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const constructProxyHeader = (proxy: XRPCProxyOptions | undefined): string | null => {
|
|
186
|
+
if (proxy) {
|
|
187
|
+
return `${proxy.service}#${proxy.type}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return null;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const constructSearchParams = (params: Record<string, unknown> | undefined): string => {
|
|
194
|
+
let searchParams: URLSearchParams | undefined;
|
|
195
|
+
|
|
196
|
+
for (const key in params) {
|
|
197
|
+
const value = params[key];
|
|
198
|
+
|
|
199
|
+
if (value !== undefined) {
|
|
200
|
+
searchParams ??= new URLSearchParams();
|
|
201
|
+
|
|
202
|
+
if (Array.isArray(value)) {
|
|
203
|
+
for (let idx = 0, len = value.length; idx < len; idx++) {
|
|
204
|
+
const val = value[idx];
|
|
205
|
+
searchParams.append(key, '' + val);
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
searchParams.set(key, '' + value);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return searchParams ? `?` + searchParams.toString() : '';
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const isJsonValue = (o: unknown): o is Record<string, unknown> => {
|
|
217
|
+
if (typeof o !== 'object' || o === null) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if ('toJSON' in o) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const proto = Object.getPrototypeOf(o);
|
|
226
|
+
return proto === null || proto === Object.prototype;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const isErrorResponse = (value: any): value is ErrorResponseBody => {
|
|
230
|
+
if (typeof value !== 'object' || value === null) {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const kindType = typeof value.error;
|
|
235
|
+
const messageType = typeof value.message;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
(kindType === 'undefined' || kindType === 'string') &&
|
|
239
|
+
(messageType === 'undefined' || messageType === 'string')
|
|
240
|
+
);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
interface ErrorResponseBody {
|
|
244
|
+
error?: string;
|
|
245
|
+
message?: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const clone = (rpc: XRPC): XRPC => {
|
|
249
|
+
return new XRPC({ handler: rpc.handle, proxy: rpc.proxy });
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export const withProxy = (rpc: XRPC, options: XRPCProxyOptions) => {
|
|
253
|
+
return new XRPC({ handler: rpc.handle, proxy: options });
|
|
254
|
+
};
|
package/lib/utils/did.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* DID document-related functionalities.
|
|
4
|
+
* This module is exported for convenience and is no way part of public API,
|
|
5
|
+
* it can be removed at any time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Retrieves AT Protocol PDS endpoint from the DID document, if available
|
|
10
|
+
* @param doc DID document
|
|
11
|
+
* @returns The PDS endpoint, if available
|
|
12
|
+
*/
|
|
13
|
+
export const getPdsEndpoint = (doc: DidDocument): string | undefined => {
|
|
14
|
+
return getServiceEndpoint(doc, '#atproto_pds', 'AtprotoPersonalDataServer');
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Retrieve a service endpoint from the DID document, if available
|
|
19
|
+
* @param doc DID document
|
|
20
|
+
* @param serviceId Service ID
|
|
21
|
+
* @param serviceType Service type
|
|
22
|
+
* @returns The requested service endpoint, if available
|
|
23
|
+
*/
|
|
24
|
+
export const getServiceEndpoint = (
|
|
25
|
+
doc: DidDocument,
|
|
26
|
+
serviceId: string,
|
|
27
|
+
serviceType: string,
|
|
28
|
+
): string | undefined => {
|
|
29
|
+
const did = doc.id;
|
|
30
|
+
|
|
31
|
+
const didServiceId = did + serviceId;
|
|
32
|
+
const found = doc.service?.find((service) => service.id === serviceId || service.id === didServiceId);
|
|
33
|
+
|
|
34
|
+
if (!found || found.type !== serviceType || typeof found.serviceEndpoint !== 'string') {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return validateUrl(found.serviceEndpoint);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const validateUrl = (urlStr: string): string | undefined => {
|
|
42
|
+
let url;
|
|
43
|
+
try {
|
|
44
|
+
url = new URL(urlStr);
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const proto = url.protocol;
|
|
50
|
+
|
|
51
|
+
if (url.hostname && (proto === 'http:' || proto === 'https:')) {
|
|
52
|
+
return urlStr;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* DID document
|
|
58
|
+
*/
|
|
59
|
+
export interface DidDocument {
|
|
60
|
+
id: string;
|
|
61
|
+
alsoKnownAs?: string[];
|
|
62
|
+
verificationMethod?: Array<{
|
|
63
|
+
id: string;
|
|
64
|
+
type: string;
|
|
65
|
+
controller: string;
|
|
66
|
+
publicKeyMultibase?: string;
|
|
67
|
+
}>;
|
|
68
|
+
service?: Array<{
|
|
69
|
+
id: string;
|
|
70
|
+
type: string;
|
|
71
|
+
serviceEndpoint: string | Record<string, unknown>;
|
|
72
|
+
}>;
|
|
73
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Assortment of HTTP utilities
|
|
4
|
+
* This module is exported for convenience and is no way part of public API,
|
|
5
|
+
* it can be removed at any time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const mergeHeaders = (
|
|
9
|
+
init: HeadersInit | undefined,
|
|
10
|
+
defaults: Record<string, string | null>,
|
|
11
|
+
): HeadersInit | undefined => {
|
|
12
|
+
let headers: Headers | undefined;
|
|
13
|
+
|
|
14
|
+
for (const name in defaults) {
|
|
15
|
+
const value = defaults[name];
|
|
16
|
+
|
|
17
|
+
if (value !== null) {
|
|
18
|
+
headers ??= new Headers(init);
|
|
19
|
+
|
|
20
|
+
if (!headers.has(name)) {
|
|
21
|
+
headers.set(name, value);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return headers ?? init;
|
|
27
|
+
};
|
package/lib/utils/jwt.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* JWT decoding utilities for session resumption checks.
|
|
4
|
+
* This module is exported for convenience and is no way part of public API,
|
|
5
|
+
* it can be removed at any time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Decodes a JWT token
|
|
10
|
+
* @param token The token string
|
|
11
|
+
* @returns JSON object from the token
|
|
12
|
+
*/
|
|
13
|
+
export const decodeJwt = (token: string): unknown => {
|
|
14
|
+
const pos = 1;
|
|
15
|
+
const part = token.split('.')[1];
|
|
16
|
+
|
|
17
|
+
let decoded: string;
|
|
18
|
+
|
|
19
|
+
if (typeof part !== 'string') {
|
|
20
|
+
throw new Error('invalid token: missing part ' + (pos + 1));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
decoded = base64UrlDecode(part);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
throw new Error('invalid token: invalid b64 for part ' + (pos + 1) + ' (' + (e as Error).message + ')');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(decoded);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
throw new Error('invalid token: invalid json for part ' + (pos + 1) + ' (' + (e as Error).message + ')');
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decodes a URL-safe Base64 string
|
|
38
|
+
* @param str URL-safe Base64 that needed to be decoded
|
|
39
|
+
* @returns The actual string
|
|
40
|
+
*/
|
|
41
|
+
export const base64UrlDecode = (str: string): string => {
|
|
42
|
+
let output = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
43
|
+
|
|
44
|
+
switch (output.length % 4) {
|
|
45
|
+
case 0:
|
|
46
|
+
break;
|
|
47
|
+
case 2:
|
|
48
|
+
output += '==';
|
|
49
|
+
break;
|
|
50
|
+
case 3:
|
|
51
|
+
output += '=';
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
throw new Error('base64 string is not of the correct length');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return b64DecodeUnicode(output);
|
|
59
|
+
} catch {
|
|
60
|
+
return atob(output);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const b64DecodeUnicode = (str: string): string => {
|
|
65
|
+
return decodeURIComponent(
|
|
66
|
+
atob(str).replace(/(.)/g, (_m, p) => {
|
|
67
|
+
let code = p.charCodeAt(0).toString(16).toUpperCase();
|
|
68
|
+
|
|
69
|
+
if (code.length < 2) {
|
|
70
|
+
code = '0' + code;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return '%' + code;
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@atcute/client",
|
|
4
|
-
"version": "2.0.
|
|
4
|
+
"version": "2.0.4",
|
|
5
5
|
"description": "lightweight and cute API client for AT Protocol",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
|
-
"url": "https://
|
|
8
|
+
"url": "https://github.com/mary-ext/atcute",
|
|
9
|
+
"directory": "packages/core/client"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
|
-
"dist/"
|
|
12
|
+
"dist/",
|
|
13
|
+
"lib/",
|
|
14
|
+
"!lib/**/*.bench.ts",
|
|
15
|
+
"!lib/**/*.test.ts"
|
|
12
16
|
],
|
|
13
17
|
"exports": {
|
|
14
18
|
".": "./dist/index.js",
|
|
@@ -18,10 +22,10 @@
|
|
|
18
22
|
"./utils/jwt": "./dist/utils/jwt.js"
|
|
19
23
|
},
|
|
20
24
|
"devDependencies": {
|
|
21
|
-
"@vitest/coverage-v8": "^2.1.
|
|
22
|
-
"vitest": "^2.1.
|
|
23
|
-
"@atcute/
|
|
24
|
-
"@atcute/
|
|
25
|
+
"@vitest/coverage-v8": "^2.1.3",
|
|
26
|
+
"vitest": "^2.1.3",
|
|
27
|
+
"@atcute/internal-dev-env": "^1.0.1",
|
|
28
|
+
"@atcute/lex-cli": "^1.0.3"
|
|
25
29
|
},
|
|
26
30
|
"scripts": {
|
|
27
31
|
"build": "tsc --project tsconfig.build.json",
|