@atproto/xrpc-server 0.6.4 → 0.7.1
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 +34 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -4
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +55 -74
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +73 -17
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +97 -40
- package/dist/types.js.map +1 -1
- package/dist/util.d.ts +5 -6
- package/dist/util.d.ts.map +1 -1
- package/dist/util.js +69 -74
- package/dist/util.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +1 -7
- package/src/server.ts +78 -99
- package/src/types.ts +209 -41
- package/src/util.ts +80 -62
- package/tests/parsing.test.ts +89 -0
package/src/util.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert'
|
|
2
|
-
import { Duplex,
|
|
2
|
+
import { Duplex, pipeline, Readable } from 'node:stream'
|
|
3
3
|
import { IncomingMessage } from 'node:http'
|
|
4
|
-
import * as zlib from 'node:zlib'
|
|
5
4
|
import express from 'express'
|
|
6
5
|
import mime from 'mime-types'
|
|
7
6
|
import {
|
|
@@ -11,7 +10,7 @@ import {
|
|
|
11
10
|
LexXrpcQuery,
|
|
12
11
|
LexXrpcSubscription,
|
|
13
12
|
} from '@atproto/lexicon'
|
|
14
|
-
import { MaxSizeChecker } from '@atproto/common'
|
|
13
|
+
import { createDecoders, MaxSizeChecker } from '@atproto/common'
|
|
15
14
|
import { ResponseType } from '@atproto/xrpc'
|
|
16
15
|
|
|
17
16
|
import {
|
|
@@ -157,7 +156,7 @@ export function validateOutput(
|
|
|
157
156
|
def: LexXrpcProcedure | LexXrpcQuery,
|
|
158
157
|
output: HandlerSuccess | undefined,
|
|
159
158
|
lexicons: Lexicons,
|
|
160
|
-
):
|
|
159
|
+
): void {
|
|
161
160
|
// initial validation
|
|
162
161
|
if (output) {
|
|
163
162
|
handlerSuccess.parse(output)
|
|
@@ -197,8 +196,6 @@ export function validateOutput(
|
|
|
197
196
|
throw new InternalServerError(e instanceof Error ? e.message : String(e))
|
|
198
197
|
}
|
|
199
198
|
}
|
|
200
|
-
|
|
201
|
-
return output
|
|
202
199
|
}
|
|
203
200
|
|
|
204
201
|
export function normalizeMime(v: string) {
|
|
@@ -261,7 +258,17 @@ function decodeBodyStream(
|
|
|
261
258
|
)
|
|
262
259
|
}
|
|
263
260
|
|
|
264
|
-
|
|
261
|
+
let transforms: Duplex[]
|
|
262
|
+
try {
|
|
263
|
+
transforms = createDecoders(contentEncoding)
|
|
264
|
+
} catch (cause) {
|
|
265
|
+
throw new XRPCError(
|
|
266
|
+
ResponseType.UnsupportedMediaType,
|
|
267
|
+
'unsupported content-encoding',
|
|
268
|
+
undefined,
|
|
269
|
+
{ cause },
|
|
270
|
+
)
|
|
271
|
+
}
|
|
265
272
|
|
|
266
273
|
if (maxSize !== undefined) {
|
|
267
274
|
const maxSizeChecker = new MaxSizeChecker(
|
|
@@ -277,54 +284,6 @@ function decodeBodyStream(
|
|
|
277
284
|
: req
|
|
278
285
|
}
|
|
279
286
|
|
|
280
|
-
export function createDecoders(contentEncoding?: string | string[]): Duplex[] {
|
|
281
|
-
return parseContentEncoding(contentEncoding).reverse().map(createDecoder)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
export function parseContentEncoding(
|
|
285
|
-
contentEncoding?: string | string[],
|
|
286
|
-
): string[] {
|
|
287
|
-
// undefined, empty string, and empty array
|
|
288
|
-
if (!contentEncoding?.length) return []
|
|
289
|
-
|
|
290
|
-
// Non empty string
|
|
291
|
-
if (typeof contentEncoding === 'string') {
|
|
292
|
-
return contentEncoding
|
|
293
|
-
.split(',')
|
|
294
|
-
.map((x) => x.trim().toLowerCase())
|
|
295
|
-
.filter((x) => x && x !== 'identity')
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// content-encoding should never be an array
|
|
299
|
-
return contentEncoding.flatMap(parseContentEncoding)
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
export function createDecoder(encoding: string): Duplex {
|
|
303
|
-
// https://www.rfc-editor.org/rfc/rfc7231#section-3.1.2.1
|
|
304
|
-
// > All content-coding values are case-insensitive...
|
|
305
|
-
switch (encoding.trim().toLowerCase()) {
|
|
306
|
-
// https://www.rfc-editor.org/rfc/rfc9112.html#section-7.2
|
|
307
|
-
case 'gzip':
|
|
308
|
-
case 'x-gzip':
|
|
309
|
-
return zlib.createGunzip({
|
|
310
|
-
// using Z_SYNC_FLUSH (cURL default) to be less strict when decoding
|
|
311
|
-
flush: zlib.constants.Z_SYNC_FLUSH,
|
|
312
|
-
finishFlush: zlib.constants.Z_SYNC_FLUSH,
|
|
313
|
-
})
|
|
314
|
-
case 'deflate':
|
|
315
|
-
return zlib.createInflate()
|
|
316
|
-
case 'br':
|
|
317
|
-
return zlib.createBrotliDecompress()
|
|
318
|
-
case 'identity':
|
|
319
|
-
return new PassThrough()
|
|
320
|
-
default:
|
|
321
|
-
throw new XRPCError(
|
|
322
|
-
ResponseType.UnsupportedMediaType,
|
|
323
|
-
'unsupported content-encoding',
|
|
324
|
-
)
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
287
|
export function serverTimingHeader(timings: ServerTiming[]) {
|
|
329
288
|
return timings
|
|
330
289
|
.map((timing) => {
|
|
@@ -360,11 +319,70 @@ export interface ServerTiming {
|
|
|
360
319
|
description?: string
|
|
361
320
|
}
|
|
362
321
|
|
|
363
|
-
export const parseReqNsid = (
|
|
364
|
-
req:
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
322
|
+
export const parseReqNsid = (req: express.Request | IncomingMessage) =>
|
|
323
|
+
parseUrlNsid('originalUrl' in req ? req.originalUrl : req.url || '/')
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Validates and extracts the nsid from an xrpc path
|
|
327
|
+
*/
|
|
328
|
+
export const parseUrlNsid = (url: string): string => {
|
|
329
|
+
// /!\ Hot path
|
|
330
|
+
|
|
331
|
+
if (
|
|
332
|
+
// Ordered by likelihood of failure
|
|
333
|
+
url.length <= 6 ||
|
|
334
|
+
url[5] !== '/' ||
|
|
335
|
+
url[4] !== 'c' ||
|
|
336
|
+
url[3] !== 'p' ||
|
|
337
|
+
url[2] !== 'r' ||
|
|
338
|
+
url[1] !== 'x' ||
|
|
339
|
+
url[0] !== '/'
|
|
340
|
+
) {
|
|
341
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const startOfNsid = 6
|
|
345
|
+
|
|
346
|
+
let curr = startOfNsid
|
|
347
|
+
let char: number
|
|
348
|
+
let alphaNumRequired = true
|
|
349
|
+
for (; curr < url.length; curr++) {
|
|
350
|
+
char = url.charCodeAt(curr)
|
|
351
|
+
if (
|
|
352
|
+
(char >= 48 && char <= 57) || // 0-9
|
|
353
|
+
(char >= 65 && char <= 90) || // A-Z
|
|
354
|
+
(char >= 97 && char <= 122) // a-z
|
|
355
|
+
) {
|
|
356
|
+
alphaNumRequired = false
|
|
357
|
+
} else if (char === 45 /* "-" */ || char === 46 /* "." */) {
|
|
358
|
+
if (alphaNumRequired) {
|
|
359
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
360
|
+
}
|
|
361
|
+
alphaNumRequired = true
|
|
362
|
+
} else if (char === 47 /* "/" */) {
|
|
363
|
+
// Allow trailing slash (next char is either EOS or "?")
|
|
364
|
+
if (curr === url.length - 1 || url.charCodeAt(curr + 1) === 63) {
|
|
365
|
+
break
|
|
366
|
+
}
|
|
367
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
368
|
+
} else if (char === 63 /* "?"" */) {
|
|
369
|
+
break
|
|
370
|
+
} else {
|
|
371
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// last char was one of: '-', '.', '/'
|
|
376
|
+
if (alphaNumRequired) {
|
|
377
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A domain name consists of minimum two characters
|
|
381
|
+
if (curr - startOfNsid < 2) {
|
|
382
|
+
throw new InvalidRequestError('invalid xrpc path')
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// @TODO is there a max ?
|
|
386
|
+
|
|
387
|
+
return url.slice(startOfNsid, curr)
|
|
370
388
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { parseUrlNsid } from '../src/util'
|
|
2
|
+
|
|
3
|
+
const testValid = (url: string, expected: string) => {
|
|
4
|
+
expect(parseUrlNsid(url)).toBe(expected)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const testInvalid = (url: string, errorMessage = 'invalid xrpc path') => {
|
|
8
|
+
expect(() => parseUrlNsid(url)).toThrow(errorMessage)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('parseUrlNsid', () => {
|
|
12
|
+
it('should extract the NSID from the URL', () => {
|
|
13
|
+
testValid('/xrpc/blee.blah.bloo', 'blee.blah.bloo')
|
|
14
|
+
testValid('/xrpc/blee.blah.bloo?foo[]', 'blee.blah.bloo')
|
|
15
|
+
testValid('/xrpc/blee.blah.bloo?foo=bar', 'blee.blah.bloo')
|
|
16
|
+
testValid('/xrpc/com.example.nsid', 'com.example.nsid')
|
|
17
|
+
testValid('/xrpc/com.example.nsid?foo=bar', 'com.example.nsid')
|
|
18
|
+
testValid('/xrpc/com.example-domain.nsid', 'com.example-domain.nsid')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('should allow a trailing slash', () => {
|
|
22
|
+
testValid('/xrpc/blee.blah.bloo/?', 'blee.blah.bloo')
|
|
23
|
+
testValid('/xrpc/blee.blah.bloo/?foo=', 'blee.blah.bloo')
|
|
24
|
+
testValid('/xrpc/blee.blah.bloo/?bool', 'blee.blah.bloo')
|
|
25
|
+
testValid('/xrpc/com.example.nsid/', 'com.example.nsid')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('should throw an error if the URL is too short', () => {
|
|
29
|
+
testInvalid('/xrpc/a')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('should throw an error if the URL is empty', () => {
|
|
33
|
+
testInvalid('')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should throw an error if the URL is missing the NSID', () => {
|
|
37
|
+
testInvalid('/xrpc/')
|
|
38
|
+
testInvalid('/xrpc/?')
|
|
39
|
+
testInvalid('/xrpc/?foo=bar')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('should throw an error if the URL contains extra path segments', () => {
|
|
43
|
+
testInvalid('/xrpc/123/extra')
|
|
44
|
+
testInvalid('/xrpc/123/extra?foo=bar')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should throw an error if the URL is missing the XRPC path prefix', () => {
|
|
48
|
+
testInvalid('/foo/123')
|
|
49
|
+
testInvalid('/foo/com.example.nsid')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should throw an error if the NSID starts with a dot', () => {
|
|
53
|
+
testInvalid('/xrpc/.')
|
|
54
|
+
testInvalid('/xrpc/..')
|
|
55
|
+
testInvalid('/xrpc/....')
|
|
56
|
+
testInvalid('/xrpc/.com.example.nsid')
|
|
57
|
+
testInvalid('/xrpc/com..example.nsid')
|
|
58
|
+
testInvalid('/xrpc/com.example..nsid')
|
|
59
|
+
testInvalid('/xrpc/com.example.nsid.')
|
|
60
|
+
testInvalid('/xrpc/com.example.nsid./')
|
|
61
|
+
testInvalid('/xrpc/com.example.nsid.?foo=bar')
|
|
62
|
+
testInvalid('/xrpc/com.example.nsid./?foo=bar')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should throw an error if the NSID contains a misplaced dash', () => {
|
|
66
|
+
testInvalid('/xrpc/-')
|
|
67
|
+
testInvalid('/xrpc/com.example.-nsid')
|
|
68
|
+
testInvalid('/xrpc/com.example-.nsid')
|
|
69
|
+
testInvalid('/xrpc/com.-example.nsid')
|
|
70
|
+
testInvalid('/xrpc/com.-example-.nsid')
|
|
71
|
+
testInvalid('/xrpc/com.example.nsid-')
|
|
72
|
+
testInvalid('/xrpc/-com.example.nsid')
|
|
73
|
+
testInvalid('/xrpc/com.example--domain.nsid')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should throw an error if the URL starts with a space', () => {
|
|
77
|
+
testInvalid(' /xrpc/com.example.nsid')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should throw an error if the NSID contains invalid characters', () => {
|
|
81
|
+
testInvalid('/xrpc/com.example.nsid#')
|
|
82
|
+
testInvalid('/xrpc/com.example.nsid!')
|
|
83
|
+
testInvalid('/xrpc/com.example#?nsid')
|
|
84
|
+
testInvalid('/xrpc/!com.example.nsid')
|
|
85
|
+
testInvalid('/xrpc/com.example.nsid ')
|
|
86
|
+
testInvalid('/xrpc/ com.example.nsid')
|
|
87
|
+
testInvalid('/xrpc/com. example.nsid')
|
|
88
|
+
})
|
|
89
|
+
})
|