@atproto/xrpc-server 0.10.14 → 0.10.16
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/CHANGELOG.md +23 -0
- package/dist/auth.d.ts +3 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +18 -0
- package/dist/auth.js.map +1 -1
- package/dist/errors.d.ts +7 -14
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +19 -6
- package/dist/errors.js.map +1 -1
- package/dist/server.d.ts +27 -11
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +115 -78
- package/dist/server.js.map +1 -1
- package/dist/stream/frames.d.ts +5 -1
- package/dist/stream/frames.d.ts.map +1 -1
- package/dist/stream/frames.js +32 -5
- package/dist/stream/frames.js.map +1 -1
- package/dist/stream/types.d.ts +18 -44
- package/dist/stream/types.d.ts.map +1 -1
- package/dist/stream/types.js +10 -10
- package/dist/stream/types.js.map +1 -1
- package/dist/types.d.ts +47 -70
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +28 -15
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +18 -9
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +180 -37
- package/dist/util.js.map +1 -1
- package/package.json +11 -7
- package/src/auth.ts +28 -2
- package/src/errors.ts +23 -7
- package/src/server.ts +307 -111
- package/src/stream/frames.ts +39 -6
- package/src/stream/types.ts +14 -14
- package/src/types.ts +106 -25
- package/src/util.ts +272 -60
- package/tests/_util.ts +62 -5
- package/tests/bodies.test.ts +442 -387
- package/tests/procedures.test.ts +71 -52
- package/tests/queries.test.ts +56 -39
- package/tests/subscriptions.test.ts +234 -221
package/tests/bodies.test.ts
CHANGED
|
@@ -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 {
|
|
12
|
+
import {
|
|
13
|
+
buildAddLexicons,
|
|
14
|
+
buildMethodLexicons,
|
|
15
|
+
closeServer,
|
|
16
|
+
createServer,
|
|
17
|
+
} from './_util'
|
|
13
18
|
|
|
14
|
-
const
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
'
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
352
|
+
it('supports Blob without explicit type', async () => {
|
|
353
|
+
const bytes = randomBytes(1024)
|
|
354
|
+
const expectedCid = await cidForCbor(bytes)
|
|
358
355
|
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
364
|
+
it('supports ReadableStream', async () => {
|
|
365
|
+
const bytes = randomBytes(1024)
|
|
366
|
+
const expectedCid = await cidForCbor(bytes)
|
|
368
367
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
382
|
+
it('supports blob uploads', async () => {
|
|
383
|
+
const bytes = randomBytes(1024)
|
|
384
|
+
const expectedCid = await cidForCbor(bytes)
|
|
379
385
|
|
|
380
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
},
|
|
390
|
-
)
|
|
391
|
-
expect(data.cid).toEqual(expectedCid.toString())
|
|
392
|
-
})
|
|
388
|
+
})
|
|
389
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
390
|
+
})
|
|
393
391
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
392
|
+
it(`supports identity encoding`, async () => {
|
|
393
|
+
const bytes = randomBytes(1024)
|
|
394
|
+
const expectedCid = await cidForCbor(bytes)
|
|
397
395
|
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
-
})
|
|
417
|
+
)
|
|
418
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
419
|
+
})
|
|
411
420
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
421
|
+
it('supports deflate encoding', async () => {
|
|
422
|
+
const bytes = randomBytes(1024)
|
|
423
|
+
const expectedCid = await cidForCbor(bytes)
|
|
415
424
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
428
|
-
})
|
|
435
|
+
)
|
|
436
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
437
|
+
})
|
|
429
438
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
439
|
+
it('supports br encoding', async () => {
|
|
440
|
+
const bytes = randomBytes(1024)
|
|
441
|
+
const expectedCid = await cidForCbor(bytes)
|
|
433
442
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
|
|
446
|
-
})
|
|
453
|
+
)
|
|
454
|
+
expect(data.cid).toEqual(expectedCid.toString())
|
|
455
|
+
})
|
|
447
456
|
|
|
448
|
-
|
|
449
|
-
|
|
457
|
+
it('supports multiple encodings', async () => {
|
|
458
|
+
const bytes = randomBytes(1024)
|
|
459
|
+
const expectedCid = await cidForCbor(bytes)
|
|
450
460
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
464
|
-
|
|
476
|
+
it('fails gracefully on invalid encodings', async () => {
|
|
477
|
+
const bytes = randomBytes(1024)
|
|
465
478
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
476
|
-
|
|
494
|
+
it('supports empty payload', async () => {
|
|
495
|
+
const bytes = new Uint8Array(0)
|
|
496
|
+
const expectedCid = await cidForCbor(bytes)
|
|
477
497
|
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
519
|
-
|
|
546
|
+
await expect(promise).rejects.toThrow('request entity too large')
|
|
547
|
+
})
|
|
520
548
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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
|
+
}
|