@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.
@@ -2,17 +2,19 @@ import * as http from 'node:http'
2
2
  import { AddressInfo } from 'node:net'
3
3
  import { WebSocket, createWebSocketStream } from 'ws'
4
4
  import { wait } from '@atproto/common'
5
- import { LexiconDoc } from '@atproto/lexicon'
5
+ import { LexiconDoc, Lexicons } from '@atproto/lexicon'
6
6
  import { ErrorFrame, Frame, MessageFrame, Subscription, byFrame } from '../src'
7
7
  import * as xrpcServer from '../src'
8
8
  import {
9
9
  basicAuthHeaders,
10
+ buildAddLexicons,
11
+ buildMethodLexicons,
10
12
  closeServer,
11
13
  createBasicAuth,
12
14
  createServer,
13
15
  } from './_util'
14
16
 
15
- const LEXICONS: LexiconDoc[] = [
17
+ const LEXICONS = [
16
18
  {
17
19
  lexicon: 1,
18
20
  id: 'io.example.streamOne',
@@ -102,278 +104,289 @@ const LEXICONS: LexiconDoc[] = [
102
104
  },
103
105
  },
104
106
  },
105
- ]
107
+ ] as const satisfies LexiconDoc[]
106
108
 
107
- describe('Subscriptions', () => {
108
- let s: http.Server
109
-
110
- const server = xrpcServer.createServer(LEXICONS)
111
- const lex = server.lex
112
-
113
- server.streamMethod('io.example.streamOne', async function* ({ params }) {
109
+ const handlers = {
110
+ 'io.example.streamOne': async function* ({
111
+ params,
112
+ }: xrpcServer.StreamContext) {
114
113
  const countdown = Number(params.countdown ?? 0)
115
114
  for (let i = countdown; i >= 0; i--) {
116
115
  await wait(0)
117
- yield { $type: '#countdownStatus', count: i }
116
+ yield { $type: 'io.example.streamOne#countdownStatus', count: i }
118
117
  }
119
- })
120
-
121
- server.streamMethod('io.example.streamTwo', async function* ({ params }) {
118
+ },
119
+ 'io.example.streamTwo': async function* ({
120
+ params,
121
+ }: xrpcServer.StreamContext) {
122
122
  const countdown = Number(params.countdown ?? 0)
123
123
  for (let i = countdown; i >= 0; i--) {
124
124
  await wait(200)
125
125
  yield {
126
- $type: i % 2 === 0 ? '#even' : 'io.example.streamTwo#odd',
126
+ $type: `io.example.streamTwo${i % 2 === 0 ? '#even' : '#odd'}`,
127
127
  count: i,
128
128
  }
129
129
  }
130
130
  yield {
131
131
  $type: 'io.example.otherNsid#done',
132
132
  }
133
- })
134
-
135
- server.streamMethod('io.example.streamAuth', {
133
+ },
134
+ 'io.example.streamAuth': {
136
135
  auth: createBasicAuth({ username: 'admin', password: 'password' }),
137
136
  handler: async function* ({ auth }) {
138
137
  yield { ...auth, $type: 'io.example.streamAuth#auth' }
139
138
  },
140
- })
141
-
142
- let port: number
143
-
144
- beforeAll(async () => {
145
- s = await createServer(server)
146
- port = (s.address() as AddressInfo).port
147
- })
148
- afterAll(async () => {
149
- if (s) await closeServer(s)
150
- })
151
-
152
- it('streams messages', async () => {
153
- const ws = new WebSocket(
154
- `ws://localhost:${port}/xrpc/io.example.streamOne?countdown=5`,
155
- )
156
-
157
- const frames: Frame[] = []
158
- for await (const frame of byFrame(ws)) {
159
- frames.push(frame)
160
- }
139
+ },
140
+ }
141
+
142
+ for (const buildServer of [buildMethodLexicons, buildAddLexicons]) {
143
+ describe(buildServer, () => {
144
+ // @NOTE we need to clone because "new Lexicons" will mutate the lexicon
145
+ // definitions
146
+ const lex = new Lexicons(structuredClone(LEXICONS))
147
+
148
+ let server: xrpcServer.Server
149
+ let s: http.Server
150
+ let port: number
151
+ beforeAll(async () => {
152
+ server = await buildServer(LEXICONS, handlers)
153
+ s = await createServer(server)
154
+ port = (s.address() as AddressInfo).port
155
+ })
156
+ afterAll(async () => {
157
+ if (s) await closeServer(s)
158
+ })
161
159
 
162
- expect(frames).toEqual([
163
- new MessageFrame({ count: 5 }, { type: '#countdownStatus' }),
164
- new MessageFrame({ count: 4 }, { type: '#countdownStatus' }),
165
- new MessageFrame({ count: 3 }, { type: '#countdownStatus' }),
166
- new MessageFrame({ count: 2 }, { type: '#countdownStatus' }),
167
- new MessageFrame({ count: 1 }, { type: '#countdownStatus' }),
168
- new MessageFrame({ count: 0 }, { type: '#countdownStatus' }),
169
- ])
170
- })
160
+ it('streams messages', async () => {
161
+ const ws = new WebSocket(
162
+ `ws://localhost:${port}/xrpc/io.example.streamOne?countdown=5`,
163
+ )
171
164
 
172
- it('streams messages in a union', async () => {
173
- const ws = new WebSocket(
174
- `ws://localhost:${port}/xrpc/io.example.streamTwo?countdown=5`,
175
- )
165
+ const frames: Frame[] = []
166
+ for await (const frame of byFrame(ws)) {
167
+ frames.push(frame)
168
+ }
176
169
 
177
- const frames: Frame[] = []
178
- for await (const frame of byFrame(ws)) {
179
- frames.push(frame)
180
- }
170
+ expect(frames).toEqual([
171
+ new MessageFrame({ count: 5 }, { type: '#countdownStatus' }),
172
+ new MessageFrame({ count: 4 }, { type: '#countdownStatus' }),
173
+ new MessageFrame({ count: 3 }, { type: '#countdownStatus' }),
174
+ new MessageFrame({ count: 2 }, { type: '#countdownStatus' }),
175
+ new MessageFrame({ count: 1 }, { type: '#countdownStatus' }),
176
+ new MessageFrame({ count: 0 }, { type: '#countdownStatus' }),
177
+ ])
178
+ })
181
179
 
182
- expect(frames).toEqual([
183
- new MessageFrame({ count: 5 }, { type: '#odd' }),
184
- new MessageFrame({ count: 4 }, { type: '#even' }),
185
- new MessageFrame({ count: 3 }, { type: '#odd' }),
186
- new MessageFrame({ count: 2 }, { type: '#even' }),
187
- new MessageFrame({ count: 1 }, { type: '#odd' }),
188
- new MessageFrame({ count: 0 }, { type: '#even' }),
189
- new MessageFrame({}, { type: 'io.example.otherNsid#done' }),
190
- ])
191
- })
180
+ it('streams messages in a union', async () => {
181
+ const ws = new WebSocket(
182
+ `ws://localhost:${port}/xrpc/io.example.streamTwo?countdown=5`,
183
+ )
192
184
 
193
- it('resolves auth into handler', async () => {
194
- const ws = new WebSocket(
195
- `ws://localhost:${port}/xrpc/io.example.streamAuth`,
196
- {
197
- headers: basicAuthHeaders({
198
- username: 'admin',
199
- password: 'password',
200
- }),
201
- },
202
- )
185
+ const frames: Frame[] = []
186
+ for await (const frame of byFrame(ws)) {
187
+ frames.push(frame)
188
+ }
203
189
 
204
- const frames: Frame[] = []
205
- for await (const frame of byFrame(ws)) {
206
- frames.push(frame)
207
- }
190
+ expect(frames).toEqual([
191
+ new MessageFrame({ count: 5 }, { type: '#odd' }),
192
+ new MessageFrame({ count: 4 }, { type: '#even' }),
193
+ new MessageFrame({ count: 3 }, { type: '#odd' }),
194
+ new MessageFrame({ count: 2 }, { type: '#even' }),
195
+ new MessageFrame({ count: 1 }, { type: '#odd' }),
196
+ new MessageFrame({ count: 0 }, { type: '#even' }),
197
+ new MessageFrame({}, { type: 'io.example.otherNsid#done' }),
198
+ ])
199
+ })
208
200
 
209
- expect(frames).toEqual([
210
- new MessageFrame(
201
+ it('resolves auth into handler', async () => {
202
+ const ws = new WebSocket(
203
+ `ws://localhost:${port}/xrpc/io.example.streamAuth`,
211
204
  {
212
- credentials: {
205
+ headers: basicAuthHeaders({
213
206
  username: 'admin',
214
- },
215
- artifacts: {
216
- original: 'YWRtaW46cGFzc3dvcmQ=',
217
- },
207
+ password: 'password',
208
+ }),
218
209
  },
219
- {
220
- type: '#auth',
221
- },
222
- ),
223
- ])
224
- })
225
-
226
- it('errors immediately on bad parameter', async () => {
227
- const ws = new WebSocket(`ws://localhost:${port}/xrpc/io.example.streamOne`)
228
-
229
- const frames: Frame[] = []
230
- for await (const frame of byFrame(ws)) {
231
- frames.push(frame)
232
- }
233
-
234
- expect(frames).toEqual([
235
- new ErrorFrame({
236
- error: 'InvalidRequest',
237
- message: 'Error: Params must have the property "countdown"',
238
- }),
239
- ])
240
- })
210
+ )
241
211
 
242
- it('errors immediately on bad auth', async () => {
243
- const ws = new WebSocket(
244
- `ws://localhost:${port}/xrpc/io.example.streamAuth`,
245
- {
246
- headers: basicAuthHeaders({
247
- username: 'bad',
248
- password: 'wrong',
249
- }),
250
- },
251
- )
212
+ const frames: Frame[] = []
213
+ for await (const frame of byFrame(ws)) {
214
+ frames.push(frame)
215
+ }
252
216
 
253
- const frames: Frame[] = []
254
- for await (const frame of byFrame(ws)) {
255
- frames.push(frame)
256
- }
217
+ expect(frames).toEqual([
218
+ new MessageFrame(
219
+ {
220
+ credentials: {
221
+ username: 'admin',
222
+ },
223
+ artifacts: {
224
+ original: 'YWRtaW46cGFzc3dvcmQ=',
225
+ },
226
+ },
227
+ {
228
+ type: '#auth',
229
+ },
230
+ ),
231
+ ])
232
+ })
257
233
 
258
- expect(frames).toEqual([
259
- new ErrorFrame({
260
- error: 'AuthenticationRequired',
261
- message: 'Authentication Required',
262
- }),
263
- ])
264
- })
234
+ it('errors immediately on bad parameter', async () => {
235
+ const ws = new WebSocket(
236
+ `ws://localhost:${port}/xrpc/io.example.streamOne`,
237
+ )
265
238
 
266
- it('does not websocket upgrade at bad endpoint', async () => {
267
- const ws = new WebSocket(`ws://localhost:${port}/xrpc/does.not.exist`)
268
- const drainStream = async () => {
269
- for await (const bytes of createWebSocketStream(ws)) {
270
- bytes // drain
239
+ const frames: Frame[] = []
240
+ for await (const frame of byFrame(ws)) {
241
+ frames.push(frame)
271
242
  }
272
- }
273
- await expect(drainStream).rejects.toHaveProperty('code', 'ECONNRESET')
274
- })
275
243
 
276
- describe('Subscription consumer', () => {
277
- it('receives messages w/ skips', async () => {
278
- const sub = new Subscription({
279
- service: `ws://localhost:${port}`,
280
- method: 'io.example.streamOne',
281
- getParams: () => ({ countdown: 5 }),
282
- validate: (obj) => {
283
- const result = lex.assertValidXrpcMessage<{ count: number }>(
284
- 'io.example.streamOne',
285
- obj,
286
- )
287
- if (!result.count || result.count % 2) {
288
- return result
289
- }
244
+ expect(frames).toEqual([
245
+ expect.objectContaining({
246
+ body: expect.objectContaining({
247
+ error: 'InvalidRequest',
248
+ message: expect.stringContaining('countdown'),
249
+ }),
250
+ }),
251
+ ])
252
+ })
253
+
254
+ it('errors immediately on bad auth', async () => {
255
+ const ws = new WebSocket(
256
+ `ws://localhost:${port}/xrpc/io.example.streamAuth`,
257
+ {
258
+ headers: basicAuthHeaders({
259
+ username: 'bad',
260
+ password: 'wrong',
261
+ }),
290
262
  },
291
- })
263
+ )
292
264
 
293
- const messages: { count: number }[] = []
294
- for await (const msg of sub) {
295
- messages.push(msg)
265
+ const frames: Frame[] = []
266
+ for await (const frame of byFrame(ws)) {
267
+ frames.push(frame)
296
268
  }
297
269
 
298
- expect(messages).toEqual([
299
- { $type: 'io.example.streamOne#countdownStatus', count: 5 },
300
- { $type: 'io.example.streamOne#countdownStatus', count: 3 },
301
- { $type: 'io.example.streamOne#countdownStatus', count: 1 },
302
- { $type: 'io.example.streamOne#countdownStatus', count: 0 },
270
+ expect(frames).toEqual([
271
+ new ErrorFrame({
272
+ error: 'AuthenticationRequired',
273
+ message: 'Authentication Required',
274
+ }),
303
275
  ])
304
276
  })
305
277
 
306
- it('reconnects w/ param update', async () => {
307
- let countdown = 10
308
- let reconnects = 0
309
- const sub = new Subscription({
310
- service: `ws://localhost:${port}`,
311
- method: 'io.example.streamOne',
312
- onReconnectError: () => reconnects++,
313
- getParams: () => ({ countdown }),
314
- validate: (obj) => {
315
- return lex.assertValidXrpcMessage<{ count: number }>(
316
- 'io.example.streamOne',
317
- obj,
318
- )
319
- },
320
- })
321
-
322
- let disconnected = false
323
- for await (const msg of sub) {
324
- expect(msg.count).toBeGreaterThanOrEqual(countdown - 1) // No skips
325
- countdown = Math.min(countdown, msg.count) // Only allow forward movement
326
- if (msg.count <= 6 && !disconnected) {
327
- disconnected = true
328
- server.subscriptions.forEach(({ wss }) => {
329
- wss.clients.forEach((c) => c.terminate())
330
- })
278
+ it('does not websocket upgrade at bad endpoint', async () => {
279
+ const ws = new WebSocket(`ws://localhost:${port}/xrpc/does.not.exist`)
280
+ const drainStream = async () => {
281
+ for await (const bytes of createWebSocketStream(ws)) {
282
+ bytes // drain
331
283
  }
332
284
  }
333
-
334
- expect(countdown).toEqual(0)
335
- expect(reconnects).toBeGreaterThan(0)
285
+ await expect(drainStream).rejects.toHaveProperty('code', 'ECONNRESET')
336
286
  })
337
287
 
338
- it('aborts with signal', async () => {
339
- const abortController = new AbortController()
340
- const sub = new Subscription({
341
- service: `ws://localhost:${port}`,
342
- method: 'io.example.streamOne',
343
- signal: abortController.signal,
344
- getParams: () => ({ countdown: 10 }),
345
- validate: (obj) => {
346
- const result = lex.assertValidXrpcMessage<{ count: number }>(
347
- 'io.example.streamOne',
348
- obj,
349
- )
350
- return result
351
- },
352
- })
288
+ describe('Subscription consumer', () => {
289
+ it('receives messages w/ skips', async () => {
290
+ const sub = new Subscription({
291
+ service: `ws://localhost:${port}`,
292
+ method: 'io.example.streamOne',
293
+ getParams: () => ({ countdown: 5 }),
294
+ validate: (obj) => {
295
+ const result = lex.assertValidXrpcMessage<{ count: number }>(
296
+ 'io.example.streamOne',
297
+ obj,
298
+ )
299
+ if (!result.count || result.count % 2) {
300
+ return result
301
+ }
302
+ },
303
+ })
353
304
 
354
- let error
355
- let disconnected = false
356
- const messages: { count: number }[] = []
357
- try {
305
+ const messages: { count: number }[] = []
358
306
  for await (const msg of sub) {
359
307
  messages.push(msg)
308
+ }
309
+
310
+ expect(messages).toEqual([
311
+ { $type: 'io.example.streamOne#countdownStatus', count: 5 },
312
+ { $type: 'io.example.streamOne#countdownStatus', count: 3 },
313
+ { $type: 'io.example.streamOne#countdownStatus', count: 1 },
314
+ { $type: 'io.example.streamOne#countdownStatus', count: 0 },
315
+ ])
316
+ })
317
+
318
+ it('reconnects w/ param update', async () => {
319
+ let countdown = 10
320
+ let reconnects = 0
321
+ const sub = new Subscription({
322
+ service: `ws://localhost:${port}`,
323
+ method: 'io.example.streamOne',
324
+ onReconnectError: () => reconnects++,
325
+ getParams: () => ({ countdown }),
326
+ validate: (obj) => {
327
+ return lex.assertValidXrpcMessage<{ count: number }>(
328
+ 'io.example.streamOne',
329
+ obj,
330
+ )
331
+ },
332
+ })
333
+
334
+ let disconnected = false
335
+ for await (const msg of sub) {
336
+ expect(msg.count).toBeGreaterThanOrEqual(countdown - 1) // No skips
337
+ countdown = Math.min(countdown, msg.count) // Only allow forward movement
360
338
  if (msg.count <= 6 && !disconnected) {
361
339
  disconnected = true
362
- abortController.abort(new Error('Oops!'))
340
+ server.subscriptions.forEach(({ wss }) => {
341
+ wss.clients.forEach((c) => c.terminate())
342
+ })
363
343
  }
364
344
  }
365
- } catch (err) {
366
- error = err
367
- }
368
345
 
369
- expect(error).toEqual(new Error('Oops!'))
370
- expect(messages).toEqual([
371
- { $type: 'io.example.streamOne#countdownStatus', count: 10 },
372
- { $type: 'io.example.streamOne#countdownStatus', count: 9 },
373
- { $type: 'io.example.streamOne#countdownStatus', count: 8 },
374
- { $type: 'io.example.streamOne#countdownStatus', count: 7 },
375
- { $type: 'io.example.streamOne#countdownStatus', count: 6 },
376
- ])
346
+ expect(countdown).toEqual(0)
347
+ expect(reconnects).toBeGreaterThan(0)
348
+ })
349
+
350
+ it('aborts with signal', async () => {
351
+ const abortController = new AbortController()
352
+ const sub = new Subscription({
353
+ service: `ws://localhost:${port}`,
354
+ method: 'io.example.streamOne',
355
+ signal: abortController.signal,
356
+ getParams: () => ({ countdown: 10 }),
357
+ validate: (obj) => {
358
+ const result = lex.assertValidXrpcMessage<{ count: number }>(
359
+ 'io.example.streamOne',
360
+ obj,
361
+ )
362
+ return result
363
+ },
364
+ })
365
+
366
+ let error
367
+ let disconnected = false
368
+ const messages: { count: number }[] = []
369
+ try {
370
+ for await (const msg of sub) {
371
+ messages.push(msg)
372
+ if (msg.count <= 6 && !disconnected) {
373
+ disconnected = true
374
+ abortController.abort(new Error('Oops!'))
375
+ }
376
+ }
377
+ } catch (err) {
378
+ error = err
379
+ }
380
+
381
+ expect(error).toEqual(new Error('Oops!'))
382
+ expect(messages).toEqual([
383
+ { $type: 'io.example.streamOne#countdownStatus', count: 10 },
384
+ { $type: 'io.example.streamOne#countdownStatus', count: 9 },
385
+ { $type: 'io.example.streamOne#countdownStatus', count: 8 },
386
+ { $type: 'io.example.streamOne#countdownStatus', count: 7 },
387
+ { $type: 'io.example.streamOne#countdownStatus', count: 6 },
388
+ ])
389
+ })
377
390
  })
378
391
  })
379
- })
392
+ }