@atproto/xrpc-server 0.10.14 → 0.10.15

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.
@@ -9,9 +9,16 @@ import { LexiconDoc } from '@atproto/lexicon'
9
9
  import { ResponseType, XrpcClient } from '@atproto/xrpc'
10
10
  import * as xrpcServer from '../src'
11
11
  import { logger } from '../src/logger'
12
- import { closeServer, createServer } from './_util'
12
+ import {
13
+ buildAddLexicons,
14
+ buildMethodLexicons,
15
+ closeServer,
16
+ createServer,
17
+ } from './_util'
13
18
 
14
- const LEXICONS: LexiconDoc[] = [
19
+ const BLOB_LIMIT = 5000
20
+
21
+ const LEXICONS = [
15
22
  {
16
23
  lexicon: 1,
17
24
  id: 'io.example.validationTest',
@@ -85,464 +92,486 @@ const LEXICONS: LexiconDoc[] = [
85
92
  },
86
93
  },
87
94
  },
88
- ]
89
-
90
- const BLOB_LIMIT = 5000
95
+ ] as const satisfies LexiconDoc[]
91
96
 
92
- async function consumeInput(
93
- input: Readable | string | object,
94
- ): Promise<Buffer> {
95
- if (Buffer.isBuffer(input)) {
96
- return input
97
- }
98
- if (typeof input === 'string') {
99
- return Buffer.from(input)
100
- }
101
- if (input instanceof Readable) {
102
- try {
103
- return Buffer.concat(await input.toArray())
104
- } catch (err) {
105
- if (err instanceof xrpcServer.XRPCError) {
106
- throw err
107
- } else {
108
- throw new xrpcServer.XRPCError(
109
- ResponseType.InvalidRequest,
110
- 'unable to read input',
111
- )
112
- }
113
- }
114
- }
115
- throw new Error('Invalid input')
116
- }
117
-
118
- describe('Bodies', () => {
119
- let s: http.Server
120
- const server = xrpcServer.createServer(LEXICONS, {
121
- payload: {
122
- blobLimit: BLOB_LIMIT,
123
- },
124
- })
125
- server.method('io.example.validationTest', (ctx) => {
97
+ const handlers = {
98
+ 'io.example.validationTest': (ctx: xrpcServer.HandlerContext) => {
126
99
  assert(!(ctx.input?.body instanceof Readable), 'Input is readable')
127
100
 
128
101
  return {
129
- encoding: 'json',
102
+ encoding: 'application/json',
130
103
  body: ctx.input?.body ?? null,
131
104
  }
132
- })
133
- server.method('io.example.validationTestTwo', () => ({
134
- encoding: 'json',
135
- body: { wrong: 'data' },
136
- }))
137
- server.method('io.example.blobTest', async (ctx) => {
105
+ },
106
+ 'io.example.validationTestTwo': () => {
107
+ return {
108
+ encoding: 'application/json',
109
+ body: { wrong: 'data' },
110
+ }
111
+ },
112
+ 'io.example.blobTest': async (ctx: xrpcServer.HandlerContext) => {
138
113
  assert(ctx.input?.body != null, 'Input body is required')
139
114
  const buffer = await consumeInput(ctx.input.body)
140
115
  const cid = await cidForCbor(buffer)
141
116
  return {
142
- encoding: 'json',
117
+ encoding: 'application/json',
143
118
  body: { cid: cid.toString() },
144
119
  }
145
- })
120
+ },
121
+ }
146
122
 
147
- let client: XrpcClient
148
- let url: string
149
- beforeAll(async () => {
150
- s = await createServer(server)
151
- const { port } = s.address() as AddressInfo
152
- url = `http://localhost:${port}`
153
- client = new XrpcClient(url, LEXICONS)
154
- })
155
- afterAll(async () => {
156
- await closeServer(s)
157
- })
123
+ for (const buildServer of [buildMethodLexicons, buildAddLexicons]) {
124
+ describe(buildServer, () => {
125
+ let s: http.Server
126
+ let client: XrpcClient
127
+ let url: string
128
+ beforeAll(async () => {
129
+ const server = await buildServer(LEXICONS, handlers, {
130
+ payload: {
131
+ blobLimit: BLOB_LIMIT,
132
+ },
133
+ })
134
+ s = await createServer(server)
135
+ const { port } = s.address() as AddressInfo
136
+ url = `http://localhost:${port}`
137
+ client = new XrpcClient(url, LEXICONS)
138
+ })
139
+ afterAll(async () => {
140
+ if (s) await closeServer(s)
141
+ })
158
142
 
159
- it('validates input and output bodies', async () => {
160
- const res1 = await client.call(
161
- 'io.example.validationTest',
162
- {},
163
- {
164
- foo: 'hello',
165
- bar: 123,
166
- },
167
- )
168
- expect(res1.success).toBeTruthy()
169
- expect(res1.data.foo).toBe('hello')
170
- expect(res1.data.bar).toBe(123)
171
-
172
- await expect(client.call('io.example.validationTest', {})).rejects.toThrow(
173
- 'Request encoding (Content-Type) required but not provided',
174
- )
175
- await expect(
176
- client.call('io.example.validationTest', {}, {}),
177
- ).rejects.toThrow(`Input must have the property "foo"`)
178
- await expect(
179
- client.call('io.example.validationTest', {}, { foo: 123 }),
180
- ).rejects.toThrow(`Input/foo must be a string`)
181
- await expect(
182
- client.call(
143
+ test('io.example.validationTest', async () => {
144
+ const res = await client.call(
183
145
  'io.example.validationTest',
184
146
  {},
185
147
  { foo: 'hello', bar: 123 },
186
- { encoding: 'image/jpeg' },
187
- ),
188
- ).rejects.toThrow(`Unable to encode object as image/jpeg data`)
189
- await expect(
190
- client.call(
191
- 'io.example.validationTest',
192
- {},
193
- // Does not need to be a valid jpeg
194
- new Blob([randomBytes(123)], { type: 'image/jpeg' }),
195
- ),
196
- ).rejects.toThrow(`Wrong request encoding (Content-Type): image/jpeg`)
197
- await expect(
198
- client.call(
199
- 'io.example.validationTest',
200
- {},
201
- (() => {
202
- const formData = new FormData()
203
- formData.append('foo', 'bar')
204
- return formData
205
- })(),
206
- ),
207
- ).rejects.toThrow(
208
- `Wrong request encoding (Content-Type): multipart/form-data`,
209
- )
210
- await expect(
211
- client.call(
212
- 'io.example.validationTest',
213
- {},
214
- new URLSearchParams([['foo', 'bar']]),
215
- ),
216
- ).rejects.toThrow(
217
- `Wrong request encoding (Content-Type): application/x-www-form-urlencoded`,
218
- )
219
- await expect(
220
- client.call(
221
- 'io.example.validationTest',
222
- {},
223
- new Blob([new Uint8Array([1])]),
224
- ),
225
- ).rejects.toThrow(
226
- `Wrong request encoding (Content-Type): application/octet-stream`,
227
- )
228
- await expect(
229
- client.call(
230
- 'io.example.validationTest',
231
- {},
232
- new ReadableStream({
233
- pull(ctrl) {
234
- ctrl.enqueue(new Uint8Array([1]))
235
- ctrl.close()
236
- },
237
- }),
238
- ),
239
- ).rejects.toThrow(
240
- `Wrong request encoding (Content-Type): application/octet-stream`,
241
- )
242
- await expect(
243
- client.call('io.example.validationTest', {}, new Uint8Array([1])),
244
- ).rejects.toThrow(
245
- `Wrong request encoding (Content-Type): application/octet-stream`,
246
- )
247
-
248
- // 500 responses don't include details, so we nab details from the logger.
249
- const spy = jest.spyOn(logger, 'error')
250
- try {
251
- await expect(client.call('io.example.validationTestTwo')).rejects.toThrow(
252
- 'Internal Server Error',
253
148
  )
149
+ expect(res.success).toBe(true)
150
+ expect(res.data.foo).toBe('hello')
151
+ expect(res.data.bar).toBe(123)
152
+ })
254
153
 
255
- expect(spy).toHaveBeenCalledWith(
256
- expect.objectContaining({
257
- err: expect.objectContaining({
258
- message: 'Output must have the property "foo"',
154
+ test('requires content-type when body is expected', async () => {
155
+ await expect(
156
+ client.call('io.example.validationTest', {}),
157
+ ).rejects.toMatchObject({
158
+ message: 'Request encoding (Content-Type) required but not provided',
159
+ })
160
+ })
161
+ test('validates required input properties', async () => {
162
+ await expect(
163
+ client.call('io.example.validationTest', {}, {}),
164
+ ).rejects.toMatchObject({
165
+ error: 'InvalidRequest',
166
+ message: expect.stringContaining('foo'),
167
+ })
168
+ })
169
+ test('validates input property types', async () => {
170
+ await expect(
171
+ client.call('io.example.validationTest', {}, { foo: 123 }),
172
+ ).rejects.toMatchObject({
173
+ error: 'InvalidRequest',
174
+ message: expect.stringContaining('foo'),
175
+ })
176
+ })
177
+ test('rejects invalid encoding for object data', async () => {
178
+ await expect(
179
+ client.call(
180
+ 'io.example.validationTest',
181
+ {},
182
+ { foo: 'hello', bar: 123 },
183
+ { encoding: 'image/jpeg' },
184
+ ),
185
+ ).rejects.toMatchObject({
186
+ message: `Unable to encode object as image/jpeg data`,
187
+ })
188
+ })
189
+ test('rejects image/jpeg content-type for json schema', async () => {
190
+ await expect(
191
+ client.call(
192
+ 'io.example.validationTest',
193
+ {},
194
+ // Does not need to be a valid jpeg
195
+ new Blob([randomBytes(123)], { type: 'image/jpeg' }),
196
+ ),
197
+ ).rejects.toMatchObject({
198
+ message: `Wrong request encoding (Content-Type): image/jpeg`,
199
+ })
200
+ })
201
+ test('rejects multipart/form-data content-type for json schema', async () => {
202
+ await expect(
203
+ client.call(
204
+ 'io.example.validationTest',
205
+ {},
206
+ (() => {
207
+ const formData = new FormData()
208
+ formData.append('foo', 'bar')
209
+ return formData
210
+ })(),
211
+ ),
212
+ ).rejects.toMatchObject({
213
+ message: `Wrong request encoding (Content-Type): multipart/form-data`,
214
+ })
215
+ })
216
+ test('rejects application/x-www-form-urlencoded content-type for json schema', async () => {
217
+ await expect(
218
+ client.call(
219
+ 'io.example.validationTest',
220
+ {},
221
+ new URLSearchParams([['foo', 'bar']]),
222
+ ),
223
+ ).rejects.toMatchObject({
224
+ message: `Wrong request encoding (Content-Type): application/x-www-form-urlencoded`,
225
+ })
226
+ })
227
+ test('rejects application/octet-stream blob for json schema', async () => {
228
+ await expect(
229
+ client.call(
230
+ 'io.example.validationTest',
231
+ {},
232
+ new Blob([new Uint8Array([1])]),
233
+ ),
234
+ ).rejects.toMatchObject({
235
+ message: `Wrong request encoding (Content-Type): application/octet-stream`,
236
+ })
237
+ })
238
+ test('rejects application/octet-stream readable stream for json schema', async () => {
239
+ await expect(
240
+ client.call(
241
+ 'io.example.validationTest',
242
+ {},
243
+ new ReadableStream({
244
+ pull(ctrl) {
245
+ ctrl.enqueue(new Uint8Array([1]))
246
+ ctrl.close()
247
+ },
259
248
  }),
260
- }),
261
- 'unhandled exception in xrpc method io.example.validationTestTwo',
262
- )
263
- } finally {
264
- spy.mockRestore()
265
- }
266
- })
267
-
268
- it('supports ArrayBuffers', async () => {
269
- const bytes = randomBytes(1024)
270
- const expectedCid = await cidForCbor(bytes)
271
-
272
- const bytesResponse = await client.call('io.example.blobTest', {}, bytes, {
273
- encoding: 'application/octet-stream',
249
+ ),
250
+ ).rejects.toMatchObject({
251
+ message: `Wrong request encoding (Content-Type): application/octet-stream`,
252
+ })
253
+ })
254
+ test('rejects application/octet-stream uint8array for json schema', async () => {
255
+ await expect(
256
+ client.call('io.example.validationTest', {}, new Uint8Array([1])),
257
+ ).rejects.toMatchObject({
258
+ message: `Wrong request encoding (Content-Type): application/octet-stream`,
259
+ })
274
260
  })
275
- expect(bytesResponse.data.cid).toEqual(expectedCid.toString())
276
- })
277
261
 
278
- it('supports empty payload on procedues with encoding', async () => {
279
- const bytes = new Uint8Array(0)
280
- const expectedCid = await cidForCbor(bytes)
281
- const bytesResponse = await client.call('io.example.blobTest', {}, bytes)
282
- expect(bytesResponse.data.cid).toEqual(expectedCid.toString())
283
- })
262
+ test('validation errors on procedures include details in logs', async () => {
263
+ // 500 responses don't include details, so we nab details from the logger.
264
+ const spy = jest.spyOn(logger, 'error')
265
+ try {
266
+ await expect(
267
+ client.call('io.example.validationTestTwo'),
268
+ ).rejects.toThrow('Internal Server Error')
269
+
270
+ expect(spy).toHaveBeenCalledWith(
271
+ expect.objectContaining({
272
+ err: expect.objectContaining({
273
+ message: expect.stringContaining('foo'),
274
+ }),
275
+ }),
276
+ 'unhandled exception in xrpc method io.example.validationTestTwo',
277
+ )
278
+ } finally {
279
+ spy.mockRestore()
280
+ }
281
+ })
284
282
 
285
- it('supports upload of empty txt file', async () => {
286
- const txtFile = new Blob([], { type: 'text/plain' })
287
- const expectedCid = await cidForCbor(await txtFile.arrayBuffer())
288
- const fileResponse = await client.call('io.example.blobTest', {}, txtFile)
289
- expect(fileResponse.data.cid).toEqual(expectedCid.toString())
290
- })
283
+ it('supports ArrayBuffers', async () => {
284
+ const bytes = randomBytes(1024)
285
+ const expectedCid = await cidForCbor(bytes)
291
286
 
292
- it('supports upload of json data', async () => {
293
- const jsonFile = new Blob([Buffer.from(`{"foo":"bar","baz":[3, null]}`)], {
294
- type: 'application/json',
287
+ const bytesResponse = await client.call(
288
+ 'io.example.blobTest',
289
+ {},
290
+ bytes,
291
+ {
292
+ encoding: 'application/octet-stream',
293
+ },
294
+ )
295
+ expect(bytesResponse.data.cid).toEqual(expectedCid.toString())
295
296
  })
296
- const expectedCid = await cidForCbor(await jsonFile.arrayBuffer())
297
- const fileResponse = await client.call('io.example.blobTest', {}, jsonFile)
298
- expect(fileResponse.data.cid).toEqual(expectedCid.toString())
299
- })
300
297
 
301
- it('supports ArrayBufferView', async () => {
302
- const bytes = randomBytes(1024)
303
- const expectedCid = await cidForCbor(bytes)
298
+ it('supports empty payload on procedues with encoding', async () => {
299
+ const bytes = new Uint8Array(0)
300
+ const expectedCid = await cidForCbor(bytes)
301
+ const bytesResponse = await client.call('io.example.blobTest', {}, bytes)
302
+ expect(bytesResponse.data.cid).toEqual(expectedCid.toString())
303
+ })
304
304
 
305
- const bufferResponse = await client.call(
306
- 'io.example.blobTest',
307
- {},
308
- Buffer.from(bytes),
309
- )
310
- expect(bufferResponse.data.cid).toEqual(expectedCid.toString())
311
- })
305
+ it('supports upload of empty txt file', async () => {
306
+ const txtFile = new Blob([], { type: 'text/plain' })
307
+ const expectedCid = await cidForCbor(await txtFile.arrayBuffer())
308
+ const fileResponse = await client.call('io.example.blobTest', {}, txtFile)
309
+ expect(fileResponse.data.cid).toEqual(expectedCid.toString())
310
+ })
312
311
 
313
- it('supports Blob', async () => {
314
- const bytes = randomBytes(1024)
315
- const expectedCid = await cidForCbor(bytes)
312
+ it('supports upload of json data', async () => {
313
+ const jsonFile = new Blob(
314
+ [Buffer.from(`{"foo":"bar","baz":[3, null]}`)],
315
+ {
316
+ type: 'application/json',
317
+ },
318
+ )
319
+ const expectedCid = await cidForCbor(await jsonFile.arrayBuffer())
320
+ const fileResponse = await client.call(
321
+ 'io.example.blobTest',
322
+ {},
323
+ jsonFile,
324
+ )
325
+ expect(fileResponse.data.cid).toEqual(expectedCid.toString())
326
+ })
316
327
 
317
- const blobResponse = await client.call(
318
- 'io.example.blobTest',
319
- {},
320
- new Blob([bytes], { type: 'application/octet-stream' }),
321
- )
322
- expect(blobResponse.data.cid).toEqual(expectedCid.toString())
323
- })
328
+ it('supports ArrayBufferView', async () => {
329
+ const bytes = randomBytes(1024)
330
+ const expectedCid = await cidForCbor(bytes)
324
331
 
325
- it('supports Blob without explicit type', async () => {
326
- const bytes = randomBytes(1024)
327
- const expectedCid = await cidForCbor(bytes)
332
+ const bufferResponse = await client.call(
333
+ 'io.example.blobTest',
334
+ {},
335
+ Buffer.from(bytes),
336
+ )
337
+ expect(bufferResponse.data.cid).toEqual(expectedCid.toString())
338
+ })
328
339
 
329
- const blobResponse = await client.call(
330
- 'io.example.blobTest',
331
- {},
332
- new Blob([bytes]),
333
- )
334
- expect(blobResponse.data.cid).toEqual(expectedCid.toString())
335
- })
340
+ it('supports Blob', async () => {
341
+ const bytes = randomBytes(1024)
342
+ const expectedCid = await cidForCbor(bytes)
336
343
 
337
- it('supports ReadableStream', async () => {
338
- const bytes = randomBytes(1024)
339
- const expectedCid = await cidForCbor(bytes)
340
-
341
- const streamResponse = await client.call(
342
- 'io.example.blobTest',
343
- {},
344
- // ReadableStream.from not available in node < 20
345
- new ReadableStream({
346
- pull(ctrl) {
347
- ctrl.enqueue(bytes)
348
- ctrl.close()
349
- },
350
- }),
351
- )
352
- expect(streamResponse.data.cid).toEqual(expectedCid.toString())
353
- })
344
+ const blobResponse = await client.call(
345
+ 'io.example.blobTest',
346
+ {},
347
+ new Blob([bytes], { type: 'application/octet-stream' }),
348
+ )
349
+ expect(blobResponse.data.cid).toEqual(expectedCid.toString())
350
+ })
354
351
 
355
- it('supports blob uploads', async () => {
356
- const bytes = randomBytes(1024)
357
- const expectedCid = await cidForCbor(bytes)
352
+ it('supports Blob without explicit type', async () => {
353
+ const bytes = randomBytes(1024)
354
+ const expectedCid = await cidForCbor(bytes)
358
355
 
359
- const { data } = await client.call('io.example.blobTest', {}, bytes, {
360
- encoding: 'application/octet-stream',
356
+ const blobResponse = await client.call(
357
+ 'io.example.blobTest',
358
+ {},
359
+ new Blob([bytes]),
360
+ )
361
+ expect(blobResponse.data.cid).toEqual(expectedCid.toString())
361
362
  })
362
- expect(data.cid).toEqual(expectedCid.toString())
363
- })
364
363
 
365
- it(`supports identity encoding`, async () => {
366
- const bytes = randomBytes(1024)
367
- const expectedCid = await cidForCbor(bytes)
364
+ it('supports ReadableStream', async () => {
365
+ const bytes = randomBytes(1024)
366
+ const expectedCid = await cidForCbor(bytes)
368
367
 
369
- const { data } = await client.call('io.example.blobTest', {}, bytes, {
370
- encoding: 'application/octet-stream',
371
- headers: { 'content-encoding': 'identity' },
368
+ const streamResponse = await client.call(
369
+ 'io.example.blobTest',
370
+ {},
371
+ // ReadableStream.from not available in node < 20
372
+ new ReadableStream({
373
+ pull(ctrl) {
374
+ ctrl.enqueue(bytes)
375
+ ctrl.close()
376
+ },
377
+ }),
378
+ )
379
+ expect(streamResponse.data.cid).toEqual(expectedCid.toString())
372
380
  })
373
- expect(data.cid).toEqual(expectedCid.toString())
374
- })
375
381
 
376
- it('supports gzip encoding', async () => {
377
- const bytes = randomBytes(1024)
378
- const expectedCid = await cidForCbor(bytes)
382
+ it('supports blob uploads', async () => {
383
+ const bytes = randomBytes(1024)
384
+ const expectedCid = await cidForCbor(bytes)
379
385
 
380
- const { data } = await client.call(
381
- 'io.example.blobTest',
382
- {},
383
- gzipSync(bytes),
384
- {
386
+ const { data } = await client.call('io.example.blobTest', {}, bytes, {
385
387
  encoding: 'application/octet-stream',
386
- headers: {
387
- 'content-encoding': 'gzip',
388
- },
389
- },
390
- )
391
- expect(data.cid).toEqual(expectedCid.toString())
392
- })
388
+ })
389
+ expect(data.cid).toEqual(expectedCid.toString())
390
+ })
393
391
 
394
- it('supports deflate encoding', async () => {
395
- const bytes = randomBytes(1024)
396
- const expectedCid = await cidForCbor(bytes)
392
+ it(`supports identity encoding`, async () => {
393
+ const bytes = randomBytes(1024)
394
+ const expectedCid = await cidForCbor(bytes)
397
395
 
398
- const { data } = await client.call(
399
- 'io.example.blobTest',
400
- {},
401
- deflateSync(bytes),
402
- {
396
+ const { data } = await client.call('io.example.blobTest', {}, bytes, {
403
397
  encoding: 'application/octet-stream',
404
- headers: {
405
- 'content-encoding': 'deflate',
398
+ headers: { 'content-encoding': 'identity' },
399
+ })
400
+ expect(data.cid).toEqual(expectedCid.toString())
401
+ })
402
+
403
+ it('supports gzip encoding', async () => {
404
+ const bytes = randomBytes(1024)
405
+ const expectedCid = await cidForCbor(bytes)
406
+
407
+ const { data } = await client.call(
408
+ 'io.example.blobTest',
409
+ {},
410
+ gzipSync(bytes),
411
+ {
412
+ encoding: 'application/octet-stream',
413
+ headers: {
414
+ 'content-encoding': 'gzip',
415
+ },
406
416
  },
407
- },
408
- )
409
- expect(data.cid).toEqual(expectedCid.toString())
410
- })
417
+ )
418
+ expect(data.cid).toEqual(expectedCid.toString())
419
+ })
411
420
 
412
- it('supports br encoding', async () => {
413
- const bytes = randomBytes(1024)
414
- const expectedCid = await cidForCbor(bytes)
421
+ it('supports deflate encoding', async () => {
422
+ const bytes = randomBytes(1024)
423
+ const expectedCid = await cidForCbor(bytes)
415
424
 
416
- const { data } = await client.call(
417
- 'io.example.blobTest',
418
- {},
419
- brotliCompressSync(bytes),
420
- {
421
- encoding: 'application/octet-stream',
422
- headers: {
423
- 'content-encoding': 'br',
425
+ const { data } = await client.call(
426
+ 'io.example.blobTest',
427
+ {},
428
+ deflateSync(bytes),
429
+ {
430
+ encoding: 'application/octet-stream',
431
+ headers: {
432
+ 'content-encoding': 'deflate',
433
+ },
424
434
  },
425
- },
426
- )
427
- expect(data.cid).toEqual(expectedCid.toString())
428
- })
435
+ )
436
+ expect(data.cid).toEqual(expectedCid.toString())
437
+ })
429
438
 
430
- it('supports multiple encodings', async () => {
431
- const bytes = randomBytes(1024)
432
- const expectedCid = await cidForCbor(bytes)
439
+ it('supports br encoding', async () => {
440
+ const bytes = randomBytes(1024)
441
+ const expectedCid = await cidForCbor(bytes)
433
442
 
434
- const { data } = await client.call(
435
- 'io.example.blobTest',
436
- {},
437
- brotliCompressSync(deflateSync(gzipSync(bytes))),
438
- {
439
- encoding: 'application/octet-stream',
440
- headers: {
441
- 'content-encoding': 'gzip, identity, deflate, identity, br, identity',
443
+ const { data } = await client.call(
444
+ 'io.example.blobTest',
445
+ {},
446
+ brotliCompressSync(bytes),
447
+ {
448
+ encoding: 'application/octet-stream',
449
+ headers: {
450
+ 'content-encoding': 'br',
451
+ },
442
452
  },
443
- },
444
- )
445
- expect(data.cid).toEqual(expectedCid.toString())
446
- })
453
+ )
454
+ expect(data.cid).toEqual(expectedCid.toString())
455
+ })
447
456
 
448
- it('fails gracefully on invalid encodings', async () => {
449
- const bytes = randomBytes(1024)
457
+ it('supports multiple encodings', async () => {
458
+ const bytes = randomBytes(1024)
459
+ const expectedCid = await cidForCbor(bytes)
450
460
 
451
- const promise = client.call(
452
- 'io.example.blobTest',
453
- {},
454
- brotliCompressSync(bytes),
455
- {
456
- encoding: 'application/octet-stream',
457
- headers: {
458
- 'content-encoding': 'gzip',
461
+ const { data } = await client.call(
462
+ 'io.example.blobTest',
463
+ {},
464
+ brotliCompressSync(deflateSync(gzipSync(bytes))),
465
+ {
466
+ encoding: 'application/octet-stream',
467
+ headers: {
468
+ 'content-encoding':
469
+ 'gzip, identity, deflate, identity, br, identity',
470
+ },
459
471
  },
460
- },
461
- )
472
+ )
473
+ expect(data.cid).toEqual(expectedCid.toString())
474
+ })
462
475
 
463
- await expect(promise).rejects.toThrow('unable to read input')
464
- })
476
+ it('fails gracefully on invalid encodings', async () => {
477
+ const bytes = randomBytes(1024)
465
478
 
466
- it('supports empty payload', async () => {
467
- const bytes = new Uint8Array(0)
468
- const expectedCid = await cidForCbor(bytes)
479
+ const promise = client.call(
480
+ 'io.example.blobTest',
481
+ {},
482
+ brotliCompressSync(bytes),
483
+ {
484
+ encoding: 'application/octet-stream',
485
+ headers: {
486
+ 'content-encoding': 'gzip',
487
+ },
488
+ },
489
+ )
469
490
 
470
- // Using "undefined" as body to avoid encoding as lexicon { $bytes: "<base64>" }
471
- const result = await client.call('io.example.blobTest', {}, bytes, {
472
- encoding: 'text/plain',
491
+ await expect(promise).rejects.toThrow('unable to read input')
473
492
  })
474
493
 
475
- expect(result.data.cid).toEqual(expectedCid.toString())
476
- })
494
+ it('supports empty payload', async () => {
495
+ const bytes = new Uint8Array(0)
496
+ const expectedCid = await cidForCbor(bytes)
477
497
 
478
- it('supports max blob size (based on content-length)', async () => {
479
- const bytes = randomBytes(BLOB_LIMIT + 1)
498
+ // Using "undefined" as body to avoid encoding as lexicon { $bytes: "<base64>" }
499
+ const result = await client.call('io.example.blobTest', {}, bytes, {
500
+ encoding: 'text/plain',
501
+ })
480
502
 
481
- // Exactly the number of allowed bytes
482
- await client.call('io.example.blobTest', {}, bytes.slice(0, BLOB_LIMIT), {
483
- encoding: 'application/octet-stream',
503
+ expect(result.data.cid).toEqual(expectedCid.toString())
484
504
  })
485
505
 
486
- // Over the number of allowed bytes
487
- const promise = client.call('io.example.blobTest', {}, bytes, {
488
- encoding: 'application/octet-stream',
506
+ it('supports max blob size (based on content-length)', async () => {
507
+ const bytes = randomBytes(BLOB_LIMIT + 1)
508
+
509
+ // Exactly the number of allowed bytes
510
+ await client.call('io.example.blobTest', {}, bytes.slice(0, BLOB_LIMIT), {
511
+ encoding: 'application/octet-stream',
512
+ })
513
+
514
+ // Over the number of allowed bytes
515
+ const promise = client.call('io.example.blobTest', {}, bytes, {
516
+ encoding: 'application/octet-stream',
517
+ })
518
+
519
+ await expect(promise).rejects.toThrow('request entity too large')
489
520
  })
490
521
 
491
- await expect(promise).rejects.toThrow('request entity too large')
492
- })
522
+ it('supports max blob size (missing content-length)', async () => {
523
+ // We stream bytes in these tests so that content-length isn't included.
524
+ const bytes = randomBytes(BLOB_LIMIT + 1)
493
525
 
494
- it('supports max blob size (missing content-length)', async () => {
495
- // We stream bytes in these tests so that content-length isn't included.
496
- const bytes = randomBytes(BLOB_LIMIT + 1)
526
+ // Exactly the number of allowed bytes
527
+ await client.call(
528
+ 'io.example.blobTest',
529
+ {},
530
+ bytesToReadableStream(bytes.slice(0, BLOB_LIMIT)),
531
+ {
532
+ encoding: 'application/octet-stream',
533
+ },
534
+ )
497
535
 
498
- // Exactly the number of allowed bytes
499
- await client.call(
500
- 'io.example.blobTest',
501
- {},
502
- bytesToReadableStream(bytes.slice(0, BLOB_LIMIT)),
503
- {
504
- encoding: 'application/octet-stream',
505
- },
506
- )
507
-
508
- // Over the number of allowed bytes.
509
- const promise = client.call(
510
- 'io.example.blobTest',
511
- {},
512
- bytesToReadableStream(bytes),
513
- {
514
- encoding: 'application/octet-stream',
515
- },
516
- )
536
+ // Over the number of allowed bytes.
537
+ const promise = client.call(
538
+ 'io.example.blobTest',
539
+ {},
540
+ bytesToReadableStream(bytes),
541
+ {
542
+ encoding: 'application/octet-stream',
543
+ },
544
+ )
517
545
 
518
- await expect(promise).rejects.toThrow('request entity too large')
519
- })
546
+ await expect(promise).rejects.toThrow('request entity too large')
547
+ })
520
548
 
521
- it('requires any parsable Content-Type for blob uploads', async () => {
522
- // not a real mimetype, but correct syntax
523
- await client.call('io.example.blobTest', {}, randomBytes(BLOB_LIMIT), {
524
- encoding: 'some/thing',
549
+ it('requires any parsable Content-Type for blob uploads', async () => {
550
+ // not a real mimetype, but correct syntax
551
+ await client.call('io.example.blobTest', {}, randomBytes(BLOB_LIMIT), {
552
+ encoding: 'some/thing',
553
+ })
525
554
  })
526
- })
527
555
 
528
- it('errors on an empty Content-type on blob upload', async () => {
529
- // empty mimetype, but correct syntax
530
- const res = await fetch(`${url}/xrpc/io.example.blobTest`, {
531
- method: 'post',
532
- headers: { 'Content-Type': '' },
533
- body: randomBytes(BLOB_LIMIT),
534
- // @ts-ignore see note in @atproto/xrpc/client.ts
535
- duplex: 'half',
536
- })
537
- const resBody = await res.json()
538
- const status = res.status
539
- expect(status).toBe(400)
540
- expect(resBody).toMatchObject({
541
- error: 'InvalidRequest',
542
- message: 'Request encoding (Content-Type) required but not provided',
556
+ it('errors on an empty Content-type on blob upload', async () => {
557
+ // empty mimetype, but correct syntax
558
+ const res = await fetch(`${url}/xrpc/io.example.blobTest`, {
559
+ method: 'post',
560
+ headers: { 'Content-Type': '' },
561
+ body: randomBytes(BLOB_LIMIT),
562
+ // @ts-ignore see note in @atproto/xrpc/client.ts
563
+ duplex: 'half',
564
+ })
565
+ const resBody = await res.json()
566
+ const status = res.status
567
+ expect(status).toBe(400)
568
+ expect(resBody).toMatchObject({
569
+ error: 'InvalidRequest',
570
+ message: 'Request encoding (Content-Type) required but not provided',
571
+ })
543
572
  })
544
573
  })
545
- })
574
+ }
546
575
 
547
576
  const bytesToReadableStream = (bytes: Uint8Array): ReadableStream => {
548
577
  // not using ReadableStream.from(), which lacks support in some contexts including nodejs v18.
@@ -553,3 +582,29 @@ const bytesToReadableStream = (bytes: Uint8Array): ReadableStream => {
553
582
  },
554
583
  })
555
584
  }
585
+
586
+ async function consumeInput(
587
+ input: Readable | string | object,
588
+ ): Promise<Buffer> {
589
+ if (Buffer.isBuffer(input)) {
590
+ return input
591
+ }
592
+ if (typeof input === 'string') {
593
+ return Buffer.from(input)
594
+ }
595
+ if (input instanceof Readable) {
596
+ try {
597
+ return Buffer.concat(await input.toArray())
598
+ } catch (err) {
599
+ if (err instanceof xrpcServer.XRPCError) {
600
+ throw err
601
+ } else {
602
+ throw new xrpcServer.XRPCError(
603
+ ResponseType.InvalidRequest,
604
+ 'unable to read input',
605
+ )
606
+ }
607
+ }
608
+ }
609
+ throw new Error('Invalid input')
610
+ }