@atproto/lex-client 0.0.15 → 0.0.17

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.
@@ -0,0 +1,333 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildXrpcRequestHeaders,
4
+ isAsyncIterable,
5
+ isBlobLike,
6
+ toReadableStream,
7
+ toReadableStreamPonyfill,
8
+ } from './util.js'
9
+
10
+ // ============================================================================
11
+ // isBlobLike
12
+ // ============================================================================
13
+
14
+ describe(isBlobLike, () => {
15
+ it('returns true for native Blob', () => {
16
+ expect(isBlobLike(new Blob(['hello']))).toBe(true)
17
+ })
18
+
19
+ it('returns true for native File', () => {
20
+ expect(isBlobLike(new File(['hello'], 'test.txt'))).toBe(true)
21
+ })
22
+
23
+ it('returns false for null', () => {
24
+ expect(isBlobLike(null)).toBe(false)
25
+ })
26
+
27
+ it('returns false for undefined', () => {
28
+ expect(isBlobLike(undefined)).toBe(false)
29
+ })
30
+
31
+ it('returns false for primitives', () => {
32
+ expect(isBlobLike(42)).toBe(false)
33
+ expect(isBlobLike('string')).toBe(false)
34
+ expect(isBlobLike(true)).toBe(false)
35
+ })
36
+
37
+ it('returns false for plain objects', () => {
38
+ expect(isBlobLike({})).toBe(false)
39
+ expect(isBlobLike({ stream: () => {} })).toBe(false)
40
+ })
41
+
42
+ it('returns true for Blob-like objects with [Symbol.toStringTag] = "Blob"', () => {
43
+ const blobLike = {
44
+ [Symbol.toStringTag]: 'Blob',
45
+ stream: () => new ReadableStream(),
46
+ }
47
+ expect(isBlobLike(blobLike)).toBe(true)
48
+ })
49
+
50
+ it('returns true for File-like objects with [Symbol.toStringTag] = "File"', () => {
51
+ const fileLike = {
52
+ [Symbol.toStringTag]: 'File',
53
+ stream: () => new ReadableStream(),
54
+ }
55
+ expect(isBlobLike(fileLike)).toBe(true)
56
+ })
57
+
58
+ it('returns false for objects with Blob tag but no stream method', () => {
59
+ const notBlob = {
60
+ [Symbol.toStringTag]: 'Blob',
61
+ }
62
+ expect(isBlobLike(notBlob)).toBe(false)
63
+ })
64
+
65
+ it('returns false for objects with Blob tag but non-function stream', () => {
66
+ const notBlob = {
67
+ [Symbol.toStringTag]: 'Blob',
68
+ stream: 'not a function',
69
+ }
70
+ expect(isBlobLike(notBlob)).toBe(false)
71
+ })
72
+ })
73
+
74
+ // ============================================================================
75
+ // isAsyncIterable
76
+ // ============================================================================
77
+
78
+ describe(isAsyncIterable, () => {
79
+ it('returns true for async generators', () => {
80
+ async function* gen() {
81
+ yield 1
82
+ }
83
+ expect(isAsyncIterable(gen())).toBe(true)
84
+ })
85
+
86
+ it('returns true for objects with Symbol.asyncIterator', () => {
87
+ const iterable = {
88
+ [Symbol.asyncIterator]() {
89
+ return { next: async () => ({ done: true, value: undefined }) }
90
+ },
91
+ }
92
+ expect(isAsyncIterable(iterable)).toBe(true)
93
+ })
94
+
95
+ it('returns false for null', () => {
96
+ expect(isAsyncIterable(null)).toBe(false)
97
+ })
98
+
99
+ it('returns false for undefined', () => {
100
+ expect(isAsyncIterable(undefined)).toBe(false)
101
+ })
102
+
103
+ it('returns false for plain objects', () => {
104
+ expect(isAsyncIterable({})).toBe(false)
105
+ })
106
+
107
+ it('returns false for sync iterables', () => {
108
+ expect(isAsyncIterable([1, 2, 3])).toBe(false)
109
+ expect(isAsyncIterable('string')).toBe(false)
110
+ })
111
+ })
112
+
113
+ // ============================================================================
114
+ // buildXrpcRequestHeaders
115
+ // ============================================================================
116
+
117
+ describe(buildXrpcRequestHeaders, () => {
118
+ it('returns empty headers when no options are set', () => {
119
+ const headers = buildXrpcRequestHeaders({})
120
+ expect([...headers.entries()]).toEqual([])
121
+ })
122
+
123
+ it('sets atproto-proxy header from service option', () => {
124
+ const headers = buildXrpcRequestHeaders({
125
+ service: 'did:plc:1234#atproto_labeler',
126
+ })
127
+ expect(headers.get('atproto-proxy')).toBe('did:plc:1234#atproto_labeler')
128
+ })
129
+
130
+ it('does not override existing atproto-proxy header', () => {
131
+ const headers = buildXrpcRequestHeaders({
132
+ headers: { 'atproto-proxy': 'did:plc:existing#service' },
133
+ service: 'did:plc:new#service',
134
+ })
135
+ expect(headers.get('atproto-proxy')).toBe('did:plc:existing#service')
136
+ })
137
+
138
+ it('sets atproto-accept-labelers from labelers option', () => {
139
+ const headers = buildXrpcRequestHeaders({
140
+ labelers: ['did:plc:labeler1', 'did:plc:labeler2'] as const,
141
+ })
142
+ expect(headers.get('atproto-accept-labelers')).toBe(
143
+ 'did:plc:labeler1, did:plc:labeler2',
144
+ )
145
+ })
146
+
147
+ it('appends to existing atproto-accept-labelers header', () => {
148
+ const headers = buildXrpcRequestHeaders({
149
+ headers: { 'atproto-accept-labelers': 'did:plc:existing' },
150
+ labelers: ['did:plc:new'] as const,
151
+ })
152
+ expect(headers.get('atproto-accept-labelers')).toBe(
153
+ 'did:plc:new, did:plc:existing',
154
+ )
155
+ })
156
+
157
+ it('passes through base headers', () => {
158
+ const headers = buildXrpcRequestHeaders({
159
+ headers: { Authorization: 'Bearer token123' },
160
+ })
161
+ expect(headers.get('Authorization')).toBe('Bearer token123')
162
+ })
163
+
164
+ it('accepts Headers instance as base headers', () => {
165
+ const base = new Headers({ 'X-Custom': 'value' })
166
+ const headers = buildXrpcRequestHeaders({ headers: base })
167
+ expect(headers.get('X-Custom')).toBe('value')
168
+ })
169
+
170
+ it('sets empty header for empty labelers iterable', () => {
171
+ const headers = buildXrpcRequestHeaders({ labelers: [] })
172
+ // An empty array still sets the header (to empty string), distinguishing
173
+ // "no labelers requested" from "labelers option not provided"
174
+ expect(headers.has('atproto-accept-labelers')).toBe(true)
175
+ expect(headers.get('atproto-accept-labelers')).toBe('')
176
+ })
177
+ })
178
+
179
+ // ============================================================================
180
+ // toReadableStream
181
+ // ============================================================================
182
+
183
+ describe(toReadableStream, () => {
184
+ it('converts async iterable to ReadableStream', async () => {
185
+ async function* gen() {
186
+ yield new Uint8Array([1, 2])
187
+ yield new Uint8Array([3, 4])
188
+ }
189
+
190
+ const stream = toReadableStream(gen())
191
+ const reader = stream.getReader()
192
+
193
+ const chunk1 = await reader.read()
194
+ expect(chunk1.done).toBe(false)
195
+ expect(chunk1.value).toEqual(new Uint8Array([1, 2]))
196
+
197
+ const chunk2 = await reader.read()
198
+ expect(chunk2.done).toBe(false)
199
+ expect(chunk2.value).toEqual(new Uint8Array([3, 4]))
200
+
201
+ const end = await reader.read()
202
+ expect(end.done).toBe(true)
203
+ })
204
+
205
+ it('handles empty async iterable', async () => {
206
+ async function* gen() {
207
+ // yields nothing
208
+ }
209
+
210
+ const stream = toReadableStream(gen())
211
+ const reader = stream.getReader()
212
+
213
+ const result = await reader.read()
214
+ expect(result.done).toBe(true)
215
+ })
216
+
217
+ it('can be consumed with Response API', async () => {
218
+ async function* gen() {
219
+ yield new TextEncoder().encode('hello ')
220
+ yield new TextEncoder().encode('world')
221
+ }
222
+
223
+ const stream = toReadableStream(gen())
224
+ const response = new Response(stream)
225
+ const text = await response.text()
226
+ expect(text).toBe('hello world')
227
+ })
228
+
229
+ it('propagates errors from the async iterable', async () => {
230
+ async function* gen() {
231
+ yield new Uint8Array([1])
232
+ throw new Error('stream error')
233
+ }
234
+
235
+ const stream = toReadableStream(gen())
236
+ const reader = stream.getReader()
237
+
238
+ // First chunk succeeds
239
+ await reader.read()
240
+
241
+ // Second read should reject
242
+ await expect(reader.read()).rejects.toThrow('stream error')
243
+ })
244
+ })
245
+
246
+ // ============================================================================
247
+ // toReadableStreamPonyfill
248
+ // ============================================================================
249
+
250
+ describe(toReadableStreamPonyfill, () => {
251
+ it('converts async iterable to ReadableStream', async () => {
252
+ async function* gen() {
253
+ yield new Uint8Array([1, 2])
254
+ yield new Uint8Array([3, 4])
255
+ }
256
+
257
+ const stream = toReadableStreamPonyfill(gen())
258
+ const reader = stream.getReader()
259
+
260
+ const chunk1 = await reader.read()
261
+ expect(chunk1.done).toBe(false)
262
+ expect(chunk1.value).toEqual(new Uint8Array([1, 2]))
263
+
264
+ const chunk2 = await reader.read()
265
+ expect(chunk2.done).toBe(false)
266
+ expect(chunk2.value).toEqual(new Uint8Array([3, 4]))
267
+
268
+ const end = await reader.read()
269
+ expect(end.done).toBe(true)
270
+ })
271
+
272
+ it('handles empty async iterable', async () => {
273
+ async function* gen() {
274
+ // yields nothing
275
+ }
276
+
277
+ const stream = toReadableStreamPonyfill(gen())
278
+ const reader = stream.getReader()
279
+
280
+ const result = await reader.read()
281
+ expect(result.done).toBe(true)
282
+ })
283
+
284
+ it('can be consumed with Response API', async () => {
285
+ async function* gen() {
286
+ yield new TextEncoder().encode('hello ')
287
+ yield new TextEncoder().encode('world')
288
+ }
289
+
290
+ const stream = toReadableStreamPonyfill(gen())
291
+ const response = new Response(stream)
292
+ const text = await response.text()
293
+ expect(text).toBe('hello world')
294
+ })
295
+
296
+ it('propagates errors from the async iterable', async () => {
297
+ async function* gen() {
298
+ yield new Uint8Array([1])
299
+ throw new Error('stream error')
300
+ }
301
+
302
+ const stream = toReadableStreamPonyfill(gen())
303
+ const reader = stream.getReader()
304
+
305
+ await reader.read()
306
+ await expect(reader.read()).rejects.toThrow('stream error')
307
+ })
308
+
309
+ it('calls iterator.return() on cancel', async () => {
310
+ let returned = false
311
+ const iterable: AsyncIterable<Uint8Array> = {
312
+ [Symbol.asyncIterator]() {
313
+ return {
314
+ async next() {
315
+ return { done: false, value: new Uint8Array([1]) }
316
+ },
317
+ async return() {
318
+ returned = true
319
+ return { done: true, value: undefined }
320
+ },
321
+ }
322
+ },
323
+ }
324
+
325
+ const stream = toReadableStreamPonyfill(iterable)
326
+ const reader = stream.getReader()
327
+
328
+ await reader.read()
329
+ await reader.cancel()
330
+
331
+ expect(returned).toBe(true)
332
+ })
333
+ })
package/src/util.ts CHANGED
@@ -1,32 +1,23 @@
1
- import {
2
- DidString,
3
- InferMethodOutput,
4
- InferMethodOutputBody,
5
- Procedure,
6
- Query,
7
- } from '@atproto/lex-schema'
1
+ import type { DidString, Service } from './types.js'
8
2
 
9
- /**
10
- * The body type of an XRPC response, inferred from the method's output schema.
11
- *
12
- * For JSON responses, this is the parsed LexValue. For binary responses,
13
- * this is a Uint8Array.
14
- *
15
- * @typeParam M - The XRPC method type (Procedure or Query)
16
- */
17
- export type XrpcResponseBody<M extends Procedure | Query = Procedure | Query> =
18
- InferMethodOutputBody<M, Uint8Array>
3
+ export function applyDefaults<
4
+ TDefaults extends Record<string, unknown>,
5
+ TOptions extends {
6
+ [K in keyof TDefaults]?: TDefaults[K]
7
+ },
8
+ >(options: TOptions, defaults: TDefaults): TOptions & TDefaults {
9
+ const combined: Partial<TDefaults> = { ...options }
19
10
 
20
- /**
21
- * The full payload type of an XRPC response, including body and encoding.
22
- *
23
- * Returns `null` for methods that have no output.
24
- *
25
- * @typeParam M - The XRPC method type (Procedure or Query)
26
- */
27
- export type XrpcResponsePayload<
28
- M extends Procedure | Query = Procedure | Query,
29
- > = InferMethodOutput<M, Uint8Array>
11
+ // @NOTE We make sure that options with an explicit `undefined` value get the
12
+ // default, since spreading doesn't override with `undefined`.
13
+ for (const key of Object.keys(defaults) as (keyof typeof defaults)[]) {
14
+ if (options[key] === undefined) {
15
+ combined[key] = defaults[key]
16
+ }
17
+ }
18
+
19
+ return combined as TOptions & TDefaults
20
+ }
30
21
 
31
22
  /**
32
23
  * Type guard to check if a value is {@link Blob}-like.
@@ -64,6 +55,17 @@ export function isAsyncIterable<T>(
64
55
  )
65
56
  }
66
57
 
58
+ export type XrpcRequestHeadersOptions = {
59
+ /** Additional HTTP headers to include in the request. */
60
+ headers?: HeadersInit
61
+
62
+ /** Labeler DIDs to request labels from for content moderation. */
63
+ labelers?: Iterable<DidString>
64
+
65
+ /** Service proxy identifier for routing requests through a specific service. */
66
+ service?: Service
67
+ }
68
+
67
69
  /**
68
70
  * Builds HTTP headers for AT Protocol requests.
69
71
  *
@@ -71,17 +73,12 @@ export function isAsyncIterable<T>(
71
73
  * - `atproto-proxy`: Service routing header (if service is specified)
72
74
  * - `atproto-accept-labelers`: Comma-separated list of labeler DIDs
73
75
  *
74
- * @param options - Header building options
75
- * @param options.headers - Base headers to include
76
- * @param options.service - Service proxy identifier
77
- * @param options.labelers - Labeler DIDs to request labels from
76
+ * @see {@link XrpcRequestHeadersOptions}
78
77
  * @returns A new Headers object with AT Protocol headers added
79
78
  */
80
- export function buildAtprotoHeaders(options: {
81
- headers?: HeadersInit
82
- service?: `${DidString}#${string}`
83
- labelers?: Iterable<DidString>
84
- }): Headers {
79
+ export function buildXrpcRequestHeaders(
80
+ options: XrpcRequestHeadersOptions,
81
+ ): Headers {
85
82
  const headers = new Headers(options?.headers)
86
83
 
87
84
  if (options.service && !headers.has('atproto-proxy')) {
@@ -104,10 +101,19 @@ export function toReadableStream(
104
101
  data: AsyncIterable<Uint8Array>,
105
102
  ): ReadableStream<Uint8Array> {
106
103
  // Use the native ReadableStream.from() if available.
104
+
105
+ /* v8 ignore next -- @preserve */
107
106
  if ('from' in ReadableStream && typeof ReadableStream.from === 'function') {
108
107
  return ReadableStream.from(data)
109
108
  }
110
109
 
110
+ /* v8 ignore next -- @preserve */
111
+ return toReadableStreamPonyfill(data)
112
+ }
113
+
114
+ export function toReadableStreamPonyfill(
115
+ data: AsyncIterable<Uint8Array>,
116
+ ): ReadableStream<Uint8Array> {
111
117
  let iterator: AsyncIterator<Uint8Array> | undefined
112
118
  return new ReadableStream({
113
119
  async pull(controller) {