@atproto/xrpc-server 0.0.1 → 0.2.0

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.
Files changed (46) hide show
  1. package/dist/auth.d.ts +15 -0
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +40116 -29848
  4. package/dist/index.js.map +4 -4
  5. package/dist/server.d.ts +9 -3
  6. package/dist/src/index.d.ts +2 -0
  7. package/dist/src/logger.d.ts +2 -0
  8. package/dist/src/server.d.ts +19 -0
  9. package/dist/src/types.d.ts +115 -0
  10. package/dist/src/util.d.ts +10 -0
  11. package/dist/stream/frames.d.ts +25 -0
  12. package/dist/stream/index.d.ts +5 -0
  13. package/dist/stream/logger.d.ts +2 -0
  14. package/dist/stream/server.d.ts +11 -0
  15. package/dist/stream/stream.d.ts +5 -0
  16. package/dist/stream/subscription.d.ts +24 -0
  17. package/dist/stream/types.d.ts +64 -0
  18. package/dist/tsconfig.build.tsbuildinfo +1 -0
  19. package/dist/types.d.ts +16 -0
  20. package/dist/util.d.ts +3 -2
  21. package/package.json +14 -2
  22. package/src/auth.ts +111 -0
  23. package/src/index.ts +2 -0
  24. package/src/server.ts +148 -10
  25. package/src/stream/frames.ts +95 -0
  26. package/src/stream/index.ts +5 -0
  27. package/src/stream/logger.ts +5 -0
  28. package/src/stream/server.ts +65 -0
  29. package/src/stream/stream.ts +26 -0
  30. package/src/stream/subscription.ts +175 -0
  31. package/src/stream/types.ts +43 -0
  32. package/src/types.ts +27 -2
  33. package/src/util.ts +38 -7
  34. package/tests/_util.ts +36 -1
  35. package/tests/auth.test.ts +15 -36
  36. package/tests/bodies.test.ts +50 -9
  37. package/tests/errors.test.ts +38 -11
  38. package/tests/frames.test.ts +137 -0
  39. package/tests/ipld.test.ts +96 -0
  40. package/tests/parameters.test.ts +13 -45
  41. package/tests/procedures.test.ts +7 -3
  42. package/tests/queries.test.ts +7 -3
  43. package/tests/stream.test.ts +169 -0
  44. package/tests/subscriptions.test.ts +347 -0
  45. package/tsconfig.build.tsbuildinfo +1 -1
  46. package/tsconfig.json +1 -0
@@ -0,0 +1,175 @@
1
+ import { wait } from '@atproto/common'
2
+ import { WebSocket, ClientOptions } from 'ws'
3
+ import { byMessage } from './stream'
4
+ import { CloseCode, DisconnectError } from './types'
5
+
6
+ export class Subscription<T = unknown> {
7
+ constructor(
8
+ public opts: ClientOptions & {
9
+ service: string
10
+ method: string
11
+ maxReconnectSeconds?: number
12
+ signal?: AbortSignal
13
+ validate: (obj: unknown) => T | undefined
14
+ onReconnectError?: (
15
+ error: unknown,
16
+ n: number,
17
+ initialSetup: boolean,
18
+ ) => void
19
+ getParams?: () =>
20
+ | Record<string, unknown>
21
+ | Promise<Record<string, unknown> | undefined>
22
+ | undefined
23
+ },
24
+ ) {}
25
+
26
+ async *[Symbol.asyncIterator](): AsyncGenerator<T> {
27
+ let initialSetup = true
28
+ let reconnects: number | null = null
29
+ const maxReconnectMs = 1000 * (this.opts.maxReconnectSeconds ?? 64)
30
+ while (true) {
31
+ if (reconnects !== null) {
32
+ const duration = initialSetup
33
+ ? Math.min(1000, maxReconnectMs)
34
+ : backoffMs(reconnects++, maxReconnectMs)
35
+ await wait(duration)
36
+ }
37
+ const ws = await this.getSocket()
38
+ const ac = new AbortController()
39
+ if (this.opts.signal) {
40
+ forwardSignal(this.opts.signal, ac)
41
+ }
42
+ ws.once('open', () => {
43
+ initialSetup = false
44
+ reconnects = 0
45
+ })
46
+ ws.once('close', (code, reason) => {
47
+ if (code === CloseCode.Abnormal) {
48
+ // Forward into an error to distinguish from a clean close
49
+ ac.abort(
50
+ new AbnormalCloseError(`Abnormal ws close: ${reason.toString()}`),
51
+ )
52
+ }
53
+ })
54
+ try {
55
+ const cancelable = { signal: ac.signal }
56
+ for await (const message of byMessage(ws, cancelable)) {
57
+ const t = message.header.t
58
+ const clone =
59
+ message.body !== undefined ? { ...message.body } : undefined
60
+ if (clone !== undefined && t !== undefined) {
61
+ clone['$type'] = t.startsWith('#') ? this.opts.method + t : t
62
+ }
63
+ const result = this.opts.validate(clone)
64
+ if (result !== undefined) {
65
+ yield result
66
+ }
67
+ }
68
+ } catch (_err) {
69
+ const err = _err?.['code'] === 'ABORT_ERR' ? _err['cause'] : _err
70
+ if (err instanceof DisconnectError) {
71
+ // We cleanly end the connection
72
+ ws.close(err.wsCode)
73
+ break
74
+ }
75
+ ws.close() // No-ops if already closed or closing
76
+ if (isReconnectable(err)) {
77
+ reconnects ??= 0 // Never reconnect with a null
78
+ this.opts.onReconnectError?.(err, reconnects, initialSetup)
79
+ continue
80
+ } else {
81
+ throw err
82
+ }
83
+ }
84
+ break // Other side cleanly ended stream and disconnected
85
+ }
86
+ }
87
+
88
+ private async getSocket() {
89
+ const params = (await this.opts.getParams?.()) ?? {}
90
+ const query = encodeQueryParams(params)
91
+ const url = `${this.opts.service}/xrpc/${this.opts.method}?${query}`
92
+ return new WebSocket(url, this.opts)
93
+ }
94
+ }
95
+
96
+ export default Subscription
97
+
98
+ class AbnormalCloseError extends Error {
99
+ code = 'EWSABNORMALCLOSE'
100
+ }
101
+
102
+ function isReconnectable(err: unknown): boolean {
103
+ // Network errors are reconnectable.
104
+ // AuthenticationRequired and InvalidRequest XRPCErrors are not reconnectable.
105
+ // @TODO method-specific XRPCErrors may be reconnectable, need to consider. Receiving
106
+ // an invalid message is not current reconnectable, but the user can decide to skip them.
107
+ if (!err || typeof err['code'] !== 'string') return false
108
+ return networkErrorCodes.includes(err['code'])
109
+ }
110
+
111
+ const networkErrorCodes = [
112
+ 'EWSABNORMALCLOSE',
113
+ 'ECONNRESET',
114
+ 'ECONNREFUSED',
115
+ 'ECONNABORTED',
116
+ 'EPIPE',
117
+ 'ETIMEDOUT',
118
+ 'ECANCELED',
119
+ ]
120
+
121
+ function backoffMs(n: number, maxMs: number) {
122
+ const baseSec = Math.pow(2, n) // 1, 2, 4, ...
123
+ const randSec = Math.random() - 0.5 // Random jitter between -.5 and .5 seconds
124
+ const ms = 1000 * (baseSec + randSec)
125
+ return Math.min(ms, maxMs)
126
+ }
127
+
128
+ function encodeQueryParams(obj: Record<string, unknown>): string {
129
+ const params = new URLSearchParams()
130
+ Object.entries(obj).forEach(([key, value]) => {
131
+ const encoded = encodeQueryParam(value)
132
+ if (Array.isArray(encoded)) {
133
+ encoded.forEach((enc) => params.append(key, enc))
134
+ } else {
135
+ params.set(key, encoded)
136
+ }
137
+ })
138
+ return params.toString()
139
+ }
140
+
141
+ // Adapted from xrpc, but without any lex-specific knowledge
142
+ function encodeQueryParam(value: unknown): string | string[] {
143
+ if (typeof value === 'string') {
144
+ return value
145
+ }
146
+ if (typeof value === 'number') {
147
+ return value.toString()
148
+ }
149
+ if (typeof value === 'boolean') {
150
+ return value ? 'true' : 'false'
151
+ }
152
+ if (typeof value === 'undefined') {
153
+ return ''
154
+ }
155
+ if (typeof value === 'object') {
156
+ if (value instanceof Date) {
157
+ return value.toISOString()
158
+ } else if (Array.isArray(value)) {
159
+ return value.flatMap(encodeQueryParam)
160
+ } else if (!value) {
161
+ return ''
162
+ }
163
+ }
164
+ throw new Error(`Cannot encode ${typeof value}s into query params`)
165
+ }
166
+
167
+ function forwardSignal(signal: AbortSignal, ac: AbortController) {
168
+ if (signal.aborted) {
169
+ return ac.abort(signal.reason)
170
+ } else {
171
+ signal.addEventListener('abort', () => ac.abort(signal.reason), {
172
+ signal: ac.signal,
173
+ })
174
+ }
175
+ }
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod'
2
+
3
+ export enum FrameType {
4
+ Message = 1,
5
+ Error = -1,
6
+ }
7
+
8
+ export const messageFrameHeader = z.object({
9
+ op: z.literal(FrameType.Message), // Frame op
10
+ t: z.string().optional(), // Message body type discriminator
11
+ })
12
+ export type MessageFrameHeader = z.infer<typeof messageFrameHeader>
13
+
14
+ export const errorFrameHeader = z.object({
15
+ op: z.literal(FrameType.Error),
16
+ })
17
+ export const errorFrameBody = z.object({
18
+ error: z.string(), // Error code
19
+ message: z.string().optional(), // Error message
20
+ })
21
+ export type ErrorFrameHeader = z.infer<typeof errorFrameHeader>
22
+ export type ErrorFrameBody<T extends string = string> = { error: T } & z.infer<
23
+ typeof errorFrameBody
24
+ >
25
+
26
+ export const frameHeader = z.union([messageFrameHeader, errorFrameHeader])
27
+ export type FrameHeader = z.infer<typeof frameHeader>
28
+
29
+ export class DisconnectError extends Error {
30
+ constructor(
31
+ public wsCode: CloseCode = CloseCode.Policy,
32
+ public xrpcCode?: string,
33
+ ) {
34
+ super()
35
+ }
36
+ }
37
+
38
+ // https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
39
+ export enum CloseCode {
40
+ Normal = 1000,
41
+ Abnormal = 1006,
42
+ Policy = 1008,
43
+ }
package/src/types.ts CHANGED
@@ -1,7 +1,12 @@
1
+ import { IncomingMessage } from 'http'
1
2
  import express from 'express'
2
3
  import { isHttpError } from 'http-errors'
3
4
  import zod from 'zod'
4
- import { ResponseType, ResponseTypeStrings } from '@atproto/xrpc'
5
+ import {
6
+ ResponseType,
7
+ ResponseTypeStrings,
8
+ ResponseTypeNames,
9
+ } from '@atproto/xrpc'
5
10
 
6
11
  export type Options = {
7
12
  validateResponse?: boolean
@@ -52,6 +57,13 @@ export type XRPCHandler = (ctx: {
52
57
  res: express.Response
53
58
  }) => Promise<HandlerOutput> | HandlerOutput | undefined
54
59
 
60
+ export type XRPCStreamHandler = (ctx: {
61
+ auth: HandlerAuth | undefined
62
+ params: Params
63
+ req: IncomingMessage
64
+ signal: AbortSignal
65
+ }) => AsyncIterable<unknown>
66
+
55
67
  export type AuthOutput = HandlerAuth | HandlerError
56
68
 
57
69
  export type AuthVerifier = (ctx: {
@@ -59,11 +71,20 @@ export type AuthVerifier = (ctx: {
59
71
  res: express.Response
60
72
  }) => Promise<AuthOutput> | AuthOutput
61
73
 
74
+ export type StreamAuthVerifier = (ctx: {
75
+ req: IncomingMessage
76
+ }) => Promise<AuthOutput> | AuthOutput
77
+
62
78
  export type XRPCHandlerConfig = {
63
79
  auth?: AuthVerifier
64
80
  handler: XRPCHandler
65
81
  }
66
82
 
83
+ export type XRPCStreamHandlerConfig = {
84
+ auth?: StreamAuthVerifier
85
+ handler: XRPCStreamHandler
86
+ }
87
+
67
88
  export class XRPCError extends Error {
68
89
  constructor(
69
90
  public type: ResponseType,
@@ -75,7 +96,7 @@ export class XRPCError extends Error {
75
96
 
76
97
  get payload() {
77
98
  return {
78
- error: this.customErrorName,
99
+ error: this.customErrorName ?? this.typeName,
79
100
  message:
80
101
  this.type === ResponseType.InternalServerError
81
102
  ? this.typeStr // Do not respond with error details for 500s
@@ -83,6 +104,10 @@ export class XRPCError extends Error {
83
104
  }
84
105
  }
85
106
 
107
+ get typeName(): string | undefined {
108
+ return ResponseTypeNames[this.type]
109
+ }
110
+
86
111
  get typeStr(): string | undefined {
87
112
  return ResponseTypeStrings[this.type]
88
113
  }
package/src/util.ts CHANGED
@@ -2,7 +2,13 @@ import { Readable, Transform } from 'stream'
2
2
  import { createDeflate, createGunzip } from 'zlib'
3
3
  import express from 'express'
4
4
  import mime from 'mime-types'
5
- import { Lexicons, LexXrpcProcedure, LexXrpcQuery } from '@atproto/lexicon'
5
+ import {
6
+ jsonToLex,
7
+ Lexicons,
8
+ LexXrpcProcedure,
9
+ LexXrpcQuery,
10
+ LexXrpcSubscription,
11
+ } from '@atproto/lexicon'
6
12
  import { forwardStreamErrors, MaxSizeChecker } from '@atproto/common'
7
13
  import {
8
14
  UndecodedParams,
@@ -17,7 +23,7 @@ import {
17
23
  } from './types'
18
24
 
19
25
  export function decodeQueryParams(
20
- def: LexXrpcProcedure | LexXrpcQuery,
26
+ def: LexXrpcProcedure | LexXrpcQuery | LexXrpcSubscription,
21
27
  params: UndecodedParams,
22
28
  ): Params {
23
29
  const decoded: Params = {}
@@ -50,7 +56,7 @@ export function decodeQueryParam(
50
56
  if (type === 'string' || type === 'datetime') {
51
57
  return String(value)
52
58
  }
53
- if (type === 'number') {
59
+ if (type === 'float') {
54
60
  return Number(String(value))
55
61
  } else if (type === 'integer') {
56
62
  return Number(String(value)) | 0
@@ -59,6 +65,18 @@ export function decodeQueryParam(
59
65
  }
60
66
  }
61
67
 
68
+ export function getQueryParams(url = ''): Record<string, string | string[]> {
69
+ const { searchParams } = new URL(url ?? '', 'http://x')
70
+ const result: Record<string, string | string[]> = {}
71
+ for (const key of searchParams.keys()) {
72
+ result[key] = searchParams.getAll(key)
73
+ if (result[key].length === 1) {
74
+ result[key] = result[key][0]
75
+ }
76
+ }
77
+ return result
78
+ }
79
+
62
80
  export function validateInput(
63
81
  nsid: string,
64
82
  def: LexXrpcProcedure | LexXrpcQuery,
@@ -88,7 +106,15 @@ export function validateInput(
88
106
  def.input?.encoding &&
89
107
  (!inputEncoding || !isValidEncoding(def.input?.encoding, inputEncoding))
90
108
  ) {
91
- throw new InvalidRequestError(`Invalid request encoding: ${inputEncoding}`)
109
+ if (!inputEncoding) {
110
+ throw new InvalidRequestError(
111
+ `Request encoding (Content-Type) required but not provided`,
112
+ )
113
+ } else {
114
+ throw new InvalidRequestError(
115
+ `Wrong request encoding (Content-Type): ${inputEncoding}`,
116
+ )
117
+ }
92
118
  }
93
119
 
94
120
  if (!inputEncoding) {
@@ -99,7 +125,8 @@ export function validateInput(
99
125
  // if input schema, validate
100
126
  if (def.input?.schema) {
101
127
  try {
102
- lexicons.assertValidXrpcInput(nsid, req.body)
128
+ const lexBody = req.body ? jsonToLex(req.body) : req.body
129
+ req.body = lexicons.assertValidXrpcInput(nsid, lexBody)
103
130
  } catch (e) {
104
131
  throw new InvalidRequestError(e instanceof Error ? e.message : String(e))
105
132
  }
@@ -157,7 +184,10 @@ export function validateOutput(
157
184
  // output schema
158
185
  if (def.output?.schema) {
159
186
  try {
160
- lexicons.assertValidXrpcOutput(nsid, output?.body)
187
+ const result = lexicons.assertValidXrpcOutput(nsid, output?.body)
188
+ if (output) {
189
+ output.body = result
190
+ }
161
191
  } catch (e) {
162
192
  throw new InternalServerError(e instanceof Error ? e.message : String(e))
163
193
  }
@@ -167,8 +197,9 @@ export function validateOutput(
167
197
  }
168
198
 
169
199
  export function normalizeMime(v: string) {
170
- const fullType = mime.contentType(v)
171
200
  if (!v) return false
201
+ const fullType = mime.contentType(v)
202
+ if (!fullType) return false
172
203
  const shortType = fullType.split(';')[0]
173
204
  if (!shortType) return false
174
205
  return shortType
package/tests/_util.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as http from 'http'
2
2
  import express from 'express'
3
- import * as xrpc from '../src/index'
3
+ import * as xrpc from '../src'
4
+ import { AuthRequiredError } from '../src'
4
5
 
5
6
  export async function createServer(
6
7
  port: number,
@@ -18,3 +19,37 @@ export async function closeServer(httpServer: http.Server) {
18
19
  httpServer.close(() => r(undefined))
19
20
  })
20
21
  }
22
+
23
+ export function createBasicAuth(allowed: {
24
+ username: string
25
+ password: string
26
+ }) {
27
+ return function (ctx: { req: http.IncomingMessage }) {
28
+ const header = ctx.req.headers.authorization ?? ''
29
+ if (!header.startsWith('Basic ')) {
30
+ throw new AuthRequiredError()
31
+ }
32
+ const original = header.replace('Basic ', '')
33
+ const [username, password] = Buffer.from(original, 'base64')
34
+ .toString()
35
+ .split(':')
36
+ if (username !== allowed.username || password !== allowed.password) {
37
+ throw new AuthRequiredError()
38
+ }
39
+ return {
40
+ credentials: { username },
41
+ artifacts: { original },
42
+ }
43
+ }
44
+ }
45
+
46
+ export function basicAuthHeaders(creds: {
47
+ username: string
48
+ password: string
49
+ }) {
50
+ return {
51
+ authorization:
52
+ 'Basic ' +
53
+ Buffer.from(`${creds.username}:${creds.password}`).toString('base64'),
54
+ }
55
+ }
@@ -1,9 +1,13 @@
1
1
  import * as http from 'http'
2
- import express from 'express'
3
- import xrpc, { XRPCError } from '@atproto/xrpc'
4
- import { createServer, closeServer } from './_util'
2
+ import getPort from 'get-port'
3
+ import xrpc, { ServiceClient, XRPCError } from '@atproto/xrpc'
5
4
  import * as xrpcServer from '../src'
6
- import { AuthRequiredError } from '../src'
5
+ import {
6
+ createServer,
7
+ closeServer,
8
+ createBasicAuth,
9
+ basicAuthHeaders,
10
+ } from './_util'
7
11
 
8
12
  const LEXICONS = [
9
13
  {
@@ -51,10 +55,13 @@ describe('Auth', () => {
51
55
  }
52
56
  },
53
57
  })
54
- const client = xrpc.service(`http://localhost:8894`)
55
58
  xrpc.addLexicons(LEXICONS)
59
+
60
+ let client: ServiceClient
56
61
  beforeAll(async () => {
57
- s = await createServer(8894, server)
62
+ const port = await getPort()
63
+ s = await createServer(port, server)
64
+ client = xrpc.service(`http://localhost:${port}`)
58
65
  })
59
66
  afterAll(async () => {
60
67
  await closeServer(s)
@@ -75,7 +82,7 @@ describe('Auth', () => {
75
82
  )
76
83
  throw new Error('Didnt throw')
77
84
  } catch (e: any) {
78
- expect(e instanceof XRPCError).toBeTruthy()
85
+ expect(e).toBeInstanceOf(XRPCError)
79
86
  expect(e.success).toBeFalsy()
80
87
  expect(e.error).toBe('AuthenticationRequired')
81
88
  expect(e.message).toBe('Authentication Required')
@@ -98,7 +105,7 @@ describe('Auth', () => {
98
105
  )
99
106
  throw new Error('Didnt throw')
100
107
  } catch (e: any) {
101
- expect(e instanceof XRPCError).toBeTruthy()
108
+ expect(e).toBeInstanceOf(XRPCError)
102
109
  expect(e.success).toBeFalsy()
103
110
  expect(e.error).toBe('InvalidRequest')
104
111
  expect(e.message).toBe('Input/present must be true')
@@ -125,31 +132,3 @@ describe('Auth', () => {
125
132
  })
126
133
  })
127
134
  })
128
-
129
- function createBasicAuth(allowed: { username: string; password: string }) {
130
- return function (ctx: { req: express.Request }) {
131
- const header = ctx.req.headers.authorization ?? ''
132
- if (!header.startsWith('Basic ')) {
133
- throw new AuthRequiredError()
134
- }
135
- const original = header.replace('Basic ', '')
136
- const [username, password] = Buffer.from(original, 'base64')
137
- .toString()
138
- .split(':')
139
- if (username !== allowed.username || password !== allowed.password) {
140
- throw new AuthRequiredError()
141
- }
142
- return {
143
- credentials: { username },
144
- artifacts: { original },
145
- }
146
- }
147
- }
148
-
149
- function basicAuthHeaders(creds: { username: string; password: string }) {
150
- return {
151
- authorization:
152
- 'Basic ' +
153
- Buffer.from(`${creds.username}:${creds.password}`).toString('base64'),
154
- }
155
- }
@@ -1,8 +1,9 @@
1
1
  import * as http from 'http'
2
2
  import { Readable } from 'stream'
3
3
  import { gzipSync } from 'zlib'
4
- import xrpc from '@atproto/xrpc'
5
- import { bytesToStream, cidForData } from '@atproto/common'
4
+ import getPort from 'get-port'
5
+ import xrpc, { ServiceClient } from '@atproto/xrpc'
6
+ import { bytesToStream, cidForCbor } from '@atproto/common'
6
7
  import { randomBytes } from '@atproto/crypto'
7
8
  import { createServer, closeServer } from './_util'
8
9
  import * as xrpcServer from '../src'
@@ -22,7 +23,7 @@ const LEXICONS = [
22
23
  required: ['foo'],
23
24
  properties: {
24
25
  foo: { type: 'string' },
25
- bar: { type: 'number' },
26
+ bar: { type: 'integer' },
26
27
  },
27
28
  },
28
29
  },
@@ -33,7 +34,7 @@ const LEXICONS = [
33
34
  required: ['foo'],
34
35
  properties: {
35
36
  foo: { type: 'string' },
36
- bar: { type: 'number' },
37
+ bar: { type: 'integer' },
37
38
  },
38
39
  },
39
40
  },
@@ -53,7 +54,7 @@ const LEXICONS = [
53
54
  required: ['foo'],
54
55
  properties: {
55
56
  foo: { type: 'string' },
56
- bar: { type: 'number' },
57
+ bar: { type: 'integer' },
57
58
  },
58
59
  },
59
60
  },
@@ -113,17 +114,22 @@ describe('Bodies', () => {
113
114
  for await (const data of ctx.input.body) {
114
115
  buffers.push(data)
115
116
  }
116
- const cid = await cidForData(Buffer.concat(buffers))
117
+ const cid = await cidForCbor(Buffer.concat(buffers))
117
118
  return {
118
119
  encoding: 'json',
119
120
  body: { cid: cid.toString() },
120
121
  }
121
122
  },
122
123
  )
123
- const client = xrpc.service(`http://localhost:8892`)
124
124
  xrpc.addLexicons(LEXICONS)
125
+
126
+ let client: ServiceClient
127
+ let url: string
125
128
  beforeAll(async () => {
126
- s = await createServer(8892, server)
129
+ const port = await getPort()
130
+ s = await createServer(port, server)
131
+ url = `http://localhost:${port}`
132
+ client = xrpc.service(url)
127
133
  })
128
134
  afterAll(async () => {
129
135
  await closeServer(s)
@@ -151,6 +157,14 @@ describe('Bodies', () => {
151
157
  await expect(
152
158
  client.call('io.example.validationTest', {}, { foo: 123 }),
153
159
  ).rejects.toThrow(`Input/foo must be a string`)
160
+ await expect(
161
+ client.call(
162
+ 'io.example.validationTest',
163
+ {},
164
+ { foo: 'hello', bar: 123 },
165
+ { encoding: 'image/jpeg' },
166
+ ),
167
+ ).rejects.toThrow(`Wrong request encoding (Content-Type): image/jpeg`)
154
168
 
155
169
  // 500 responses don't include details, so we nab details from the logger.
156
170
  let error: string | undefined
@@ -169,7 +183,7 @@ describe('Bodies', () => {
169
183
 
170
184
  it('supports blobs and compression', async () => {
171
185
  const bytes = randomBytes(1024)
172
- const expectedCid = await cidForData(bytes)
186
+ const expectedCid = await cidForCbor(bytes)
173
187
 
174
188
  const { data: uncompressed } = await client.call(
175
189
  'io.example.blobTest',
@@ -237,4 +251,31 @@ describe('Bodies', () => {
237
251
 
238
252
  await expect(promise).rejects.toThrow('request entity too large')
239
253
  })
254
+
255
+ it('requires any parsable Content-Type for blob uploads', async () => {
256
+ // not a real mimetype, but correct syntax
257
+ await client.call('io.example.blobTest', {}, randomBytes(BLOB_LIMIT), {
258
+ encoding: 'some/thing',
259
+ })
260
+ })
261
+
262
+ // @TODO: figure out why this is failing dependent on the prev test being run
263
+ // https://github.com/bluesky-social/atproto/pull/550/files#r1106400413
264
+ it.skip('errors on an empty Content-type on blob upload', async () => {
265
+ // empty mimetype, but correct syntax
266
+ const res = await fetch(`${url}/xrpc/io.example.blobTest`, {
267
+ method: 'post',
268
+ headers: { 'Content-Type': '' },
269
+ body: randomBytes(BLOB_LIMIT),
270
+ // @ts-ignore see note in @atproto/xrpc/client.ts
271
+ duplex: 'half',
272
+ })
273
+ const resBody = await res.json()
274
+ const status = res.status
275
+ expect(status).toBe(400)
276
+ expect(resBody.error).toBe('InvalidRequest')
277
+ expect(resBody.message).toBe(
278
+ 'Request encoding (Content-Type) required but not provided',
279
+ )
280
+ })
240
281
  })