@atproto/lex-server 0.1.3 → 0.1.5
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 +29 -0
- package/dist/errors.d.ts +3 -2
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/lex-router.d.ts +2 -2
- package/dist/lex-router.d.ts.map +1 -1
- package/dist/lex-router.js +2 -2
- package/dist/lex-router.js.map +1 -1
- package/dist/nodejs.d.ts +3 -2
- package/dist/nodejs.d.ts.map +1 -1
- package/dist/nodejs.js +1 -1
- package/dist/nodejs.js.map +1 -1
- package/dist/service-auth.d.ts +3 -3
- package/dist/service-auth.d.ts.map +1 -1
- package/dist/service-auth.js +2 -2
- package/dist/service-auth.js.map +1 -1
- package/package.json +15 -20
- package/nodejs.cjs +0 -5
- package/src/errors.test.ts +0 -262
- package/src/errors.ts +0 -173
- package/src/index.ts +0 -3
- package/src/lex-router.test.ts +0 -2189
- package/src/lex-router.ts +0 -1219
- package/src/lib/drain-websocket.ts +0 -34
- package/src/lib/sleep.ts +0 -25
- package/src/lib/www-authenticate.test.ts +0 -134
- package/src/lib/www-authenticate.ts +0 -111
- package/src/nodejs.test.ts +0 -107
- package/src/nodejs.ts +0 -678
- package/src/service-auth.test.ts +0 -87
- package/src/service-auth.ts +0 -517
- package/tsconfig.build.json +0 -12
- package/tsconfig.json +0 -8
- package/tsconfig.tests.json +0 -8
package/src/lex-router.test.ts
DELETED
|
@@ -1,2189 +0,0 @@
|
|
|
1
|
-
import { AddressInfo } from 'node:net'
|
|
2
|
-
import { scheduler } from 'node:timers/promises'
|
|
3
|
-
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { WebSocket } from 'ws'
|
|
5
|
-
import { decodeAll } from '@atproto/lex-cbor'
|
|
6
|
-
import { buildAgent, xrpc } from '@atproto/lex-client'
|
|
7
|
-
import { parseCid } from '@atproto/lex-data'
|
|
8
|
-
import { l } from '@atproto/lex-schema'
|
|
9
|
-
import { LexError, LexServerAuthError, LexServerError } from './errors.js'
|
|
10
|
-
import {
|
|
11
|
-
ConnectionInfo,
|
|
12
|
-
HandlerErrorHook,
|
|
13
|
-
HealthCheckHandler,
|
|
14
|
-
LexRouter,
|
|
15
|
-
LexRouterAuth,
|
|
16
|
-
LexRouterMethodHandler,
|
|
17
|
-
SocketErrorHook,
|
|
18
|
-
} from './lex-router.js'
|
|
19
|
-
import { serve, upgradeWebSocket } from './nodejs.js'
|
|
20
|
-
|
|
21
|
-
// ============================================================================
|
|
22
|
-
// Schema Definitions
|
|
23
|
-
// ============================================================================
|
|
24
|
-
|
|
25
|
-
const io = {
|
|
26
|
-
example: {
|
|
27
|
-
echo: l.procedure(
|
|
28
|
-
'io.example.echo',
|
|
29
|
-
l.params(),
|
|
30
|
-
l.payload('*/*'),
|
|
31
|
-
l.payload('*/*'),
|
|
32
|
-
),
|
|
33
|
-
status: l.query(
|
|
34
|
-
'io.example.status',
|
|
35
|
-
l.params(),
|
|
36
|
-
l.payload('application/json', l.object({ status: l.string() })),
|
|
37
|
-
),
|
|
38
|
-
ipld: l.procedure(
|
|
39
|
-
'io.example.ipld',
|
|
40
|
-
l.params(),
|
|
41
|
-
l.payload(
|
|
42
|
-
'application/json',
|
|
43
|
-
l.object({
|
|
44
|
-
cid: l.cid(),
|
|
45
|
-
bytes: l.bytes(),
|
|
46
|
-
}),
|
|
47
|
-
),
|
|
48
|
-
l.payload(
|
|
49
|
-
'application/json',
|
|
50
|
-
l.object({
|
|
51
|
-
cid: l.cid(),
|
|
52
|
-
bytes: l.bytes(),
|
|
53
|
-
}),
|
|
54
|
-
),
|
|
55
|
-
),
|
|
56
|
-
paramsToBody: l.query(
|
|
57
|
-
'io.example.paramsToBody',
|
|
58
|
-
l.params({
|
|
59
|
-
name: l.string(),
|
|
60
|
-
pronouns: l.array(l.string()),
|
|
61
|
-
}),
|
|
62
|
-
l.payload(
|
|
63
|
-
'application/json',
|
|
64
|
-
l.object({
|
|
65
|
-
params: l.object({
|
|
66
|
-
name: l.string(),
|
|
67
|
-
pronouns: l.array(l.string()),
|
|
68
|
-
}),
|
|
69
|
-
}),
|
|
70
|
-
),
|
|
71
|
-
),
|
|
72
|
-
},
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const handlers: {
|
|
76
|
-
[K in keyof typeof io.example]: LexRouterMethodHandler<(typeof io.example)[K]>
|
|
77
|
-
} = {
|
|
78
|
-
echo: async ({ input }) => ({
|
|
79
|
-
encoding: input.encoding,
|
|
80
|
-
body: input.body.body!,
|
|
81
|
-
}),
|
|
82
|
-
status: async () => ({ body: { status: 'ok' } }),
|
|
83
|
-
ipld: async ({ input }) => ({ body: input.body! }),
|
|
84
|
-
paramsToBody: async ({ params }) => ({ body: { params } }),
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ============================================================================
|
|
88
|
-
// Basic LexRouter Tests
|
|
89
|
-
// ============================================================================
|
|
90
|
-
|
|
91
|
-
describe(LexRouter, () => {
|
|
92
|
-
it('returns MethodNotImplemented when the route is not found', async () => {
|
|
93
|
-
const router = new LexRouter()
|
|
94
|
-
const request = new Request(`https://example.com/xrpc/foo.bar.baz`)
|
|
95
|
-
const response = await router.fetch(request)
|
|
96
|
-
expect(response.status).toBe(501)
|
|
97
|
-
expect(await response.json()).toMatchObject({
|
|
98
|
-
error: 'MethodNotImplemented',
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('streams payloads', async () => {
|
|
103
|
-
const router = new LexRouter().add(io.example.echo, handlers.echo)
|
|
104
|
-
const request = new Request('https://example.com/xrpc/io.example.echo', {
|
|
105
|
-
method: 'POST',
|
|
106
|
-
headers: { 'content-type': 'text/plain' },
|
|
107
|
-
// @ts-expect-error
|
|
108
|
-
duplex: 'half',
|
|
109
|
-
body: new ReadableStream({
|
|
110
|
-
start(controller) {
|
|
111
|
-
setTimeout(() => {
|
|
112
|
-
controller.enqueue(new TextEncoder().encode('aaa'))
|
|
113
|
-
setTimeout(() => {
|
|
114
|
-
controller.enqueue(new TextEncoder().encode('bbb'))
|
|
115
|
-
setTimeout(() => {
|
|
116
|
-
controller.error(new Error('Stream closed'))
|
|
117
|
-
}, 50)
|
|
118
|
-
}, 50)
|
|
119
|
-
}, 50)
|
|
120
|
-
},
|
|
121
|
-
}),
|
|
122
|
-
})
|
|
123
|
-
const response = await router.fetch(request)
|
|
124
|
-
|
|
125
|
-
const reader = response.body!.getReader()
|
|
126
|
-
const chunks: string[] = []
|
|
127
|
-
try {
|
|
128
|
-
// eslint-disable-next-line no-constant-condition
|
|
129
|
-
while (true) {
|
|
130
|
-
const { done, value } = await reader.read()
|
|
131
|
-
if (done) break
|
|
132
|
-
chunks.push(new TextDecoder().decode(value))
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
expect((err as Error).message).toBe('Stream closed')
|
|
136
|
-
}
|
|
137
|
-
expect(chunks).toEqual(['aaa', 'bbb'])
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('maps params to body', async () => {
|
|
141
|
-
const router = new LexRouter().add(
|
|
142
|
-
io.example.paramsToBody,
|
|
143
|
-
handlers.paramsToBody,
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
const request = new Request(
|
|
147
|
-
'https://example.com/xrpc/io.example.paramsToBody?name=Alice&pronouns=she%2Fher&pronouns=they%2Fthem',
|
|
148
|
-
)
|
|
149
|
-
const response = await router.fetch(request)
|
|
150
|
-
|
|
151
|
-
expect(response.status).toBe(200)
|
|
152
|
-
expect(await response.json()).toEqual({
|
|
153
|
-
params: {
|
|
154
|
-
name: 'Alice',
|
|
155
|
-
pronouns: ['she/her', 'they/them'],
|
|
156
|
-
},
|
|
157
|
-
})
|
|
158
|
-
})
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
describe('lex-client integration', () => {
|
|
162
|
-
const router = new LexRouter()
|
|
163
|
-
.add(io.example.echo, handlers.echo)
|
|
164
|
-
.add(io.example.status, handlers.status)
|
|
165
|
-
|
|
166
|
-
it('echoes text', async () => {
|
|
167
|
-
const agent = buildAgent({
|
|
168
|
-
fetch: async (input, init) => {
|
|
169
|
-
const request = new Request(input, init)
|
|
170
|
-
return router.fetch(request)
|
|
171
|
-
},
|
|
172
|
-
service: 'https://example.com',
|
|
173
|
-
})
|
|
174
|
-
const message = 'Hello, LexRouter!'
|
|
175
|
-
const response = await xrpc(agent, io.example.echo, {
|
|
176
|
-
body: message,
|
|
177
|
-
encoding: 'text/plain',
|
|
178
|
-
})
|
|
179
|
-
const responseText = new TextDecoder().decode(response.body)
|
|
180
|
-
expect(responseText).toBe(message)
|
|
181
|
-
expect(response.encoding).toBe('text/plain')
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
it('streams text', async () => {
|
|
185
|
-
const agent = buildAgent({
|
|
186
|
-
fetch: async (input, init) => {
|
|
187
|
-
const request = new Request(input, init)
|
|
188
|
-
return router.fetch(request)
|
|
189
|
-
},
|
|
190
|
-
service: 'https://example.com',
|
|
191
|
-
})
|
|
192
|
-
const message = 'Hello, LexRouter Stream!'
|
|
193
|
-
const response = await xrpc(agent, io.example.echo, {
|
|
194
|
-
body: new ReadableStream({
|
|
195
|
-
start(controller) {
|
|
196
|
-
controller.enqueue(new TextEncoder().encode(message))
|
|
197
|
-
controller.close()
|
|
198
|
-
},
|
|
199
|
-
}),
|
|
200
|
-
encoding: 'text/plain',
|
|
201
|
-
})
|
|
202
|
-
const responseText = new TextDecoder().decode(response.body)
|
|
203
|
-
expect(responseText).toBe(message)
|
|
204
|
-
expect(response.encoding).toBe('text/plain')
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('performs simple query', async () => {
|
|
208
|
-
const agent = buildAgent({
|
|
209
|
-
fetch: async (input, init) => {
|
|
210
|
-
const request = new Request(input, init)
|
|
211
|
-
return router.fetch(request)
|
|
212
|
-
},
|
|
213
|
-
service: 'https://example.com',
|
|
214
|
-
})
|
|
215
|
-
const response = await xrpc(agent, io.example.status)
|
|
216
|
-
expect(response.success).toBe(true)
|
|
217
|
-
expect(response.status).toBe(200)
|
|
218
|
-
expect(response.encoding).toBe('application/json')
|
|
219
|
-
expect(response.body.status).toBe('ok')
|
|
220
|
-
})
|
|
221
|
-
})
|
|
222
|
-
|
|
223
|
-
describe('IPLD values', () => {
|
|
224
|
-
it('can send and receive ipld vals', async () => {
|
|
225
|
-
const ipldHandler: LexRouterMethodHandler<typeof io.example.ipld> = vi.fn(
|
|
226
|
-
async ({ input }) => {
|
|
227
|
-
return { body: input.body! }
|
|
228
|
-
},
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
const router = new LexRouter().add(io.example.ipld, ipldHandler)
|
|
232
|
-
|
|
233
|
-
const agent = buildAgent({
|
|
234
|
-
fetch: async (input, init) => {
|
|
235
|
-
const request = new Request(input, init)
|
|
236
|
-
return router.fetch(request)
|
|
237
|
-
},
|
|
238
|
-
service: 'https://example.com',
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
const cid = parseCid(
|
|
242
|
-
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
|
243
|
-
)
|
|
244
|
-
|
|
245
|
-
const bytes = new Uint8Array([0, 1, 2, 3])
|
|
246
|
-
|
|
247
|
-
const response = await xrpc(agent, io.example.ipld, {
|
|
248
|
-
body: { cid, bytes },
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
expect(ipldHandler).toHaveBeenCalledTimes(1)
|
|
252
|
-
expect(response.success).toBe(true)
|
|
253
|
-
expect(response.encoding).toBe('application/json')
|
|
254
|
-
expect(response.body.cid.equals(cid)).toBe(true)
|
|
255
|
-
expect(response.body.bytes).toEqual(bytes)
|
|
256
|
-
})
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
// ============================================================================
|
|
260
|
-
// Authentication Tests (ported from xrpc-server/tests/auth.test.ts)
|
|
261
|
-
// ============================================================================
|
|
262
|
-
|
|
263
|
-
describe('Authentication', () => {
|
|
264
|
-
// Basic auth schema
|
|
265
|
-
const io = {
|
|
266
|
-
example: {
|
|
267
|
-
authTest: l.procedure(
|
|
268
|
-
'io.example.authTest',
|
|
269
|
-
l.params(),
|
|
270
|
-
l.payload(
|
|
271
|
-
'application/json',
|
|
272
|
-
l.object({
|
|
273
|
-
present: l.literal(true),
|
|
274
|
-
}),
|
|
275
|
-
),
|
|
276
|
-
l.payload(
|
|
277
|
-
'application/json',
|
|
278
|
-
l.object({
|
|
279
|
-
username: l.string(),
|
|
280
|
-
original: l.string(),
|
|
281
|
-
}),
|
|
282
|
-
),
|
|
283
|
-
),
|
|
284
|
-
},
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
type BasicAuthCredentials = {
|
|
288
|
-
username: string
|
|
289
|
-
original: string
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function createBasicAuth(allowed: {
|
|
293
|
-
username: string
|
|
294
|
-
password: string
|
|
295
|
-
}): LexRouterAuth<BasicAuthCredentials> {
|
|
296
|
-
return async ({ request }) => {
|
|
297
|
-
const header = request.headers.get('authorization') ?? ''
|
|
298
|
-
if (!header.startsWith('Basic ')) {
|
|
299
|
-
throw new LexServerAuthError(
|
|
300
|
-
'AuthenticationRequired',
|
|
301
|
-
'Authentication required',
|
|
302
|
-
)
|
|
303
|
-
}
|
|
304
|
-
const original = header.slice(6)
|
|
305
|
-
const decoded = Buffer.from(original, 'base64').toString()
|
|
306
|
-
// @NOTE not using .split(':') to allow colons in password
|
|
307
|
-
const colonIndex = decoded.indexOf(':')
|
|
308
|
-
const [username, password] =
|
|
309
|
-
colonIndex === -1
|
|
310
|
-
? [decoded, '']
|
|
311
|
-
: [decoded.slice(0, colonIndex), decoded.slice(colonIndex + 1)]
|
|
312
|
-
if (username !== allowed.username || password !== allowed.password) {
|
|
313
|
-
throw new LexServerAuthError(
|
|
314
|
-
'AuthenticationRequired',
|
|
315
|
-
'Invalid credentials',
|
|
316
|
-
)
|
|
317
|
-
}
|
|
318
|
-
return { username, original }
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function basicAuth(creds: { username: string; password: string }) {
|
|
323
|
-
return `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString('base64')}`
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
const authTestHandler: LexRouterMethodHandler<
|
|
327
|
-
typeof io.example.authTest,
|
|
328
|
-
BasicAuthCredentials
|
|
329
|
-
> = async ({ credentials }) => ({
|
|
330
|
-
body: {
|
|
331
|
-
username: credentials.username,
|
|
332
|
-
original: credentials.original,
|
|
333
|
-
},
|
|
334
|
-
})
|
|
335
|
-
|
|
336
|
-
it('fails on bad auth before invalid request payload', async () => {
|
|
337
|
-
const router = new LexRouter().add(io.example.authTest, {
|
|
338
|
-
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
339
|
-
handler: authTestHandler,
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
const request = new Request(
|
|
343
|
-
'https://example.com/xrpc/io.example.authTest',
|
|
344
|
-
{
|
|
345
|
-
method: 'POST',
|
|
346
|
-
headers: {
|
|
347
|
-
'content-type': 'application/json',
|
|
348
|
-
authorization: basicAuth({ username: 'admin', password: 'wrong' }),
|
|
349
|
-
},
|
|
350
|
-
body: JSON.stringify({ present: false }),
|
|
351
|
-
},
|
|
352
|
-
)
|
|
353
|
-
const response = await router.fetch(request)
|
|
354
|
-
|
|
355
|
-
expect(response.status).toBe(401)
|
|
356
|
-
const data = await response.json()
|
|
357
|
-
expect(data.error).toBe('AuthenticationRequired')
|
|
358
|
-
})
|
|
359
|
-
|
|
360
|
-
it('fails on invalid request payload after good auth', async () => {
|
|
361
|
-
const router = new LexRouter().add(io.example.authTest, {
|
|
362
|
-
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
363
|
-
handler: authTestHandler,
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
const request = new Request(
|
|
367
|
-
'https://example.com/xrpc/io.example.authTest',
|
|
368
|
-
{
|
|
369
|
-
method: 'POST',
|
|
370
|
-
headers: {
|
|
371
|
-
'content-type': 'application/json',
|
|
372
|
-
authorization: basicAuth({ username: 'admin', password: 'password' }),
|
|
373
|
-
},
|
|
374
|
-
body: JSON.stringify({ present: false }),
|
|
375
|
-
},
|
|
376
|
-
)
|
|
377
|
-
const response = await router.fetch(request)
|
|
378
|
-
|
|
379
|
-
expect(response.status).toBe(400)
|
|
380
|
-
const data = await response.json()
|
|
381
|
-
expect(data.error).toBe('InvalidRequest')
|
|
382
|
-
})
|
|
383
|
-
|
|
384
|
-
it('succeeds on good auth and payload', async () => {
|
|
385
|
-
const router = new LexRouter().add(io.example.authTest, {
|
|
386
|
-
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
387
|
-
handler: authTestHandler,
|
|
388
|
-
})
|
|
389
|
-
|
|
390
|
-
const request = new Request(
|
|
391
|
-
'https://example.com/xrpc/io.example.authTest',
|
|
392
|
-
{
|
|
393
|
-
method: 'POST',
|
|
394
|
-
headers: {
|
|
395
|
-
'content-type': 'application/json',
|
|
396
|
-
authorization: basicAuth({ username: 'admin', password: 'password' }),
|
|
397
|
-
},
|
|
398
|
-
body: JSON.stringify({ present: true }),
|
|
399
|
-
},
|
|
400
|
-
)
|
|
401
|
-
const response = await router.fetch(request)
|
|
402
|
-
|
|
403
|
-
expect(response.status).toBe(200)
|
|
404
|
-
const data = await response.json()
|
|
405
|
-
expect(data.username).toBe('admin')
|
|
406
|
-
expect(data.original).toBe('YWRtaW46cGFzc3dvcmQ=')
|
|
407
|
-
})
|
|
408
|
-
|
|
409
|
-
it('handles missing auth header', async () => {
|
|
410
|
-
const router = new LexRouter().add(io.example.authTest, {
|
|
411
|
-
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
412
|
-
handler: authTestHandler,
|
|
413
|
-
})
|
|
414
|
-
|
|
415
|
-
const request = new Request(
|
|
416
|
-
'https://example.com/xrpc/io.example.authTest',
|
|
417
|
-
{
|
|
418
|
-
method: 'POST',
|
|
419
|
-
headers: { 'content-type': 'application/json' },
|
|
420
|
-
body: JSON.stringify({ present: true }),
|
|
421
|
-
},
|
|
422
|
-
)
|
|
423
|
-
const response = await router.fetch(request)
|
|
424
|
-
|
|
425
|
-
expect(response.status).toBe(401)
|
|
426
|
-
const data = await response.json()
|
|
427
|
-
expect(data.error).toBe('AuthenticationRequired')
|
|
428
|
-
})
|
|
429
|
-
})
|
|
430
|
-
|
|
431
|
-
// ============================================================================
|
|
432
|
-
// Error Handling Tests (ported from xrpc-server/tests/errors.test.ts)
|
|
433
|
-
// ============================================================================
|
|
434
|
-
|
|
435
|
-
describe('Error Handling', () => {
|
|
436
|
-
const io = {
|
|
437
|
-
example: {
|
|
438
|
-
error: l.query(
|
|
439
|
-
'io.example.error',
|
|
440
|
-
l.params({
|
|
441
|
-
which: l.optional(l.string()),
|
|
442
|
-
}),
|
|
443
|
-
l.payload(),
|
|
444
|
-
),
|
|
445
|
-
throwFalsyValue: l.query(
|
|
446
|
-
'io.example.throwFalsyValue',
|
|
447
|
-
l.params(),
|
|
448
|
-
l.payload(),
|
|
449
|
-
),
|
|
450
|
-
invalidResponse: l.query(
|
|
451
|
-
'io.example.invalidResponse',
|
|
452
|
-
l.params(),
|
|
453
|
-
l.payload(
|
|
454
|
-
'application/json',
|
|
455
|
-
l.object({
|
|
456
|
-
expectedValue: l.string(),
|
|
457
|
-
}),
|
|
458
|
-
),
|
|
459
|
-
),
|
|
460
|
-
},
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
describe('Custom Errors', () => {
|
|
464
|
-
it('throws custom error using LexError', async () => {
|
|
465
|
-
const handler: LexRouterMethodHandler<typeof io.example.error> = async ({
|
|
466
|
-
params,
|
|
467
|
-
}) => {
|
|
468
|
-
if (params.which === 'foo') {
|
|
469
|
-
throw new LexServerError(400, {
|
|
470
|
-
error: 'Foo',
|
|
471
|
-
message: 'It was this one!',
|
|
472
|
-
})
|
|
473
|
-
}
|
|
474
|
-
return {}
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
const router = new LexRouter().add(io.example.error, handler)
|
|
478
|
-
|
|
479
|
-
const request = new Request(
|
|
480
|
-
'https://example.com/xrpc/io.example.error?which=foo',
|
|
481
|
-
)
|
|
482
|
-
const response = await router.fetch(request)
|
|
483
|
-
|
|
484
|
-
expect(response.status).toBe(400)
|
|
485
|
-
const data = await response.json()
|
|
486
|
-
expect(data.error).toBe('Foo')
|
|
487
|
-
expect(data.message).toBe('It was this one!')
|
|
488
|
-
})
|
|
489
|
-
|
|
490
|
-
it('returns custom error via Response object', async () => {
|
|
491
|
-
const handler: LexRouterMethodHandler<typeof io.example.error> = async ({
|
|
492
|
-
params,
|
|
493
|
-
}) => {
|
|
494
|
-
if (params.which === 'bar') {
|
|
495
|
-
return Response.json(
|
|
496
|
-
{ error: 'Bar', message: 'It was that one!' },
|
|
497
|
-
{ status: 400 },
|
|
498
|
-
)
|
|
499
|
-
}
|
|
500
|
-
return {}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
const router = new LexRouter().add(io.example.error, handler)
|
|
504
|
-
|
|
505
|
-
const request = new Request(
|
|
506
|
-
'https://example.com/xrpc/io.example.error?which=bar',
|
|
507
|
-
)
|
|
508
|
-
const response = await router.fetch(request)
|
|
509
|
-
|
|
510
|
-
expect(response.status).toBe(400)
|
|
511
|
-
const data = await response.json()
|
|
512
|
-
expect(data.error).toBe('Bar')
|
|
513
|
-
expect(data.message).toBe('It was that one!')
|
|
514
|
-
})
|
|
515
|
-
|
|
516
|
-
it('handles falsy values thrown as InternalServerError', async () => {
|
|
517
|
-
const handler: LexRouterMethodHandler<
|
|
518
|
-
typeof io.example.throwFalsyValue
|
|
519
|
-
> = async () => {
|
|
520
|
-
throw ''
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const router = new LexRouter().add(io.example.throwFalsyValue, handler)
|
|
524
|
-
|
|
525
|
-
const request = new Request(
|
|
526
|
-
'https://example.com/xrpc/io.example.throwFalsyValue',
|
|
527
|
-
)
|
|
528
|
-
const response = await router.fetch(request)
|
|
529
|
-
|
|
530
|
-
expect(response.status).toBe(500)
|
|
531
|
-
const data = await response.json()
|
|
532
|
-
expect(data.error).toBe('InternalServerError')
|
|
533
|
-
})
|
|
534
|
-
})
|
|
535
|
-
|
|
536
|
-
describe('HTTP Method Mismatches', () => {
|
|
537
|
-
it('rejects POST for query endpoints', async () => {
|
|
538
|
-
const handler: LexRouterMethodHandler<
|
|
539
|
-
typeof io.example.error
|
|
540
|
-
> = async () => ({})
|
|
541
|
-
|
|
542
|
-
const router = new LexRouter().add(io.example.error, handler)
|
|
543
|
-
|
|
544
|
-
const request = new Request('https://example.com/xrpc/io.example.error', {
|
|
545
|
-
method: 'POST',
|
|
546
|
-
})
|
|
547
|
-
const response = await router.fetch(request)
|
|
548
|
-
|
|
549
|
-
expect(response.status).toBe(405)
|
|
550
|
-
const data = await response.json()
|
|
551
|
-
expect(data.error).toBe('InvalidRequest')
|
|
552
|
-
expect(data.message).toBe('Method not allowed')
|
|
553
|
-
})
|
|
554
|
-
|
|
555
|
-
it('rejects GET for procedure endpoints', async () => {
|
|
556
|
-
const procedure = l.procedure(
|
|
557
|
-
'io.example.procedure',
|
|
558
|
-
l.params(),
|
|
559
|
-
l.payload('application/json', l.object({ data: l.string() })),
|
|
560
|
-
l.payload(),
|
|
561
|
-
)
|
|
562
|
-
|
|
563
|
-
const handler: LexRouterMethodHandler<typeof procedure> = async () => ({})
|
|
564
|
-
|
|
565
|
-
const router = new LexRouter().add(procedure, handler)
|
|
566
|
-
|
|
567
|
-
const request = new Request(
|
|
568
|
-
'https://example.com/xrpc/io.example.procedure',
|
|
569
|
-
{ method: 'GET' },
|
|
570
|
-
)
|
|
571
|
-
const response = await router.fetch(request)
|
|
572
|
-
|
|
573
|
-
expect(response.status).toBe(405)
|
|
574
|
-
const data = await response.json()
|
|
575
|
-
expect(data.error).toBe('InvalidRequest')
|
|
576
|
-
expect(data.message).toBe('Method not allowed')
|
|
577
|
-
})
|
|
578
|
-
})
|
|
579
|
-
|
|
580
|
-
describe('Method Not Found', () => {
|
|
581
|
-
it('returns MethodNotImplemented for non-existent methods', async () => {
|
|
582
|
-
const router = new LexRouter()
|
|
583
|
-
|
|
584
|
-
const request = new Request(
|
|
585
|
-
'https://example.com/xrpc/io.example.doesNotExist',
|
|
586
|
-
)
|
|
587
|
-
const response = await router.fetch(request)
|
|
588
|
-
|
|
589
|
-
expect(response.status).toBe(501)
|
|
590
|
-
expect(await response.json()).toMatchObject({
|
|
591
|
-
error: 'MethodNotImplemented',
|
|
592
|
-
})
|
|
593
|
-
})
|
|
594
|
-
})
|
|
595
|
-
|
|
596
|
-
describe('Custom Error Handlers', () => {
|
|
597
|
-
it('allows custom onHandlerError handler', async () => {
|
|
598
|
-
const onHandlerError = vi.fn<HandlerErrorHook>()
|
|
599
|
-
const customRouter = new LexRouter({
|
|
600
|
-
onHandlerError,
|
|
601
|
-
})
|
|
602
|
-
|
|
603
|
-
const handler: LexRouterMethodHandler<
|
|
604
|
-
typeof io.example.error
|
|
605
|
-
> = async () => {
|
|
606
|
-
throw new Error('Test error')
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
customRouter.add(io.example.error, handler)
|
|
610
|
-
|
|
611
|
-
const request = new Request('https://example.com/xrpc/io.example.error')
|
|
612
|
-
const response = await customRouter.fetch(request)
|
|
613
|
-
|
|
614
|
-
expect(onHandlerError).toHaveBeenCalled()
|
|
615
|
-
expect(response.status).toBe(500)
|
|
616
|
-
})
|
|
617
|
-
})
|
|
618
|
-
})
|
|
619
|
-
|
|
620
|
-
// ============================================================================
|
|
621
|
-
// Routing Tests
|
|
622
|
-
// ============================================================================
|
|
623
|
-
|
|
624
|
-
describe('Routing', () => {
|
|
625
|
-
describe('non-/xrpc/ paths', () => {
|
|
626
|
-
it('returns 404 for non-xrpc paths without fallback', async () => {
|
|
627
|
-
const router = new LexRouter()
|
|
628
|
-
const request = new Request('https://example.com/health')
|
|
629
|
-
const response = await router.fetch(request)
|
|
630
|
-
|
|
631
|
-
expect(response.status).toBe(404)
|
|
632
|
-
expect(await response.text()).toBe('Not Found')
|
|
633
|
-
})
|
|
634
|
-
|
|
635
|
-
it('delegates to fallback handler for non-xrpc paths', async () => {
|
|
636
|
-
const fallback = vi.fn(async () => new Response('OK from fallback'))
|
|
637
|
-
const router = new LexRouter({ fallback })
|
|
638
|
-
|
|
639
|
-
const request = new Request('https://example.com/health')
|
|
640
|
-
const connection: ConnectionInfo = {
|
|
641
|
-
completed: Promise.resolve(),
|
|
642
|
-
remoteAddr: { hostname: '127.0.0.1', port: 3000, transport: 'tcp' },
|
|
643
|
-
}
|
|
644
|
-
const response = await router.fetch(request, connection)
|
|
645
|
-
|
|
646
|
-
expect(fallback).toHaveBeenCalledWith(request, connection)
|
|
647
|
-
expect(response.status).toBe(200)
|
|
648
|
-
expect(await response.text()).toBe('OK from fallback')
|
|
649
|
-
})
|
|
650
|
-
})
|
|
651
|
-
|
|
652
|
-
describe('/xrpc/_health endpoint', () => {
|
|
653
|
-
it('returns default health check response', async () => {
|
|
654
|
-
const router = new LexRouter()
|
|
655
|
-
const request = new Request('https://example.com/xrpc/_health')
|
|
656
|
-
const response = await router.fetch(request)
|
|
657
|
-
|
|
658
|
-
expect(response.status).toBe(200)
|
|
659
|
-
expect(await response.json()).toEqual({ status: 'ok' })
|
|
660
|
-
})
|
|
661
|
-
|
|
662
|
-
it('calls custom healthCheck handler', async () => {
|
|
663
|
-
const healthCheck = vi.fn<HealthCheckHandler>(async () => ({
|
|
664
|
-
status: 'ok',
|
|
665
|
-
version: '1.0.0',
|
|
666
|
-
}))
|
|
667
|
-
const router = new LexRouter({ healthCheck })
|
|
668
|
-
|
|
669
|
-
const request = new Request('https://example.com/xrpc/_health')
|
|
670
|
-
const response = await router.fetch(request)
|
|
671
|
-
|
|
672
|
-
expect(healthCheck).toHaveBeenCalledWith(request)
|
|
673
|
-
expect(response.status).toBe(200)
|
|
674
|
-
expect(await response.json()).toEqual({ status: 'ok', version: '1.0.0' })
|
|
675
|
-
})
|
|
676
|
-
|
|
677
|
-
it('returns 405 for non-GET requests', async () => {
|
|
678
|
-
const router = new LexRouter()
|
|
679
|
-
const request = new Request('https://example.com/xrpc/_health', {
|
|
680
|
-
method: 'POST',
|
|
681
|
-
})
|
|
682
|
-
const response = await router.fetch(request)
|
|
683
|
-
|
|
684
|
-
expect(response.status).toBe(405)
|
|
685
|
-
const data = await response.json()
|
|
686
|
-
expect(data.error).toBe('InvalidRequest')
|
|
687
|
-
expect(data.message).toBe('Method not allowed')
|
|
688
|
-
})
|
|
689
|
-
|
|
690
|
-
it('returns 400 when atproto-proxy header is set', async () => {
|
|
691
|
-
const router = new LexRouter()
|
|
692
|
-
const request = new Request('https://example.com/xrpc/_health', {
|
|
693
|
-
headers: { 'atproto-proxy': 'did:plc:example#atproto_labeler' },
|
|
694
|
-
})
|
|
695
|
-
const response = await router.fetch(request)
|
|
696
|
-
|
|
697
|
-
expect(response.status).toBe(400)
|
|
698
|
-
const data = await response.json()
|
|
699
|
-
expect(data.error).toBe('InvalidRequest')
|
|
700
|
-
expect(data.message).toContain('atproto-proxy')
|
|
701
|
-
})
|
|
702
|
-
|
|
703
|
-
it('does not call healthCheck when atproto-proxy is set', async () => {
|
|
704
|
-
const healthCheck = vi.fn<HealthCheckHandler>(async () => ({
|
|
705
|
-
status: 'ok',
|
|
706
|
-
}))
|
|
707
|
-
const router = new LexRouter({ healthCheck })
|
|
708
|
-
const request = new Request('https://example.com/xrpc/_health', {
|
|
709
|
-
headers: { 'atproto-proxy': 'did:plc:example#atproto_labeler' },
|
|
710
|
-
})
|
|
711
|
-
const response = await router.fetch(request)
|
|
712
|
-
|
|
713
|
-
expect(healthCheck).not.toHaveBeenCalled()
|
|
714
|
-
expect(response.status).toBe(400)
|
|
715
|
-
})
|
|
716
|
-
})
|
|
717
|
-
|
|
718
|
-
describe('invalid NSID', () => {
|
|
719
|
-
it('returns 400 for invalid NSID format', async () => {
|
|
720
|
-
const router = new LexRouter()
|
|
721
|
-
const request = new Request('https://example.com/xrpc/not-an-nsid!!')
|
|
722
|
-
const response = await router.fetch(request)
|
|
723
|
-
|
|
724
|
-
expect(response.status).toBe(400)
|
|
725
|
-
const data = await response.json()
|
|
726
|
-
expect(data.error).toBe('InvalidRequest')
|
|
727
|
-
expect(data.message).toContain('Invalid NSID')
|
|
728
|
-
})
|
|
729
|
-
|
|
730
|
-
it('returns 400 for empty NSID', async () => {
|
|
731
|
-
const router = new LexRouter()
|
|
732
|
-
const request = new Request('https://example.com/xrpc/')
|
|
733
|
-
const response = await router.fetch(request)
|
|
734
|
-
|
|
735
|
-
expect(response.status).toBe(400)
|
|
736
|
-
const data = await response.json()
|
|
737
|
-
expect(data.error).toBe('InvalidRequest')
|
|
738
|
-
})
|
|
739
|
-
})
|
|
740
|
-
|
|
741
|
-
describe('atproto-proxy header', () => {
|
|
742
|
-
it('bypasses local handler when atproto-proxy header is set', async () => {
|
|
743
|
-
const router = new LexRouter().add(io.example.status, handlers.status)
|
|
744
|
-
|
|
745
|
-
const request = new Request(
|
|
746
|
-
'https://example.com/xrpc/io.example.status',
|
|
747
|
-
{ headers: { 'atproto-proxy': 'did:plc:example#atproto_labeler' } },
|
|
748
|
-
)
|
|
749
|
-
const response = await router.fetch(request)
|
|
750
|
-
|
|
751
|
-
// The handler should NOT be called - currently returns MethodNotImplemented
|
|
752
|
-
// because proxy is not yet implemented
|
|
753
|
-
expect(response.status).toBe(501)
|
|
754
|
-
})
|
|
755
|
-
|
|
756
|
-
it('returns 400 for invalid atproto-proxy header format', async () => {
|
|
757
|
-
const router = new LexRouter()
|
|
758
|
-
|
|
759
|
-
const request = new Request(
|
|
760
|
-
'https://example.com/xrpc/io.example.status',
|
|
761
|
-
{ headers: { 'atproto-proxy': 'not-a-valid-proxy' } },
|
|
762
|
-
)
|
|
763
|
-
const response = await router.fetch(request)
|
|
764
|
-
|
|
765
|
-
expect(response.status).toBe(400)
|
|
766
|
-
const data = await response.json()
|
|
767
|
-
expect(data.error).toBe('InvalidRequest')
|
|
768
|
-
expect(data.message).toContain('atproto-proxy')
|
|
769
|
-
})
|
|
770
|
-
|
|
771
|
-
it('returns 400 for atproto-proxy without fragment', async () => {
|
|
772
|
-
const router = new LexRouter()
|
|
773
|
-
|
|
774
|
-
const request = new Request(
|
|
775
|
-
'https://example.com/xrpc/io.example.status',
|
|
776
|
-
{ headers: { 'atproto-proxy': 'did:plc:example' } },
|
|
777
|
-
)
|
|
778
|
-
const response = await router.fetch(request)
|
|
779
|
-
|
|
780
|
-
expect(response.status).toBe(400)
|
|
781
|
-
const data = await response.json()
|
|
782
|
-
expect(data.error).toBe('InvalidRequest')
|
|
783
|
-
})
|
|
784
|
-
|
|
785
|
-
it('returns 400 for atproto-proxy with empty fragment', async () => {
|
|
786
|
-
const router = new LexRouter()
|
|
787
|
-
|
|
788
|
-
const request = new Request(
|
|
789
|
-
'https://example.com/xrpc/io.example.status',
|
|
790
|
-
{ headers: { 'atproto-proxy': 'did:plc:example#' } },
|
|
791
|
-
)
|
|
792
|
-
const response = await router.fetch(request)
|
|
793
|
-
|
|
794
|
-
expect(response.status).toBe(400)
|
|
795
|
-
})
|
|
796
|
-
|
|
797
|
-
it('returns 400 for atproto-proxy with spaces', async () => {
|
|
798
|
-
const router = new LexRouter()
|
|
799
|
-
|
|
800
|
-
const request = new Request(
|
|
801
|
-
'https://example.com/xrpc/io.example.status',
|
|
802
|
-
{ headers: { 'atproto-proxy': 'did:plc:example #service' } },
|
|
803
|
-
)
|
|
804
|
-
const response = await router.fetch(request)
|
|
805
|
-
|
|
806
|
-
expect(response.status).toBe(400)
|
|
807
|
-
})
|
|
808
|
-
|
|
809
|
-
it('returns 400 for atproto-proxy with multiple fragments', async () => {
|
|
810
|
-
const router = new LexRouter()
|
|
811
|
-
|
|
812
|
-
const request = new Request(
|
|
813
|
-
'https://example.com/xrpc/io.example.status',
|
|
814
|
-
{ headers: { 'atproto-proxy': 'did:plc:example#svc#extra' } },
|
|
815
|
-
)
|
|
816
|
-
const response = await router.fetch(request)
|
|
817
|
-
|
|
818
|
-
expect(response.status).toBe(400)
|
|
819
|
-
})
|
|
820
|
-
|
|
821
|
-
it('returns 400 for atproto-proxy with space in fragment', async () => {
|
|
822
|
-
const router = new LexRouter()
|
|
823
|
-
|
|
824
|
-
const request = new Request(
|
|
825
|
-
'https://example.com/xrpc/io.example.status',
|
|
826
|
-
{ headers: { 'atproto-proxy': 'did:plc:example#service id' } },
|
|
827
|
-
)
|
|
828
|
-
const response = await router.fetch(request)
|
|
829
|
-
|
|
830
|
-
expect(response.status).toBe(400)
|
|
831
|
-
})
|
|
832
|
-
})
|
|
833
|
-
|
|
834
|
-
describe('NSID normalization', () => {
|
|
835
|
-
it('matches handler when URL has uppercase domain segments', async () => {
|
|
836
|
-
const router = new LexRouter().add(io.example.status, handlers.status)
|
|
837
|
-
|
|
838
|
-
const request = new Request('https://example.com/xrpc/IO.Example.status')
|
|
839
|
-
const response = await router.fetch(request)
|
|
840
|
-
|
|
841
|
-
expect(response.status).toBe(200)
|
|
842
|
-
expect(await response.json()).toEqual({ status: 'ok' })
|
|
843
|
-
})
|
|
844
|
-
|
|
845
|
-
it('matches handler when URL has mixed-case domain segments', async () => {
|
|
846
|
-
const router = new LexRouter().add(io.example.status, handlers.status)
|
|
847
|
-
|
|
848
|
-
const request = new Request('https://example.com/xrpc/IO.EXAMPLE.status')
|
|
849
|
-
const response = await router.fetch(request)
|
|
850
|
-
|
|
851
|
-
expect(response.status).toBe(200)
|
|
852
|
-
expect(await response.json()).toEqual({ status: 'ok' })
|
|
853
|
-
})
|
|
854
|
-
|
|
855
|
-
it('preserves case sensitivity of method name (last segment)', async () => {
|
|
856
|
-
const router = new LexRouter().add(io.example.status, handlers.status)
|
|
857
|
-
|
|
858
|
-
// "Status" (uppercase S) should not match "status"
|
|
859
|
-
const request = new Request('https://example.com/xrpc/io.example.Status')
|
|
860
|
-
const response = await router.fetch(request)
|
|
861
|
-
|
|
862
|
-
expect(response.status).toBe(501)
|
|
863
|
-
expect(await response.json()).toMatchObject({
|
|
864
|
-
error: 'MethodNotImplemented',
|
|
865
|
-
})
|
|
866
|
-
})
|
|
867
|
-
|
|
868
|
-
it('prevents duplicate registration with different domain casing', async () => {
|
|
869
|
-
const router = new LexRouter().add(io.example.status, handlers.status)
|
|
870
|
-
|
|
871
|
-
expect(() => {
|
|
872
|
-
// Same NSID with different domain casing should be detected as duplicate
|
|
873
|
-
const statusUpperCase = l.query(
|
|
874
|
-
'IO.Example.status' as 'io.example.status',
|
|
875
|
-
l.params(),
|
|
876
|
-
l.payload('application/json', l.object({ status: l.string() })),
|
|
877
|
-
)
|
|
878
|
-
router.add(statusUpperCase, handlers.status)
|
|
879
|
-
}).toThrow(/already registered/)
|
|
880
|
-
})
|
|
881
|
-
})
|
|
882
|
-
|
|
883
|
-
describe('error handling', () => {
|
|
884
|
-
it('onHandlerError receives LexServerError', async () => {
|
|
885
|
-
const onHandlerError = vi.fn<HandlerErrorHook>()
|
|
886
|
-
const router = new LexRouter({ onHandlerError })
|
|
887
|
-
|
|
888
|
-
router.add(io.example.status, async () => {
|
|
889
|
-
throw new Error('Unexpected error')
|
|
890
|
-
})
|
|
891
|
-
|
|
892
|
-
const request = new Request('https://example.com/xrpc/io.example.status')
|
|
893
|
-
await router.fetch(request)
|
|
894
|
-
|
|
895
|
-
expect(onHandlerError).toHaveBeenCalledTimes(1)
|
|
896
|
-
const ctx = onHandlerError.mock.calls[0][0]
|
|
897
|
-
expect(ctx.error).toBeInstanceOf(LexServerError)
|
|
898
|
-
expect(ctx.error.status).toBe(500)
|
|
899
|
-
expect(ctx.method).toBeDefined()
|
|
900
|
-
expect(ctx.request).toBe(request)
|
|
901
|
-
})
|
|
902
|
-
|
|
903
|
-
it('does not call onHandlerError for aborted requests', async () => {
|
|
904
|
-
const onHandlerError = vi.fn<HandlerErrorHook>()
|
|
905
|
-
const router = new LexRouter({ onHandlerError })
|
|
906
|
-
|
|
907
|
-
router.add(io.example.status, async (_ctx) => {
|
|
908
|
-
const reason = new Error('aborted')
|
|
909
|
-
throw new Error('handler error', { cause: reason })
|
|
910
|
-
})
|
|
911
|
-
|
|
912
|
-
const controller = new AbortController()
|
|
913
|
-
const reason = new Error('aborted')
|
|
914
|
-
controller.abort(reason)
|
|
915
|
-
|
|
916
|
-
const request = new Request(
|
|
917
|
-
'https://example.com/xrpc/io.example.status',
|
|
918
|
-
{ signal: controller.signal },
|
|
919
|
-
)
|
|
920
|
-
|
|
921
|
-
// Need to create a handler that actually throws with the abort reason
|
|
922
|
-
const router2 = new LexRouter({ onHandlerError })
|
|
923
|
-
router2.add(io.example.status, async ({ signal }) => {
|
|
924
|
-
throw new Error('handler error', { cause: signal.reason })
|
|
925
|
-
})
|
|
926
|
-
|
|
927
|
-
const response = await router2.fetch(request)
|
|
928
|
-
|
|
929
|
-
expect(response.status).toBe(499)
|
|
930
|
-
expect(onHandlerError).not.toHaveBeenCalled()
|
|
931
|
-
})
|
|
932
|
-
|
|
933
|
-
it('returns 499 for aborted requests', async () => {
|
|
934
|
-
const controller = new AbortController()
|
|
935
|
-
const reason = new Error('Client disconnected')
|
|
936
|
-
controller.abort(reason)
|
|
937
|
-
|
|
938
|
-
const router = new LexRouter()
|
|
939
|
-
router.add(io.example.status, async () => {
|
|
940
|
-
throw new Error('after abort', { cause: reason })
|
|
941
|
-
})
|
|
942
|
-
|
|
943
|
-
const request = new Request(
|
|
944
|
-
'https://example.com/xrpc/io.example.status',
|
|
945
|
-
{ signal: controller.signal },
|
|
946
|
-
)
|
|
947
|
-
const response = await router.fetch(request)
|
|
948
|
-
|
|
949
|
-
expect(response.status).toBe(499)
|
|
950
|
-
const data = await response.json()
|
|
951
|
-
expect(data.error).toBe('RequestAborted')
|
|
952
|
-
})
|
|
953
|
-
})
|
|
954
|
-
})
|
|
955
|
-
|
|
956
|
-
// ============================================================================
|
|
957
|
-
// Parameter Tests (ported from xrpc-server/tests/parameters.test.ts)
|
|
958
|
-
// ============================================================================
|
|
959
|
-
|
|
960
|
-
describe('Parameters', () => {
|
|
961
|
-
const io = {
|
|
962
|
-
example: {
|
|
963
|
-
paramTest: l.query(
|
|
964
|
-
'io.example.paramTest',
|
|
965
|
-
l.params({
|
|
966
|
-
str: l.string({ minLength: 2, maxLength: 10 }),
|
|
967
|
-
int: l.integer({ minimum: 2, maximum: 10 }),
|
|
968
|
-
bool: l.boolean(),
|
|
969
|
-
arr: l.array(l.integer(), { maxLength: 2 }),
|
|
970
|
-
def: l.optional(l.withDefault(l.integer(), 0)),
|
|
971
|
-
}),
|
|
972
|
-
l.payload(
|
|
973
|
-
'application/json',
|
|
974
|
-
l.object({
|
|
975
|
-
str: l.string(),
|
|
976
|
-
int: l.integer(),
|
|
977
|
-
bool: l.boolean(),
|
|
978
|
-
arr: l.array(l.integer()),
|
|
979
|
-
def: l.optional(l.integer()),
|
|
980
|
-
}),
|
|
981
|
-
),
|
|
982
|
-
),
|
|
983
|
-
},
|
|
984
|
-
}
|
|
985
|
-
|
|
986
|
-
const handler: LexRouterMethodHandler<typeof io.example.paramTest> = async ({
|
|
987
|
-
params,
|
|
988
|
-
}) => ({
|
|
989
|
-
body: {
|
|
990
|
-
str: params.str,
|
|
991
|
-
int: params.int,
|
|
992
|
-
bool: params.bool,
|
|
993
|
-
arr: params.arr,
|
|
994
|
-
def: params.def,
|
|
995
|
-
},
|
|
996
|
-
})
|
|
997
|
-
|
|
998
|
-
const router = new LexRouter().add(io.example.paramTest, handler)
|
|
999
|
-
|
|
1000
|
-
it('validates query params - valid input', async () => {
|
|
1001
|
-
const request = new Request(
|
|
1002
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true&arr=1&arr=2&def=5',
|
|
1003
|
-
)
|
|
1004
|
-
const response = await router.fetch(request)
|
|
1005
|
-
|
|
1006
|
-
expect(response.status).toBe(200)
|
|
1007
|
-
const data = await response.json()
|
|
1008
|
-
expect(data.str).toBe('valid')
|
|
1009
|
-
expect(data.int).toBe(5)
|
|
1010
|
-
expect(data.bool).toBe(true)
|
|
1011
|
-
expect(data.arr).toEqual([1, 2])
|
|
1012
|
-
expect(data.def).toBe(5)
|
|
1013
|
-
})
|
|
1014
|
-
|
|
1015
|
-
it('applies default values', async () => {
|
|
1016
|
-
const request = new Request(
|
|
1017
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true&arr=3&arr=4',
|
|
1018
|
-
)
|
|
1019
|
-
const response = await router.fetch(request)
|
|
1020
|
-
|
|
1021
|
-
expect(response.status).toBe(200)
|
|
1022
|
-
const data = await response.json()
|
|
1023
|
-
// def should be undefined or 0 (default) when not provided
|
|
1024
|
-
expect(data.def).toBe(0)
|
|
1025
|
-
})
|
|
1026
|
-
|
|
1027
|
-
it('coerces types from query strings', async () => {
|
|
1028
|
-
const request = new Request(
|
|
1029
|
-
'https://example.com/xrpc/io.example.paramTest?str=10&int=5&bool=true&arr=3&arr=4',
|
|
1030
|
-
)
|
|
1031
|
-
const response = await router.fetch(request)
|
|
1032
|
-
|
|
1033
|
-
expect(response.status).toBe(200)
|
|
1034
|
-
const data = await response.json()
|
|
1035
|
-
expect(data.str).toBe('10')
|
|
1036
|
-
expect(data.int).toBe(5)
|
|
1037
|
-
expect(data.bool).toBe(true)
|
|
1038
|
-
expect(data.arr).toEqual([3, 4])
|
|
1039
|
-
})
|
|
1040
|
-
|
|
1041
|
-
it('rejects string too short', async () => {
|
|
1042
|
-
const request = new Request(
|
|
1043
|
-
'https://example.com/xrpc/io.example.paramTest?str=n&int=5&bool=true&arr=1',
|
|
1044
|
-
)
|
|
1045
|
-
const response = await router.fetch(request)
|
|
1046
|
-
|
|
1047
|
-
expect(response.status).toBe(400)
|
|
1048
|
-
const data = await response.json()
|
|
1049
|
-
expect(data.message).toContain('str')
|
|
1050
|
-
})
|
|
1051
|
-
|
|
1052
|
-
it('rejects string too long', async () => {
|
|
1053
|
-
const request = new Request(
|
|
1054
|
-
'https://example.com/xrpc/io.example.paramTest?str=loooooooooooooong&int=5&bool=true&arr=1',
|
|
1055
|
-
)
|
|
1056
|
-
const response = await router.fetch(request)
|
|
1057
|
-
|
|
1058
|
-
expect(response.status).toBe(400)
|
|
1059
|
-
const data = await response.json()
|
|
1060
|
-
expect(data.message).toContain('str')
|
|
1061
|
-
})
|
|
1062
|
-
|
|
1063
|
-
it('rejects missing required parameter str', async () => {
|
|
1064
|
-
const request = new Request(
|
|
1065
|
-
'https://example.com/xrpc/io.example.paramTest?int=5&bool=true&arr=1',
|
|
1066
|
-
)
|
|
1067
|
-
const response = await router.fetch(request)
|
|
1068
|
-
|
|
1069
|
-
expect(response.status).toBe(400)
|
|
1070
|
-
const data = await response.json()
|
|
1071
|
-
expect(data.message).toContain('str')
|
|
1072
|
-
})
|
|
1073
|
-
|
|
1074
|
-
it('rejects missing required parameter int', async () => {
|
|
1075
|
-
const request = new Request(
|
|
1076
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&bool=true&arr=1',
|
|
1077
|
-
)
|
|
1078
|
-
const response = await router.fetch(request)
|
|
1079
|
-
|
|
1080
|
-
expect(response.status).toBe(400)
|
|
1081
|
-
const data = await response.json()
|
|
1082
|
-
expect(data.message).toContain('int')
|
|
1083
|
-
})
|
|
1084
|
-
|
|
1085
|
-
it('rejects missing required parameter bool', async () => {
|
|
1086
|
-
const request = new Request(
|
|
1087
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&arr=1',
|
|
1088
|
-
)
|
|
1089
|
-
const response = await router.fetch(request)
|
|
1090
|
-
|
|
1091
|
-
expect(response.status).toBe(400)
|
|
1092
|
-
const data = await response.json()
|
|
1093
|
-
expect(data.message).toContain('bool')
|
|
1094
|
-
})
|
|
1095
|
-
|
|
1096
|
-
it('rejects integer too small', async () => {
|
|
1097
|
-
const request = new Request(
|
|
1098
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=-1&bool=true&arr=1',
|
|
1099
|
-
)
|
|
1100
|
-
const response = await router.fetch(request)
|
|
1101
|
-
|
|
1102
|
-
expect(response.status).toBe(400)
|
|
1103
|
-
const data = await response.json()
|
|
1104
|
-
expect(data.message).toContain('int')
|
|
1105
|
-
})
|
|
1106
|
-
|
|
1107
|
-
it('rejects integer too large', async () => {
|
|
1108
|
-
const request = new Request(
|
|
1109
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=11&bool=true&arr=1',
|
|
1110
|
-
)
|
|
1111
|
-
const response = await router.fetch(request)
|
|
1112
|
-
|
|
1113
|
-
expect(response.status).toBe(400)
|
|
1114
|
-
const data = await response.json()
|
|
1115
|
-
expect(data.message).toContain('int')
|
|
1116
|
-
})
|
|
1117
|
-
|
|
1118
|
-
it('rejects missing required array parameter', async () => {
|
|
1119
|
-
const request = new Request(
|
|
1120
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true',
|
|
1121
|
-
)
|
|
1122
|
-
const response = await router.fetch(request)
|
|
1123
|
-
|
|
1124
|
-
expect(response.status).toBe(400)
|
|
1125
|
-
const data = await response.json()
|
|
1126
|
-
expect(data.message).toContain('arr')
|
|
1127
|
-
})
|
|
1128
|
-
|
|
1129
|
-
it('rejects array too large', async () => {
|
|
1130
|
-
const request = new Request(
|
|
1131
|
-
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true&arr=1&arr=2&arr=3',
|
|
1132
|
-
)
|
|
1133
|
-
const response = await router.fetch(request)
|
|
1134
|
-
const data = await response.json()
|
|
1135
|
-
|
|
1136
|
-
expect(response.status).toBe(400)
|
|
1137
|
-
expect(data.message).toContain('arr')
|
|
1138
|
-
})
|
|
1139
|
-
})
|
|
1140
|
-
|
|
1141
|
-
// ============================================================================
|
|
1142
|
-
// Procedure Tests (ported from xrpc-server/tests/procedures.test.ts)
|
|
1143
|
-
// ============================================================================
|
|
1144
|
-
|
|
1145
|
-
describe('Procedures', () => {
|
|
1146
|
-
const io = {
|
|
1147
|
-
example: {
|
|
1148
|
-
pingOne: l.procedure(
|
|
1149
|
-
'io.example.pingOne',
|
|
1150
|
-
l.params({
|
|
1151
|
-
message: l.string(),
|
|
1152
|
-
}),
|
|
1153
|
-
l.payload(),
|
|
1154
|
-
l.payload('text/plain'),
|
|
1155
|
-
),
|
|
1156
|
-
pingTwo: l.procedure(
|
|
1157
|
-
'io.example.pingTwo',
|
|
1158
|
-
l.params(),
|
|
1159
|
-
l.payload('text/plain'),
|
|
1160
|
-
l.payload('text/plain'),
|
|
1161
|
-
),
|
|
1162
|
-
pingThree: l.procedure(
|
|
1163
|
-
'io.example.pingThree',
|
|
1164
|
-
l.params(),
|
|
1165
|
-
l.payload('application/octet-stream'),
|
|
1166
|
-
l.payload('application/octet-stream'),
|
|
1167
|
-
),
|
|
1168
|
-
pingFour: l.procedure(
|
|
1169
|
-
'io.example.pingFour',
|
|
1170
|
-
l.params(),
|
|
1171
|
-
l.payload(
|
|
1172
|
-
'application/json',
|
|
1173
|
-
l.object({
|
|
1174
|
-
message: l.string(),
|
|
1175
|
-
}),
|
|
1176
|
-
),
|
|
1177
|
-
l.payload(
|
|
1178
|
-
'application/json',
|
|
1179
|
-
l.object({
|
|
1180
|
-
message: l.string(),
|
|
1181
|
-
}),
|
|
1182
|
-
),
|
|
1183
|
-
),
|
|
1184
|
-
},
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
const handlers = {
|
|
1188
|
-
pingOne: (async ({ params }) => ({
|
|
1189
|
-
encoding: 'text/plain',
|
|
1190
|
-
body: params.message,
|
|
1191
|
-
})) as LexRouterMethodHandler<typeof io.example.pingOne>,
|
|
1192
|
-
pingTwo: (async ({ input }) => ({
|
|
1193
|
-
encoding: 'text/plain',
|
|
1194
|
-
body: input.body.body!,
|
|
1195
|
-
})) as LexRouterMethodHandler<typeof io.example.pingTwo>,
|
|
1196
|
-
pingThree: (async ({ input }) => ({
|
|
1197
|
-
encoding: 'application/octet-stream',
|
|
1198
|
-
body: input.body.body!,
|
|
1199
|
-
})) as LexRouterMethodHandler<typeof io.example.pingThree>,
|
|
1200
|
-
pingFour: (async ({ input }) => ({
|
|
1201
|
-
body: { message: input.body.message },
|
|
1202
|
-
})) as LexRouterMethodHandler<typeof io.example.pingFour>,
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
const router = new LexRouter()
|
|
1206
|
-
.add(io.example.pingOne, handlers.pingOne)
|
|
1207
|
-
.add(io.example.pingTwo, handlers.pingTwo)
|
|
1208
|
-
.add(io.example.pingThree, handlers.pingThree)
|
|
1209
|
-
.add(io.example.pingFour, handlers.pingFour)
|
|
1210
|
-
|
|
1211
|
-
it('serves procedure with params returning text', async () => {
|
|
1212
|
-
const request = new Request(
|
|
1213
|
-
'https://example.com/xrpc/io.example.pingOne?message=hello%20world',
|
|
1214
|
-
{ method: 'POST' },
|
|
1215
|
-
)
|
|
1216
|
-
const response = await router.fetch(request)
|
|
1217
|
-
|
|
1218
|
-
expect(response.status).toBe(200)
|
|
1219
|
-
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
1220
|
-
expect(await response.text()).toBe('hello world')
|
|
1221
|
-
})
|
|
1222
|
-
|
|
1223
|
-
it('serves procedure with text input/output', async () => {
|
|
1224
|
-
const request = new Request('https://example.com/xrpc/io.example.pingTwo', {
|
|
1225
|
-
method: 'POST',
|
|
1226
|
-
headers: { 'content-type': 'text/plain' },
|
|
1227
|
-
body: 'hello world',
|
|
1228
|
-
})
|
|
1229
|
-
const response = await router.fetch(request)
|
|
1230
|
-
|
|
1231
|
-
expect(response.status).toBe(200)
|
|
1232
|
-
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
1233
|
-
expect(await response.text()).toBe('hello world')
|
|
1234
|
-
})
|
|
1235
|
-
|
|
1236
|
-
it('serves procedure with binary input/output', async () => {
|
|
1237
|
-
const request = new Request(
|
|
1238
|
-
'https://example.com/xrpc/io.example.pingThree',
|
|
1239
|
-
{
|
|
1240
|
-
method: 'POST',
|
|
1241
|
-
headers: { 'content-type': 'application/octet-stream' },
|
|
1242
|
-
body: new TextEncoder().encode('hello world'),
|
|
1243
|
-
},
|
|
1244
|
-
)
|
|
1245
|
-
const response = await router.fetch(request)
|
|
1246
|
-
|
|
1247
|
-
expect(response.status).toBe(200)
|
|
1248
|
-
expect(response.headers.get('content-type')).toBe(
|
|
1249
|
-
'application/octet-stream',
|
|
1250
|
-
)
|
|
1251
|
-
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1252
|
-
expect(new TextDecoder().decode(responseBytes)).toBe('hello world')
|
|
1253
|
-
})
|
|
1254
|
-
|
|
1255
|
-
it('serves procedure with JSON input/output', async () => {
|
|
1256
|
-
const request = new Request(
|
|
1257
|
-
'https://example.com/xrpc/io.example.pingFour',
|
|
1258
|
-
{
|
|
1259
|
-
method: 'POST',
|
|
1260
|
-
headers: { 'content-type': 'application/json' },
|
|
1261
|
-
body: JSON.stringify({ message: 'hello world' }),
|
|
1262
|
-
},
|
|
1263
|
-
)
|
|
1264
|
-
const response = await router.fetch(request)
|
|
1265
|
-
|
|
1266
|
-
expect(response.status).toBe(200)
|
|
1267
|
-
expect(response.headers.get('content-type')).toBe('application/json')
|
|
1268
|
-
const data = await response.json()
|
|
1269
|
-
expect(data.message).toBe('hello world')
|
|
1270
|
-
})
|
|
1271
|
-
})
|
|
1272
|
-
|
|
1273
|
-
// ============================================================================
|
|
1274
|
-
// Query Tests (ported from xrpc-server/tests/queries.test.ts)
|
|
1275
|
-
// ============================================================================
|
|
1276
|
-
|
|
1277
|
-
describe('Queries', () => {
|
|
1278
|
-
const io = {
|
|
1279
|
-
example: {
|
|
1280
|
-
pingOne: l.query(
|
|
1281
|
-
'io.example.pingOne',
|
|
1282
|
-
l.params({
|
|
1283
|
-
message: l.string(),
|
|
1284
|
-
}),
|
|
1285
|
-
l.payload('text/plain'),
|
|
1286
|
-
),
|
|
1287
|
-
pingTwo: l.query(
|
|
1288
|
-
'io.example.pingTwo',
|
|
1289
|
-
l.params({
|
|
1290
|
-
message: l.string(),
|
|
1291
|
-
}),
|
|
1292
|
-
l.payload('application/octet-stream'),
|
|
1293
|
-
),
|
|
1294
|
-
pingThree: l.query(
|
|
1295
|
-
'io.example.pingThree',
|
|
1296
|
-
l.params({
|
|
1297
|
-
message: l.string(),
|
|
1298
|
-
}),
|
|
1299
|
-
l.payload('application/json', l.object({ message: l.string() })),
|
|
1300
|
-
),
|
|
1301
|
-
},
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
const handlers = {
|
|
1305
|
-
pingOne: (async ({ params }) => ({
|
|
1306
|
-
encoding: 'text/plain',
|
|
1307
|
-
body: params.message,
|
|
1308
|
-
})) satisfies LexRouterMethodHandler<typeof io.example.pingOne>,
|
|
1309
|
-
pingTwo: (async ({ params }) => ({
|
|
1310
|
-
encoding: 'application/octet-stream',
|
|
1311
|
-
body: new TextEncoder().encode(params.message),
|
|
1312
|
-
})) satisfies LexRouterMethodHandler<typeof io.example.pingTwo>,
|
|
1313
|
-
pingThree: (async ({ params }) => ({
|
|
1314
|
-
body: { message: params.message },
|
|
1315
|
-
headers: { 'x-test-header-name': 'test-value' },
|
|
1316
|
-
})) satisfies LexRouterMethodHandler<typeof io.example.pingThree>,
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
const router = new LexRouter()
|
|
1320
|
-
.add(io.example.pingOne, handlers.pingOne)
|
|
1321
|
-
.add(io.example.pingTwo, handlers.pingTwo)
|
|
1322
|
-
.add(io.example.pingThree, handlers.pingThree)
|
|
1323
|
-
|
|
1324
|
-
it('serves query with text response', async () => {
|
|
1325
|
-
const request = new Request(
|
|
1326
|
-
'https://example.com/xrpc/io.example.pingOne?message=hello%20world',
|
|
1327
|
-
)
|
|
1328
|
-
const response = await router.fetch(request)
|
|
1329
|
-
|
|
1330
|
-
expect(response.status).toBe(200)
|
|
1331
|
-
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
1332
|
-
expect(await response.text()).toBe('hello world')
|
|
1333
|
-
})
|
|
1334
|
-
|
|
1335
|
-
it('serves query with binary response', async () => {
|
|
1336
|
-
const request = new Request(
|
|
1337
|
-
'https://example.com/xrpc/io.example.pingTwo?message=hello%20world',
|
|
1338
|
-
)
|
|
1339
|
-
const response = await router.fetch(request)
|
|
1340
|
-
|
|
1341
|
-
expect(response.status).toBe(200)
|
|
1342
|
-
expect(response.headers.get('content-type')).toBe(
|
|
1343
|
-
'application/octet-stream',
|
|
1344
|
-
)
|
|
1345
|
-
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
1346
|
-
expect(new TextDecoder().decode(bytes)).toBe('hello world')
|
|
1347
|
-
})
|
|
1348
|
-
|
|
1349
|
-
it('serves query with JSON response and custom headers', async () => {
|
|
1350
|
-
const request = new Request(
|
|
1351
|
-
'https://example.com/xrpc/io.example.pingThree?message=hello%20world',
|
|
1352
|
-
)
|
|
1353
|
-
const response = await router.fetch(request)
|
|
1354
|
-
|
|
1355
|
-
expect(response.status).toBe(200)
|
|
1356
|
-
expect(response.headers.get('content-type')).toBe('application/json')
|
|
1357
|
-
expect(response.headers.get('x-test-header-name')).toBe('test-value')
|
|
1358
|
-
const data = await response.json()
|
|
1359
|
-
expect(data.message).toBe('hello world')
|
|
1360
|
-
})
|
|
1361
|
-
|
|
1362
|
-
it('rejects query with content-type header', async () => {
|
|
1363
|
-
// GET requests can't have a body, but they can have content-type headers
|
|
1364
|
-
// The server should reject queries that have content-type/content-length headers
|
|
1365
|
-
const request = new Request(
|
|
1366
|
-
'https://example.com/xrpc/io.example.pingOne?message=hello',
|
|
1367
|
-
{
|
|
1368
|
-
method: 'GET',
|
|
1369
|
-
headers: { 'content-type': 'application/json' },
|
|
1370
|
-
},
|
|
1371
|
-
)
|
|
1372
|
-
const response = await router.fetch(request)
|
|
1373
|
-
|
|
1374
|
-
expect(response.status).toBe(400)
|
|
1375
|
-
const data = await response.json()
|
|
1376
|
-
expect(data.error).toBe('InvalidRequest')
|
|
1377
|
-
})
|
|
1378
|
-
})
|
|
1379
|
-
|
|
1380
|
-
// ============================================================================
|
|
1381
|
-
// Response Handling Tests (ported from xrpc-server/tests/responses.test.ts)
|
|
1382
|
-
// ============================================================================
|
|
1383
|
-
|
|
1384
|
-
describe('Responses', () => {
|
|
1385
|
-
describe('Streaming Responses', () => {
|
|
1386
|
-
const io = {
|
|
1387
|
-
example: {
|
|
1388
|
-
readableStream: l.query(
|
|
1389
|
-
'io.example.readableStream',
|
|
1390
|
-
l.params({
|
|
1391
|
-
shouldErr: l.optional(l.boolean()),
|
|
1392
|
-
}),
|
|
1393
|
-
l.payload('application/vnd.ipld.car'),
|
|
1394
|
-
),
|
|
1395
|
-
},
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
it('returns readable streams of bytes', async () => {
|
|
1399
|
-
const handler: LexRouterMethodHandler<
|
|
1400
|
-
typeof io.example.readableStream
|
|
1401
|
-
> = async () => {
|
|
1402
|
-
const stream = new ReadableStream({
|
|
1403
|
-
start(controller) {
|
|
1404
|
-
for (let i = 0; i < 5; i++) {
|
|
1405
|
-
controller.enqueue(new Uint8Array([i]))
|
|
1406
|
-
}
|
|
1407
|
-
controller.close()
|
|
1408
|
-
},
|
|
1409
|
-
})
|
|
1410
|
-
|
|
1411
|
-
return {
|
|
1412
|
-
encoding: 'application/vnd.ipld.car',
|
|
1413
|
-
body: stream,
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
const router = new LexRouter().add(io.example.readableStream, handler)
|
|
1418
|
-
|
|
1419
|
-
const request = new Request(
|
|
1420
|
-
'https://example.com/xrpc/io.example.readableStream',
|
|
1421
|
-
)
|
|
1422
|
-
const response = await router.fetch(request)
|
|
1423
|
-
|
|
1424
|
-
expect(response.status).toBe(200)
|
|
1425
|
-
expect(response.headers.get('content-type')).toBe(
|
|
1426
|
-
'application/vnd.ipld.car',
|
|
1427
|
-
)
|
|
1428
|
-
|
|
1429
|
-
const reader = response.body!.getReader()
|
|
1430
|
-
const chunks: number[] = []
|
|
1431
|
-
// eslint-disable-next-line no-constant-condition
|
|
1432
|
-
while (true) {
|
|
1433
|
-
const { done, value } = await reader.read()
|
|
1434
|
-
if (done) break
|
|
1435
|
-
chunks.push(...value)
|
|
1436
|
-
}
|
|
1437
|
-
expect(chunks).toEqual([0, 1, 2, 3, 4])
|
|
1438
|
-
})
|
|
1439
|
-
|
|
1440
|
-
it('handles errors on readable streams of bytes', async () => {
|
|
1441
|
-
const handler: LexRouterMethodHandler<
|
|
1442
|
-
typeof io.example.readableStream
|
|
1443
|
-
> = async ({ params }) => {
|
|
1444
|
-
const stream = new ReadableStream({
|
|
1445
|
-
start(controller) {
|
|
1446
|
-
for (let i = 0; i < 5; i++) {
|
|
1447
|
-
controller.enqueue(new Uint8Array([i]))
|
|
1448
|
-
}
|
|
1449
|
-
if (params.shouldErr) {
|
|
1450
|
-
controller.error(new Error('Stream error'))
|
|
1451
|
-
} else {
|
|
1452
|
-
controller.close()
|
|
1453
|
-
}
|
|
1454
|
-
},
|
|
1455
|
-
})
|
|
1456
|
-
|
|
1457
|
-
return {
|
|
1458
|
-
encoding: 'application/vnd.ipld.car',
|
|
1459
|
-
body: stream,
|
|
1460
|
-
}
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
const router = new LexRouter().add(io.example.readableStream, handler)
|
|
1464
|
-
|
|
1465
|
-
const request = new Request(
|
|
1466
|
-
'https://example.com/xrpc/io.example.readableStream?shouldErr=true',
|
|
1467
|
-
)
|
|
1468
|
-
const response = await router.fetch(request)
|
|
1469
|
-
|
|
1470
|
-
expect(response.status).toBe(200)
|
|
1471
|
-
|
|
1472
|
-
const reader = response.body!.getReader()
|
|
1473
|
-
await expect(async () => {
|
|
1474
|
-
// eslint-disable-next-line no-constant-condition
|
|
1475
|
-
while (true) {
|
|
1476
|
-
const { done } = await reader.read()
|
|
1477
|
-
if (done) break
|
|
1478
|
-
}
|
|
1479
|
-
}).rejects.toThrow('Stream error')
|
|
1480
|
-
})
|
|
1481
|
-
})
|
|
1482
|
-
|
|
1483
|
-
describe('Empty Responses', () => {
|
|
1484
|
-
const io = {
|
|
1485
|
-
example: {
|
|
1486
|
-
emptyResponse: l.query(
|
|
1487
|
-
'io.example.emptyResponse',
|
|
1488
|
-
l.params(),
|
|
1489
|
-
l.payload(),
|
|
1490
|
-
),
|
|
1491
|
-
},
|
|
1492
|
-
}
|
|
1493
|
-
|
|
1494
|
-
it('handles responses with no body', async () => {
|
|
1495
|
-
const handler: LexRouterMethodHandler<
|
|
1496
|
-
typeof io.example.emptyResponse
|
|
1497
|
-
> = async () => ({})
|
|
1498
|
-
|
|
1499
|
-
const router = new LexRouter().add(io.example.emptyResponse, handler)
|
|
1500
|
-
|
|
1501
|
-
const request = new Request(
|
|
1502
|
-
'https://example.com/xrpc/io.example.emptyResponse',
|
|
1503
|
-
)
|
|
1504
|
-
const response = await router.fetch(request)
|
|
1505
|
-
|
|
1506
|
-
expect(response.status).toBe(200)
|
|
1507
|
-
expect(response.body).toBeNull()
|
|
1508
|
-
})
|
|
1509
|
-
|
|
1510
|
-
it('handles responses with headers but no body', async () => {
|
|
1511
|
-
const handler: LexRouterMethodHandler<
|
|
1512
|
-
typeof io.example.emptyResponse
|
|
1513
|
-
> = async () => ({
|
|
1514
|
-
headers: { 'x-custom-header': 'value' },
|
|
1515
|
-
})
|
|
1516
|
-
|
|
1517
|
-
const router = new LexRouter().add(io.example.emptyResponse, handler)
|
|
1518
|
-
|
|
1519
|
-
const request = new Request(
|
|
1520
|
-
'https://example.com/xrpc/io.example.emptyResponse',
|
|
1521
|
-
)
|
|
1522
|
-
const response = await router.fetch(request)
|
|
1523
|
-
|
|
1524
|
-
expect(response.status).toBe(200)
|
|
1525
|
-
expect(response.headers.get('x-custom-header')).toBe('value')
|
|
1526
|
-
expect(response.body).toBeNull()
|
|
1527
|
-
})
|
|
1528
|
-
})
|
|
1529
|
-
|
|
1530
|
-
describe('Custom Response Objects', () => {
|
|
1531
|
-
const io = {
|
|
1532
|
-
example: {
|
|
1533
|
-
customResponse: l.query(
|
|
1534
|
-
'io.example.customResponse',
|
|
1535
|
-
l.params({
|
|
1536
|
-
status: l.integer(),
|
|
1537
|
-
}),
|
|
1538
|
-
l.payload(),
|
|
1539
|
-
),
|
|
1540
|
-
},
|
|
1541
|
-
}
|
|
1542
|
-
|
|
1543
|
-
it('allows returning custom Response objects', async () => {
|
|
1544
|
-
const handler: LexRouterMethodHandler<
|
|
1545
|
-
typeof io.example.customResponse
|
|
1546
|
-
> = async ({ params }) => {
|
|
1547
|
-
return new Response(JSON.stringify({ code: params.status }), {
|
|
1548
|
-
status: params.status,
|
|
1549
|
-
headers: { 'content-type': 'application/json' },
|
|
1550
|
-
})
|
|
1551
|
-
}
|
|
1552
|
-
|
|
1553
|
-
const router = new LexRouter().add(io.example.customResponse, handler)
|
|
1554
|
-
|
|
1555
|
-
const request = new Request(
|
|
1556
|
-
'https://example.com/xrpc/io.example.customResponse?status=201',
|
|
1557
|
-
)
|
|
1558
|
-
const response = await router.fetch(request)
|
|
1559
|
-
|
|
1560
|
-
expect(response.status).toBe(201)
|
|
1561
|
-
const data = await response.json()
|
|
1562
|
-
expect(data.code).toBe(201)
|
|
1563
|
-
})
|
|
1564
|
-
})
|
|
1565
|
-
})
|
|
1566
|
-
|
|
1567
|
-
// ============================================================================
|
|
1568
|
-
// Body Handling Tests (ported from xrpc-server/tests/bodies.test.ts)
|
|
1569
|
-
// ============================================================================
|
|
1570
|
-
|
|
1571
|
-
describe('Body Handling', () => {
|
|
1572
|
-
describe('Input Validation', () => {
|
|
1573
|
-
const io = {
|
|
1574
|
-
example: {
|
|
1575
|
-
validationTest: l.procedure(
|
|
1576
|
-
'io.example.validationTest',
|
|
1577
|
-
l.params(),
|
|
1578
|
-
l.payload(
|
|
1579
|
-
'application/json',
|
|
1580
|
-
l.object({
|
|
1581
|
-
foo: l.string(),
|
|
1582
|
-
bar: l.optional(l.integer()),
|
|
1583
|
-
}),
|
|
1584
|
-
),
|
|
1585
|
-
l.payload(
|
|
1586
|
-
'application/json',
|
|
1587
|
-
l.object({
|
|
1588
|
-
foo: l.string(),
|
|
1589
|
-
bar: l.optional(l.integer()),
|
|
1590
|
-
}),
|
|
1591
|
-
),
|
|
1592
|
-
),
|
|
1593
|
-
},
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
const handler: LexRouterMethodHandler<
|
|
1597
|
-
typeof io.example.validationTest
|
|
1598
|
-
> = async ({ input }) => ({
|
|
1599
|
-
body: input.body!,
|
|
1600
|
-
})
|
|
1601
|
-
|
|
1602
|
-
const router = new LexRouter().add(io.example.validationTest, handler)
|
|
1603
|
-
|
|
1604
|
-
it('validates input and output bodies', async () => {
|
|
1605
|
-
const request = new Request(
|
|
1606
|
-
'https://example.com/xrpc/io.example.validationTest',
|
|
1607
|
-
{
|
|
1608
|
-
method: 'POST',
|
|
1609
|
-
headers: { 'content-type': 'application/json' },
|
|
1610
|
-
body: JSON.stringify({ foo: 'hello', bar: 123 }),
|
|
1611
|
-
},
|
|
1612
|
-
)
|
|
1613
|
-
const response = await router.fetch(request)
|
|
1614
|
-
|
|
1615
|
-
expect(response.status).toBe(200)
|
|
1616
|
-
const data = await response.json()
|
|
1617
|
-
expect(data.foo).toBe('hello')
|
|
1618
|
-
expect(data.bar).toBe(123)
|
|
1619
|
-
})
|
|
1620
|
-
|
|
1621
|
-
it('rejects missing required fields', async () => {
|
|
1622
|
-
const request = new Request(
|
|
1623
|
-
'https://example.com/xrpc/io.example.validationTest',
|
|
1624
|
-
{
|
|
1625
|
-
method: 'POST',
|
|
1626
|
-
headers: { 'content-type': 'application/json' },
|
|
1627
|
-
body: JSON.stringify({}),
|
|
1628
|
-
},
|
|
1629
|
-
)
|
|
1630
|
-
const response = await router.fetch(request)
|
|
1631
|
-
|
|
1632
|
-
expect(response.status).toBe(400)
|
|
1633
|
-
const data = await response.json()
|
|
1634
|
-
expect(data.message).toContain('foo')
|
|
1635
|
-
})
|
|
1636
|
-
|
|
1637
|
-
it('rejects wrong types', async () => {
|
|
1638
|
-
const request = new Request(
|
|
1639
|
-
'https://example.com/xrpc/io.example.validationTest',
|
|
1640
|
-
{
|
|
1641
|
-
method: 'POST',
|
|
1642
|
-
headers: { 'content-type': 'application/json' },
|
|
1643
|
-
body: JSON.stringify({ foo: 123 }),
|
|
1644
|
-
},
|
|
1645
|
-
)
|
|
1646
|
-
const response = await router.fetch(request)
|
|
1647
|
-
|
|
1648
|
-
expect(response.status).toBe(400)
|
|
1649
|
-
const data = await response.json()
|
|
1650
|
-
expect(data.message).toContain('foo')
|
|
1651
|
-
})
|
|
1652
|
-
|
|
1653
|
-
it('rejects wrong content-type', async () => {
|
|
1654
|
-
const request = new Request(
|
|
1655
|
-
'https://example.com/xrpc/io.example.validationTest',
|
|
1656
|
-
{
|
|
1657
|
-
method: 'POST',
|
|
1658
|
-
headers: { 'content-type': 'image/jpeg' },
|
|
1659
|
-
body: new Uint8Array([1, 2, 3]),
|
|
1660
|
-
},
|
|
1661
|
-
)
|
|
1662
|
-
const response = await router.fetch(request)
|
|
1663
|
-
|
|
1664
|
-
expect(response.status).toBe(400)
|
|
1665
|
-
const data = await response.json()
|
|
1666
|
-
expect(data.error).toBe('InvalidRequest')
|
|
1667
|
-
})
|
|
1668
|
-
})
|
|
1669
|
-
|
|
1670
|
-
describe('Binary Data Support', () => {
|
|
1671
|
-
const io = {
|
|
1672
|
-
example: {
|
|
1673
|
-
blobTest: l.procedure(
|
|
1674
|
-
'io.example.blobTest',
|
|
1675
|
-
l.params(),
|
|
1676
|
-
l.payload('*/*'),
|
|
1677
|
-
l.payload('application/octet-stream'),
|
|
1678
|
-
),
|
|
1679
|
-
},
|
|
1680
|
-
}
|
|
1681
|
-
|
|
1682
|
-
const handler: LexRouterMethodHandler<typeof io.example.blobTest> = async ({
|
|
1683
|
-
input,
|
|
1684
|
-
}) => {
|
|
1685
|
-
return {
|
|
1686
|
-
encoding: 'application/octet-stream',
|
|
1687
|
-
body: new Uint8Array(await input.body.arrayBuffer()),
|
|
1688
|
-
}
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
const router = new LexRouter().add(io.example.blobTest, handler)
|
|
1692
|
-
|
|
1693
|
-
it('supports ArrayBuffers', async () => {
|
|
1694
|
-
const bytes = new Uint8Array([1, 2, 3, 4, 5])
|
|
1695
|
-
const request = new Request(
|
|
1696
|
-
'https://example.com/xrpc/io.example.blobTest',
|
|
1697
|
-
{
|
|
1698
|
-
method: 'POST',
|
|
1699
|
-
// @NOTE content-type will default to application/octet-stream
|
|
1700
|
-
body: bytes,
|
|
1701
|
-
},
|
|
1702
|
-
)
|
|
1703
|
-
const response = await router.fetch(request)
|
|
1704
|
-
|
|
1705
|
-
expect(response.status).toBe(200)
|
|
1706
|
-
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1707
|
-
expect(responseBytes).toEqual(bytes)
|
|
1708
|
-
expect(response.headers.get('content-type')).toBe(
|
|
1709
|
-
'application/octet-stream',
|
|
1710
|
-
)
|
|
1711
|
-
})
|
|
1712
|
-
|
|
1713
|
-
it('supports empty payload', async () => {
|
|
1714
|
-
const bytes = new Uint8Array(0)
|
|
1715
|
-
const request = new Request(
|
|
1716
|
-
'https://example.com/xrpc/io.example.blobTest',
|
|
1717
|
-
{
|
|
1718
|
-
method: 'POST',
|
|
1719
|
-
headers: { 'content-type': 'application/octet-stream' },
|
|
1720
|
-
body: bytes,
|
|
1721
|
-
},
|
|
1722
|
-
)
|
|
1723
|
-
const response = await router.fetch(request)
|
|
1724
|
-
|
|
1725
|
-
expect(response.status).toBe(200)
|
|
1726
|
-
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1727
|
-
expect(responseBytes).toEqual(bytes)
|
|
1728
|
-
})
|
|
1729
|
-
|
|
1730
|
-
it('supports ReadableStream', async () => {
|
|
1731
|
-
const message = 'hello world'
|
|
1732
|
-
const stream = new ReadableStream({
|
|
1733
|
-
start(controller) {
|
|
1734
|
-
controller.enqueue(new TextEncoder().encode(message))
|
|
1735
|
-
controller.close()
|
|
1736
|
-
},
|
|
1737
|
-
})
|
|
1738
|
-
|
|
1739
|
-
const request = new Request(
|
|
1740
|
-
'https://example.com/xrpc/io.example.blobTest',
|
|
1741
|
-
{
|
|
1742
|
-
method: 'POST',
|
|
1743
|
-
headers: { 'content-type': 'application/octet-stream' },
|
|
1744
|
-
// @ts-expect-error
|
|
1745
|
-
duplex: 'half',
|
|
1746
|
-
body: stream,
|
|
1747
|
-
},
|
|
1748
|
-
)
|
|
1749
|
-
const response = await router.fetch(request)
|
|
1750
|
-
|
|
1751
|
-
expect(response.status).toBe(200)
|
|
1752
|
-
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1753
|
-
expect(new TextDecoder().decode(responseBytes)).toBe(message)
|
|
1754
|
-
})
|
|
1755
|
-
|
|
1756
|
-
it('requires any parsable Content-Type for blob uploads', async () => {
|
|
1757
|
-
const bytes = new Uint8Array([1, 2, 3])
|
|
1758
|
-
const request = new Request(
|
|
1759
|
-
'https://example.com/xrpc/io.example.blobTest',
|
|
1760
|
-
{
|
|
1761
|
-
method: 'POST',
|
|
1762
|
-
headers: { 'content-type': 'some/thing' },
|
|
1763
|
-
body: bytes,
|
|
1764
|
-
},
|
|
1765
|
-
)
|
|
1766
|
-
const response = await router.fetch(request)
|
|
1767
|
-
|
|
1768
|
-
expect(response.status).toBe(200)
|
|
1769
|
-
})
|
|
1770
|
-
})
|
|
1771
|
-
|
|
1772
|
-
describe('Edge Cases', () => {
|
|
1773
|
-
it('errors on missing Content-Type for JSON payload', async () => {
|
|
1774
|
-
const io = {
|
|
1775
|
-
example: {
|
|
1776
|
-
emptyContentType: l.procedure(
|
|
1777
|
-
'io.example.emptyContentType',
|
|
1778
|
-
l.params(),
|
|
1779
|
-
l.payload('application/json', l.object({ data: l.string() })),
|
|
1780
|
-
l.payload('application/json', l.object({ data: l.string() })),
|
|
1781
|
-
),
|
|
1782
|
-
},
|
|
1783
|
-
}
|
|
1784
|
-
|
|
1785
|
-
const handler: LexRouterMethodHandler<
|
|
1786
|
-
typeof io.example.emptyContentType
|
|
1787
|
-
> = async ({ input }) => ({
|
|
1788
|
-
body: { data: input.body!.data },
|
|
1789
|
-
})
|
|
1790
|
-
|
|
1791
|
-
const router = new LexRouter().add(io.example.emptyContentType, handler)
|
|
1792
|
-
|
|
1793
|
-
const request = new Request(
|
|
1794
|
-
'https://example.com/xrpc/io.example.emptyContentType',
|
|
1795
|
-
{
|
|
1796
|
-
method: 'POST',
|
|
1797
|
-
body: JSON.stringify({ data: 'test' }),
|
|
1798
|
-
},
|
|
1799
|
-
)
|
|
1800
|
-
|
|
1801
|
-
const response = await router.fetch(request)
|
|
1802
|
-
|
|
1803
|
-
expect(response.status).toBe(400)
|
|
1804
|
-
const data = await response.json()
|
|
1805
|
-
expect(data.error).toBe('InvalidRequest')
|
|
1806
|
-
})
|
|
1807
|
-
|
|
1808
|
-
it('defaults to application/octet-stream for empty Content-Type', async () => {
|
|
1809
|
-
const io = {
|
|
1810
|
-
example: {
|
|
1811
|
-
emptyContentTypeBlob: l.procedure(
|
|
1812
|
-
'io.example.emptyContentTypeBlob',
|
|
1813
|
-
l.params(),
|
|
1814
|
-
l.payload('*/*'),
|
|
1815
|
-
l.payload('application/json', l.object({ encoding: l.string() })),
|
|
1816
|
-
),
|
|
1817
|
-
},
|
|
1818
|
-
}
|
|
1819
|
-
|
|
1820
|
-
const handler: LexRouterMethodHandler<
|
|
1821
|
-
typeof io.example.emptyContentTypeBlob
|
|
1822
|
-
> = async ({ input }) => ({
|
|
1823
|
-
body: { encoding: input.encoding },
|
|
1824
|
-
})
|
|
1825
|
-
|
|
1826
|
-
const router = new LexRouter().add(
|
|
1827
|
-
io.example.emptyContentTypeBlob,
|
|
1828
|
-
handler,
|
|
1829
|
-
)
|
|
1830
|
-
|
|
1831
|
-
const request = new Request(
|
|
1832
|
-
'https://example.com/xrpc/io.example.emptyContentTypeBlob',
|
|
1833
|
-
{
|
|
1834
|
-
method: 'POST',
|
|
1835
|
-
body: new Uint8Array([1, 2, 3]),
|
|
1836
|
-
},
|
|
1837
|
-
)
|
|
1838
|
-
const response = await router.fetch(request)
|
|
1839
|
-
|
|
1840
|
-
expect(response.status).toBe(200)
|
|
1841
|
-
const data = await response.json()
|
|
1842
|
-
expect(response.headers.get('content-type')).toBe('application/json')
|
|
1843
|
-
expect(data.encoding).toBe('application/octet-stream')
|
|
1844
|
-
})
|
|
1845
|
-
})
|
|
1846
|
-
})
|
|
1847
|
-
|
|
1848
|
-
describe('Subscription', () => {
|
|
1849
|
-
const io = {
|
|
1850
|
-
example: {
|
|
1851
|
-
subscribe: l.subscription(
|
|
1852
|
-
'io.example.subscribe',
|
|
1853
|
-
l.params({
|
|
1854
|
-
message: l.withDefault(l.string(), 'hello'),
|
|
1855
|
-
}),
|
|
1856
|
-
l.object({
|
|
1857
|
-
message: l.string(),
|
|
1858
|
-
count: l.integer(),
|
|
1859
|
-
}),
|
|
1860
|
-
),
|
|
1861
|
-
},
|
|
1862
|
-
}
|
|
1863
|
-
|
|
1864
|
-
it('handles subscriptions with cleanup', async () => {
|
|
1865
|
-
let sentCount = 0
|
|
1866
|
-
const maxMessages = 10
|
|
1867
|
-
|
|
1868
|
-
const { resolve, promise: finallyPromise } = timeoutDeferred(5000)
|
|
1869
|
-
|
|
1870
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1871
|
-
io.example.subscribe,
|
|
1872
|
-
async function* ({ params: { message }, signal }) {
|
|
1873
|
-
try {
|
|
1874
|
-
for (; sentCount < maxMessages; ) {
|
|
1875
|
-
await scheduler.wait(5, { signal })
|
|
1876
|
-
yield { message, count: ++sentCount }
|
|
1877
|
-
}
|
|
1878
|
-
} finally {
|
|
1879
|
-
resolve()
|
|
1880
|
-
}
|
|
1881
|
-
},
|
|
1882
|
-
)
|
|
1883
|
-
|
|
1884
|
-
await using server = await serve(router)
|
|
1885
|
-
|
|
1886
|
-
const { port } = server.address() as AddressInfo
|
|
1887
|
-
const ws = new WebSocket(
|
|
1888
|
-
`ws://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1889
|
-
)
|
|
1890
|
-
ws.binaryType = 'arraybuffer'
|
|
1891
|
-
|
|
1892
|
-
const messages: unknown[] = []
|
|
1893
|
-
ws.addEventListener('message', (event) => {
|
|
1894
|
-
try {
|
|
1895
|
-
const bytes = new Uint8Array(event.data as ArrayBuffer)
|
|
1896
|
-
const data = [...decodeAll(bytes)]
|
|
1897
|
-
messages.push(data)
|
|
1898
|
-
} catch (err) {
|
|
1899
|
-
messages.push(err)
|
|
1900
|
-
}
|
|
1901
|
-
if (messages.length >= 3) {
|
|
1902
|
-
ws.close()
|
|
1903
|
-
}
|
|
1904
|
-
})
|
|
1905
|
-
|
|
1906
|
-
// Ensures that "finally" block is indeed called
|
|
1907
|
-
await finallyPromise
|
|
1908
|
-
|
|
1909
|
-
expect(messages).toStrictEqual([
|
|
1910
|
-
[{ op: 1 }, { message: 'ping', count: 1 }],
|
|
1911
|
-
[{ op: 1 }, { message: 'ping', count: 2 }],
|
|
1912
|
-
[{ op: 1 }, { message: 'ping', count: 3 }],
|
|
1913
|
-
])
|
|
1914
|
-
|
|
1915
|
-
expect(sentCount).toBeGreaterThanOrEqual(3)
|
|
1916
|
-
expect(sentCount).toBeLessThan(maxMessages)
|
|
1917
|
-
})
|
|
1918
|
-
|
|
1919
|
-
it('returns 405 for non-GET request', async () => {
|
|
1920
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1921
|
-
io.example.subscribe,
|
|
1922
|
-
async function* () {},
|
|
1923
|
-
)
|
|
1924
|
-
|
|
1925
|
-
await using server = await serve(router)
|
|
1926
|
-
const { port } = server.address() as AddressInfo
|
|
1927
|
-
|
|
1928
|
-
const response = await fetch(
|
|
1929
|
-
`http://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1930
|
-
{ method: 'POST' },
|
|
1931
|
-
)
|
|
1932
|
-
|
|
1933
|
-
expect(response.status).toBe(405)
|
|
1934
|
-
const data = await response.json()
|
|
1935
|
-
expect(data.error).toBe('InvalidRequest')
|
|
1936
|
-
expect(data.message).toBe('Method not allowed')
|
|
1937
|
-
})
|
|
1938
|
-
|
|
1939
|
-
it('returns 426 for non-WebSocket request', async () => {
|
|
1940
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1941
|
-
io.example.subscribe,
|
|
1942
|
-
async function* () {},
|
|
1943
|
-
)
|
|
1944
|
-
|
|
1945
|
-
await using server = await serve(router)
|
|
1946
|
-
const { port } = server.address() as AddressInfo
|
|
1947
|
-
|
|
1948
|
-
const response = await fetch(
|
|
1949
|
-
`http://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1950
|
-
{ method: 'GET' },
|
|
1951
|
-
)
|
|
1952
|
-
|
|
1953
|
-
expect(response.status).toBe(426)
|
|
1954
|
-
expect(response.headers.get('upgrade')).toBe('websocket')
|
|
1955
|
-
expect(response.headers.get('connection')).toBe('Upgrade')
|
|
1956
|
-
const data = await response.json()
|
|
1957
|
-
expect(data.error).toBe('InvalidRequest')
|
|
1958
|
-
expect(data.message).toBe(
|
|
1959
|
-
'XRPC subscriptions are only available over WebSocket',
|
|
1960
|
-
)
|
|
1961
|
-
})
|
|
1962
|
-
|
|
1963
|
-
it('closes with 1003 when client sends a message to the subscription', async () => {
|
|
1964
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1965
|
-
io.example.subscribe,
|
|
1966
|
-
async function* ({ signal }) {
|
|
1967
|
-
while (true) {
|
|
1968
|
-
await scheduler.wait(50, { signal })
|
|
1969
|
-
yield { message: 'ping', count: 1 }
|
|
1970
|
-
}
|
|
1971
|
-
},
|
|
1972
|
-
)
|
|
1973
|
-
|
|
1974
|
-
await using server = await serve(router)
|
|
1975
|
-
const { port } = server.address() as AddressInfo
|
|
1976
|
-
|
|
1977
|
-
const { resolve, reject, promise } = timeoutDeferred<{ code: number }>(5000)
|
|
1978
|
-
|
|
1979
|
-
const ws = new WebSocket(
|
|
1980
|
-
`ws://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1981
|
-
)
|
|
1982
|
-
ws.addEventListener('open', () => {
|
|
1983
|
-
ws.send('unexpected message from client')
|
|
1984
|
-
})
|
|
1985
|
-
ws.addEventListener('error', reject)
|
|
1986
|
-
ws.addEventListener('close', resolve)
|
|
1987
|
-
|
|
1988
|
-
const { code } = await promise
|
|
1989
|
-
|
|
1990
|
-
expect(code).toBe(1003)
|
|
1991
|
-
})
|
|
1992
|
-
|
|
1993
|
-
describe('error close codes', () => {
|
|
1994
|
-
const subscribeWithErrors = l.subscription(
|
|
1995
|
-
'io.example.subscribeWithErrors',
|
|
1996
|
-
l.params(),
|
|
1997
|
-
l.object({ message: l.string() }),
|
|
1998
|
-
['FutureCursor', 'ConsumerTooSlow'],
|
|
1999
|
-
)
|
|
2000
|
-
|
|
2001
|
-
it('closes with 1008 and sends error frame for known LexError', async () => {
|
|
2002
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
2003
|
-
subscribeWithErrors,
|
|
2004
|
-
async function* () {
|
|
2005
|
-
yield await Promise.reject(
|
|
2006
|
-
new LexError('FutureCursor', 'Too far in the future'),
|
|
2007
|
-
)
|
|
2008
|
-
},
|
|
2009
|
-
)
|
|
2010
|
-
|
|
2011
|
-
await using server = await serve(router)
|
|
2012
|
-
const { port } = server.address() as AddressInfo
|
|
2013
|
-
|
|
2014
|
-
const { resolve, reject, promise } = timeoutDeferred<{ code: number }>(
|
|
2015
|
-
5000,
|
|
2016
|
-
)
|
|
2017
|
-
const receivedFrames: unknown[][] = []
|
|
2018
|
-
|
|
2019
|
-
const ws = new WebSocket(
|
|
2020
|
-
`ws://localhost:${port}/xrpc/io.example.subscribeWithErrors`,
|
|
2021
|
-
)
|
|
2022
|
-
ws.binaryType = 'arraybuffer'
|
|
2023
|
-
ws.addEventListener('message', (event) => {
|
|
2024
|
-
const bytes = new Uint8Array(event.data as ArrayBuffer)
|
|
2025
|
-
receivedFrames.push([...decodeAll(bytes)])
|
|
2026
|
-
})
|
|
2027
|
-
ws.addEventListener('close', resolve)
|
|
2028
|
-
ws.addEventListener('error', reject)
|
|
2029
|
-
|
|
2030
|
-
const { code } = await promise
|
|
2031
|
-
|
|
2032
|
-
expect(code).toBe(1008)
|
|
2033
|
-
expect(receivedFrames).toHaveLength(1)
|
|
2034
|
-
const [header, body] = receivedFrames[0]
|
|
2035
|
-
expect(header).toEqual({ op: -1 })
|
|
2036
|
-
expect(body).toMatchObject({ error: 'FutureCursor' })
|
|
2037
|
-
})
|
|
2038
|
-
|
|
2039
|
-
it('closes with 1011 and sends InternalServerError frame for unknown error', async () => {
|
|
2040
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
2041
|
-
subscribeWithErrors,
|
|
2042
|
-
async function* () {
|
|
2043
|
-
yield await Promise.reject(new Error('unexpected failure'))
|
|
2044
|
-
},
|
|
2045
|
-
)
|
|
2046
|
-
|
|
2047
|
-
await using server = await serve(router)
|
|
2048
|
-
const { port } = server.address() as AddressInfo
|
|
2049
|
-
|
|
2050
|
-
const { resolve, reject, promise } = timeoutDeferred<{ code: number }>(
|
|
2051
|
-
5000,
|
|
2052
|
-
)
|
|
2053
|
-
const receivedFrames: unknown[][] = []
|
|
2054
|
-
|
|
2055
|
-
const ws = new WebSocket(
|
|
2056
|
-
`ws://localhost:${port}/xrpc/io.example.subscribeWithErrors`,
|
|
2057
|
-
)
|
|
2058
|
-
ws.binaryType = 'arraybuffer'
|
|
2059
|
-
ws.addEventListener('message', (event) => {
|
|
2060
|
-
const bytes = new Uint8Array(event.data as ArrayBuffer)
|
|
2061
|
-
receivedFrames.push([...decodeAll(bytes)])
|
|
2062
|
-
})
|
|
2063
|
-
ws.addEventListener('close', resolve)
|
|
2064
|
-
ws.addEventListener('error', reject)
|
|
2065
|
-
|
|
2066
|
-
const { code } = await promise
|
|
2067
|
-
|
|
2068
|
-
expect(code).toBe(1011)
|
|
2069
|
-
expect(receivedFrames).toHaveLength(1)
|
|
2070
|
-
const [header, body] = receivedFrames[0]
|
|
2071
|
-
expect(header).toEqual({ op: -1 })
|
|
2072
|
-
expect(body).toMatchObject({ error: 'InternalServerError' })
|
|
2073
|
-
})
|
|
2074
|
-
|
|
2075
|
-
it('closes with 1011 for a LexError not listed in method.errors', async () => {
|
|
2076
|
-
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
2077
|
-
subscribeWithErrors,
|
|
2078
|
-
async function* () {
|
|
2079
|
-
yield await Promise.reject(
|
|
2080
|
-
new LexError('SomeOtherError', 'Not a declared error'),
|
|
2081
|
-
)
|
|
2082
|
-
},
|
|
2083
|
-
)
|
|
2084
|
-
|
|
2085
|
-
await using server = await serve(router)
|
|
2086
|
-
const { port } = server.address() as AddressInfo
|
|
2087
|
-
|
|
2088
|
-
const { resolve, reject, promise } = timeoutDeferred<{ code: number }>(
|
|
2089
|
-
5000,
|
|
2090
|
-
)
|
|
2091
|
-
|
|
2092
|
-
const ws = new WebSocket(
|
|
2093
|
-
`ws://localhost:${port}/xrpc/io.example.subscribeWithErrors`,
|
|
2094
|
-
)
|
|
2095
|
-
ws.addEventListener('close', resolve)
|
|
2096
|
-
ws.addEventListener('error', reject)
|
|
2097
|
-
|
|
2098
|
-
const { code } = await promise
|
|
2099
|
-
|
|
2100
|
-
expect(code).toBe(1011)
|
|
2101
|
-
})
|
|
2102
|
-
})
|
|
2103
|
-
|
|
2104
|
-
describe('onSocketError hook', () => {
|
|
2105
|
-
it('calls onSocketError when the generator throws a non-abort error', async () => {
|
|
2106
|
-
const onSocketError = vi.fn<SocketErrorHook>()
|
|
2107
|
-
const router = new LexRouter({ upgradeWebSocket, onSocketError }).add(
|
|
2108
|
-
io.example.subscribe,
|
|
2109
|
-
async function* () {
|
|
2110
|
-
yield await Promise.reject(new Error('generator failure'))
|
|
2111
|
-
},
|
|
2112
|
-
)
|
|
2113
|
-
|
|
2114
|
-
await using server = await serve(router)
|
|
2115
|
-
const { port } = server.address() as AddressInfo
|
|
2116
|
-
|
|
2117
|
-
const { resolve, reject, promise } = timeoutDeferred<{ code: number }>(
|
|
2118
|
-
5000,
|
|
2119
|
-
)
|
|
2120
|
-
const ws = new WebSocket(
|
|
2121
|
-
`ws://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
2122
|
-
)
|
|
2123
|
-
ws.addEventListener('close', resolve)
|
|
2124
|
-
ws.addEventListener('error', reject)
|
|
2125
|
-
|
|
2126
|
-
await promise
|
|
2127
|
-
|
|
2128
|
-
expect(onSocketError).toHaveBeenCalledTimes(1)
|
|
2129
|
-
const ctx = onSocketError.mock.calls[0][0]
|
|
2130
|
-
expect(ctx.error).toBeInstanceOf(Error)
|
|
2131
|
-
expect(ctx.method).toBeDefined()
|
|
2132
|
-
expect(ctx.request).toBeDefined()
|
|
2133
|
-
})
|
|
2134
|
-
|
|
2135
|
-
it('does not call onSocketError when the error matches the abort reason', async () => {
|
|
2136
|
-
const onSocketError = vi.fn<SocketErrorHook>()
|
|
2137
|
-
const router = new LexRouter({ upgradeWebSocket, onSocketError }).add(
|
|
2138
|
-
io.example.subscribe,
|
|
2139
|
-
async function* ({ signal }) {
|
|
2140
|
-
// Wait for abort, then throw with the abort reason as cause
|
|
2141
|
-
await new Promise<void>((_, reject) => {
|
|
2142
|
-
signal.addEventListener('abort', () => {
|
|
2143
|
-
reject(new Error('aborted', { cause: signal.reason }))
|
|
2144
|
-
})
|
|
2145
|
-
})
|
|
2146
|
-
yield { message: 'never', count: 0 }
|
|
2147
|
-
},
|
|
2148
|
-
)
|
|
2149
|
-
|
|
2150
|
-
await using server = await serve(router)
|
|
2151
|
-
const { port } = server.address() as AddressInfo
|
|
2152
|
-
|
|
2153
|
-
const { resolve, reject, promise } = timeoutDeferred<{ code: number }>(
|
|
2154
|
-
5000,
|
|
2155
|
-
)
|
|
2156
|
-
const ws = new WebSocket(
|
|
2157
|
-
`ws://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
2158
|
-
)
|
|
2159
|
-
// Close from the client side to trigger the abort
|
|
2160
|
-
ws.addEventListener('open', () => ws.close())
|
|
2161
|
-
ws.addEventListener('close', resolve)
|
|
2162
|
-
ws.addEventListener('error', reject)
|
|
2163
|
-
|
|
2164
|
-
await promise
|
|
2165
|
-
|
|
2166
|
-
expect(onSocketError).not.toHaveBeenCalled()
|
|
2167
|
-
})
|
|
2168
|
-
})
|
|
2169
|
-
})
|
|
2170
|
-
|
|
2171
|
-
function defer<T = void>() {
|
|
2172
|
-
let res: (value: T | PromiseLike<T>) => void
|
|
2173
|
-
let rej: (err: unknown) => void
|
|
2174
|
-
const promise = new Promise<T>((resolve, reject) => {
|
|
2175
|
-
res = resolve
|
|
2176
|
-
rej = reject
|
|
2177
|
-
})
|
|
2178
|
-
return { resolve: res!, reject: rej!, promise }
|
|
2179
|
-
}
|
|
2180
|
-
|
|
2181
|
-
function timeoutDeferred<T = void>(ms: number) {
|
|
2182
|
-
const { resolve, reject, promise } = defer<T>()
|
|
2183
|
-
const to = setTimeout(() => reject(new Error('Timed out')), ms).unref()
|
|
2184
|
-
return {
|
|
2185
|
-
resolve,
|
|
2186
|
-
reject,
|
|
2187
|
-
promise: promise.finally(() => clearTimeout(to)),
|
|
2188
|
-
}
|
|
2189
|
-
}
|