@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,169 @@
1
+ import * as http from 'http'
2
+ import { once } from 'events'
3
+ import { AddressInfo } from 'net'
4
+ import { WebSocket } from 'ws'
5
+ import { XRPCError } from '@atproto/xrpc'
6
+ import {
7
+ ErrorFrame,
8
+ Frame,
9
+ MessageFrame,
10
+ XrpcStreamServer,
11
+ byFrame,
12
+ byMessage,
13
+ } from '../src'
14
+
15
+ describe('Stream', () => {
16
+ const wait = (ms) => new Promise((res) => setTimeout(res, ms))
17
+ it('streams message and info frames.', async () => {
18
+ const httpServer = http.createServer()
19
+ const server = new XrpcStreamServer({
20
+ server: httpServer,
21
+ handler: async function* () {
22
+ await wait(1)
23
+ yield new MessageFrame(1)
24
+ await wait(1)
25
+ yield new MessageFrame(2)
26
+ await wait(1)
27
+ yield new MessageFrame(3)
28
+ return
29
+ },
30
+ })
31
+
32
+ await once(httpServer.listen(), 'listening')
33
+ const { port } = server.wss.address() as AddressInfo
34
+
35
+ const ws = new WebSocket(`ws://localhost:${port}`)
36
+ const frames: Frame[] = []
37
+ for await (const frame of byFrame(ws)) {
38
+ frames.push(frame)
39
+ }
40
+
41
+ expect(frames).toEqual([
42
+ new MessageFrame(1),
43
+ new MessageFrame(2),
44
+ new MessageFrame(3),
45
+ ])
46
+
47
+ httpServer.close()
48
+ })
49
+
50
+ it('kills handler and closes on error frame.', async () => {
51
+ let proceededAfterError = false
52
+ const httpServer = http.createServer()
53
+ const server = new XrpcStreamServer({
54
+ server: httpServer,
55
+ handler: async function* () {
56
+ await wait(1)
57
+ yield new MessageFrame(1)
58
+ await wait(1)
59
+ yield new MessageFrame(2)
60
+ await wait(1)
61
+ yield new ErrorFrame({ error: 'BadOops' })
62
+ proceededAfterError = true
63
+ await wait(1)
64
+ yield new MessageFrame(3)
65
+ return
66
+ },
67
+ })
68
+
69
+ await once(httpServer.listen(), 'listening')
70
+ const { port } = server.wss.address() as AddressInfo
71
+
72
+ const ws = new WebSocket(`ws://localhost:${port}`)
73
+ const frames: Frame[] = []
74
+ for await (const frame of byFrame(ws)) {
75
+ frames.push(frame)
76
+ }
77
+
78
+ await wait(5) // Ensure handler hasn't kept running
79
+ expect(proceededAfterError).toEqual(false)
80
+
81
+ expect(frames).toEqual([
82
+ new MessageFrame(1),
83
+ new MessageFrame(2),
84
+ new ErrorFrame({ error: 'BadOops' }),
85
+ ])
86
+
87
+ httpServer.close()
88
+ })
89
+
90
+ it('kills handler and closes client disconnect.', async () => {
91
+ const httpServer = http.createServer()
92
+ let i = 1
93
+ const server = new XrpcStreamServer({
94
+ server: httpServer,
95
+ handler: async function* () {
96
+ while (true) {
97
+ await wait(0)
98
+ yield new MessageFrame(i++)
99
+ }
100
+ },
101
+ })
102
+
103
+ await once(httpServer.listen(), 'listening')
104
+ const { port } = server.wss.address() as AddressInfo
105
+
106
+ const ws = new WebSocket(`ws://localhost:${port}`)
107
+ const frames: Frame[] = []
108
+ for await (const frame of byFrame(ws)) {
109
+ frames.push(frame)
110
+ if (frame.body === 3) ws.terminate()
111
+ }
112
+
113
+ // Grace period to let close take place on the server
114
+ await wait(5)
115
+ // Ensure handler hasn't kept running
116
+ const currentCount = i
117
+ await wait(5)
118
+ expect(i).toBe(currentCount)
119
+
120
+ httpServer.close()
121
+ })
122
+
123
+ describe('byMessage()', () => {
124
+ it('kills handler and closes client disconnect on error frame.', async () => {
125
+ const httpServer = http.createServer()
126
+ const server = new XrpcStreamServer({
127
+ server: httpServer,
128
+ handler: async function* () {
129
+ await wait(1)
130
+ yield new MessageFrame(1)
131
+ await wait(1)
132
+ yield new MessageFrame(2)
133
+ await wait(1)
134
+ yield new ErrorFrame({
135
+ error: 'BadOops',
136
+ message: 'That was a bad one',
137
+ })
138
+ await wait(1)
139
+ yield new MessageFrame(3)
140
+ return
141
+ },
142
+ })
143
+ await once(httpServer.listen(), 'listening')
144
+ const { port } = server.wss.address() as AddressInfo
145
+
146
+ const ws = new WebSocket(`ws://localhost:${port}`)
147
+ const frames: Frame[] = []
148
+
149
+ let error
150
+ try {
151
+ for await (const frame of byMessage(ws)) {
152
+ frames.push(frame)
153
+ }
154
+ } catch (err) {
155
+ error = err
156
+ }
157
+
158
+ expect(ws.readyState).toEqual(ws.CLOSING)
159
+ expect(frames).toEqual([new MessageFrame(1), new MessageFrame(2)])
160
+ expect(error).toBeInstanceOf(XRPCError)
161
+ if (error instanceof XRPCError) {
162
+ expect(error.error).toEqual('BadOops')
163
+ expect(error.message).toEqual('That was a bad one')
164
+ }
165
+
166
+ httpServer.close()
167
+ })
168
+ })
169
+ })
@@ -0,0 +1,347 @@
1
+ import * as http from 'http'
2
+ import { WebSocket, createWebSocketStream } from 'ws'
3
+ import getPort from 'get-port'
4
+ import { wait } from '@atproto/common'
5
+ import { byFrame, MessageFrame, ErrorFrame, Frame, Subscription } from '../src'
6
+ import {
7
+ createServer,
8
+ closeServer,
9
+ createBasicAuth,
10
+ basicAuthHeaders,
11
+ } from './_util'
12
+ import * as xrpcServer from '../src'
13
+
14
+ const LEXICONS = [
15
+ {
16
+ lexicon: 1,
17
+ id: 'io.example.stream1',
18
+ defs: {
19
+ main: {
20
+ type: 'subscription',
21
+ parameters: {
22
+ type: 'params',
23
+ required: ['countdown'],
24
+ properties: {
25
+ countdown: { type: 'integer' },
26
+ },
27
+ },
28
+ message: {
29
+ schema: {
30
+ type: 'object',
31
+ required: ['count'],
32
+ properties: { count: { type: 'integer' } },
33
+ },
34
+ },
35
+ },
36
+ },
37
+ },
38
+ {
39
+ lexicon: 1,
40
+ id: 'io.example.stream2',
41
+ defs: {
42
+ main: {
43
+ type: 'subscription',
44
+ parameters: {
45
+ type: 'params',
46
+ required: ['countdown'],
47
+ properties: {
48
+ countdown: { type: 'integer' },
49
+ },
50
+ },
51
+ message: {
52
+ schema: {
53
+ type: 'union',
54
+ refs: ['#even', '#odd'],
55
+ },
56
+ },
57
+ },
58
+ even: {
59
+ type: 'object',
60
+ required: ['count'],
61
+ properties: { count: { type: 'integer' } },
62
+ },
63
+ odd: {
64
+ type: 'object',
65
+ required: ['count'],
66
+ properties: { count: { type: 'integer' } },
67
+ },
68
+ },
69
+ },
70
+ {
71
+ lexicon: 1,
72
+ id: 'io.example.streamAuth',
73
+ defs: {
74
+ main: {
75
+ type: 'subscription',
76
+ },
77
+ },
78
+ },
79
+ ]
80
+
81
+ describe('Subscriptions', () => {
82
+ let s: http.Server
83
+
84
+ const server = xrpcServer.createServer(LEXICONS)
85
+ const lex = server.lex
86
+
87
+ server.streamMethod('io.example.stream1', async function* ({ params }) {
88
+ const countdown = Number(params.countdown ?? 0)
89
+ for (let i = countdown; i >= 0; i--) {
90
+ await wait(0)
91
+ yield { count: i }
92
+ }
93
+ })
94
+
95
+ server.streamMethod('io.example.stream2', async function* ({ params }) {
96
+ const countdown = Number(params.countdown ?? 0)
97
+ for (let i = countdown; i >= 0; i--) {
98
+ yield {
99
+ $type: i % 2 === 0 ? '#even' : 'io.example.stream2#odd',
100
+ count: i,
101
+ }
102
+ }
103
+ yield {
104
+ $type: 'io.example.otherNsid#done',
105
+ }
106
+ })
107
+
108
+ server.streamMethod('io.example.streamAuth', {
109
+ auth: createBasicAuth({ username: 'admin', password: 'password' }),
110
+ handler: async function* ({ auth }) {
111
+ yield auth
112
+ },
113
+ })
114
+
115
+ let port: number
116
+
117
+ beforeAll(async () => {
118
+ port = await getPort()
119
+ s = await createServer(port, server)
120
+ })
121
+ afterAll(async () => {
122
+ await closeServer(s)
123
+ })
124
+
125
+ it('streams messages', async () => {
126
+ const ws = new WebSocket(
127
+ `ws://localhost:${port}/xrpc/io.example.stream1?countdown=5`,
128
+ )
129
+
130
+ const frames: Frame[] = []
131
+ for await (const frame of byFrame(ws)) {
132
+ frames.push(frame)
133
+ }
134
+
135
+ expect(frames).toEqual([
136
+ new MessageFrame({ count: 5 }),
137
+ new MessageFrame({ count: 4 }),
138
+ new MessageFrame({ count: 3 }),
139
+ new MessageFrame({ count: 2 }),
140
+ new MessageFrame({ count: 1 }),
141
+ new MessageFrame({ count: 0 }),
142
+ ])
143
+ })
144
+
145
+ it('streams messages in a union', async () => {
146
+ const ws = new WebSocket(
147
+ `ws://localhost:${port}/xrpc/io.example.stream2?countdown=5`,
148
+ )
149
+
150
+ const frames: Frame[] = []
151
+ for await (const frame of byFrame(ws)) {
152
+ frames.push(frame)
153
+ }
154
+
155
+ expect(frames).toEqual([
156
+ new MessageFrame({ count: 5 }, { type: '#odd' }),
157
+ new MessageFrame({ count: 4 }, { type: '#even' }),
158
+ new MessageFrame({ count: 3 }, { type: '#odd' }),
159
+ new MessageFrame({ count: 2 }, { type: '#even' }),
160
+ new MessageFrame({ count: 1 }, { type: '#odd' }),
161
+ new MessageFrame({ count: 0 }, { type: '#even' }),
162
+ new MessageFrame({}, { type: 'io.example.otherNsid#done' }),
163
+ ])
164
+ })
165
+
166
+ it('resolves auth into handler', async () => {
167
+ const ws = new WebSocket(
168
+ `ws://localhost:${port}/xrpc/io.example.streamAuth`,
169
+ {
170
+ headers: basicAuthHeaders({
171
+ username: 'admin',
172
+ password: 'password',
173
+ }),
174
+ },
175
+ )
176
+
177
+ const frames: Frame[] = []
178
+ for await (const frame of byFrame(ws)) {
179
+ frames.push(frame)
180
+ }
181
+
182
+ expect(frames).toEqual([
183
+ new MessageFrame({
184
+ credentials: {
185
+ username: 'admin',
186
+ },
187
+ artifacts: {
188
+ original: 'YWRtaW46cGFzc3dvcmQ=',
189
+ },
190
+ }),
191
+ ])
192
+ })
193
+
194
+ it('errors immediately on bad parameter', async () => {
195
+ const ws = new WebSocket(`ws://localhost:${port}/xrpc/io.example.stream1`)
196
+
197
+ const frames: Frame[] = []
198
+ for await (const frame of byFrame(ws)) {
199
+ frames.push(frame)
200
+ }
201
+
202
+ expect(frames).toEqual([
203
+ new ErrorFrame({
204
+ error: 'InvalidRequest',
205
+ message: 'Error: Params must have the property "countdown"',
206
+ }),
207
+ ])
208
+ })
209
+
210
+ it('errors immediately on bad auth', async () => {
211
+ const ws = new WebSocket(
212
+ `ws://localhost:${port}/xrpc/io.example.streamAuth`,
213
+ {
214
+ headers: basicAuthHeaders({
215
+ username: 'bad',
216
+ password: 'wrong',
217
+ }),
218
+ },
219
+ )
220
+
221
+ const frames: Frame[] = []
222
+ for await (const frame of byFrame(ws)) {
223
+ frames.push(frame)
224
+ }
225
+
226
+ expect(frames).toEqual([
227
+ new ErrorFrame({
228
+ error: 'AuthenticationRequired',
229
+ message: 'Authentication Required',
230
+ }),
231
+ ])
232
+ })
233
+
234
+ it('does not websocket upgrade at bad endpoint', async () => {
235
+ const ws = new WebSocket(`ws://localhost:${port}/xrpc/does.not.exist`)
236
+ const drainStream = async () => {
237
+ for await (const bytes of createWebSocketStream(ws)) {
238
+ bytes // drain
239
+ }
240
+ }
241
+ await expect(drainStream).rejects.toHaveProperty('code', 'ECONNRESET')
242
+ })
243
+
244
+ describe('Subscription consumer', () => {
245
+ it('receives messages w/ skips', async () => {
246
+ const sub = new Subscription({
247
+ service: `ws://localhost:${port}`,
248
+ method: 'io.example.stream1',
249
+ getParams: () => ({ countdown: 5 }),
250
+ validate: (obj) => {
251
+ const result = lex.assertValidXrpcMessage<{ count: number }>(
252
+ 'io.example.stream1',
253
+ obj,
254
+ )
255
+ if (!result.count || result.count % 2) {
256
+ return result
257
+ }
258
+ },
259
+ })
260
+
261
+ const messages: { count: number }[] = []
262
+ for await (const msg of sub) {
263
+ messages.push(msg)
264
+ }
265
+
266
+ expect(messages).toEqual([
267
+ { count: 5 },
268
+ { count: 3 },
269
+ { count: 1 },
270
+ { count: 0 },
271
+ ])
272
+ })
273
+
274
+ it('reconnects w/ param update', async () => {
275
+ let countdown = 10
276
+ let reconnects = 0
277
+ const sub = new Subscription({
278
+ service: `ws://localhost:${port}`,
279
+ method: 'io.example.stream1',
280
+ onReconnectError: () => reconnects++,
281
+ getParams: () => ({ countdown }),
282
+ validate: (obj) => {
283
+ return lex.assertValidXrpcMessage<{ count: number }>(
284
+ 'io.example.stream1',
285
+ obj,
286
+ )
287
+ },
288
+ })
289
+
290
+ let disconnected = false
291
+ for await (const msg of sub) {
292
+ expect(msg.count).toBeGreaterThanOrEqual(countdown - 1) // No skips
293
+ countdown = Math.min(countdown, msg.count) // Only allow forward movement
294
+ if (msg.count <= 6 && !disconnected) {
295
+ disconnected = true
296
+ server.subscriptions.forEach(({ wss }) => {
297
+ wss.clients.forEach((c) => c.terminate())
298
+ })
299
+ }
300
+ }
301
+
302
+ expect(countdown).toEqual(0)
303
+ expect(reconnects).toBeGreaterThan(0)
304
+ })
305
+
306
+ it('aborts with signal', async () => {
307
+ const abortController = new AbortController()
308
+ const sub = new Subscription({
309
+ service: `ws://localhost:${port}`,
310
+ method: 'io.example.stream1',
311
+ signal: abortController.signal,
312
+ getParams: () => ({ countdown: 10 }),
313
+ validate: (obj) => {
314
+ const result = lex.assertValidXrpcMessage<{ count: number }>(
315
+ 'io.example.stream1',
316
+ obj,
317
+ )
318
+ return result
319
+ },
320
+ })
321
+
322
+ let error
323
+ let disconnected = false
324
+ const messages: { count: number }[] = []
325
+ try {
326
+ for await (const msg of sub) {
327
+ messages.push(msg)
328
+ if (msg.count <= 6 && !disconnected) {
329
+ disconnected = true
330
+ abortController.abort(new Error('Oops!'))
331
+ }
332
+ }
333
+ } catch (err) {
334
+ error = err
335
+ }
336
+
337
+ expect(error).toEqual(new Error('Oops!'))
338
+ expect(messages).toEqual([
339
+ { count: 10 },
340
+ { count: 9 },
341
+ { count: 8 },
342
+ { count: 7 },
343
+ { count: 6 },
344
+ ])
345
+ })
346
+ })
347
+ })