@atproto/lex-server 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE.txt +7 -0
- package/README.md +598 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +39 -0
- package/dist/errors.js.map +1 -0
- package/dist/example.d.ts +2 -0
- package/dist/example.d.ts.map +1 -0
- package/dist/example.js +36 -0
- package/dist/example.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lex-auth-error.d.ts +15 -0
- package/dist/lex-auth-error.d.ts.map +1 -0
- package/dist/lex-auth-error.js +52 -0
- package/dist/lex-auth-error.js.map +1 -0
- package/dist/lex-server.d.ts +80 -0
- package/dist/lex-server.d.ts.map +1 -0
- package/dist/lex-server.js +285 -0
- package/dist/lex-server.js.map +1 -0
- package/dist/lib/drain-websocket.d.ts +6 -0
- package/dist/lib/drain-websocket.d.ts.map +1 -0
- package/dist/lib/drain-websocket.js +16 -0
- package/dist/lib/drain-websocket.js.map +1 -0
- package/dist/lib/sleep.d.ts +2 -0
- package/dist/lib/sleep.d.ts.map +1 -0
- package/dist/lib/sleep.js +22 -0
- package/dist/lib/sleep.js.map +1 -0
- package/dist/lib/www-authenticate.d.ts +7 -0
- package/dist/lib/www-authenticate.d.ts.map +1 -0
- package/dist/lib/www-authenticate.js +22 -0
- package/dist/lib/www-authenticate.js.map +1 -0
- package/dist/nodejs.d.ts +35 -0
- package/dist/nodejs.d.ts.map +1 -0
- package/dist/nodejs.js +236 -0
- package/dist/nodejs.js.map +1 -0
- package/dist/subscripotion.d.ts +2 -0
- package/dist/subscripotion.d.ts.map +1 -0
- package/dist/subscripotion.js +36 -0
- package/dist/subscripotion.js.map +1 -0
- package/dist/test.d.mts +2 -0
- package/dist/test.d.mts.map +1 -0
- package/dist/test.mjs +52 -0
- package/dist/test.mjs.map +1 -0
- package/nodejs.js +5 -0
- package/package.json +64 -0
- package/src/errors.ts +54 -0
- package/src/index.ts +8 -0
- package/src/lex-server.test.ts +1621 -0
- package/src/lex-server.ts +551 -0
- package/src/lib/drain-websocket.ts +23 -0
- package/src/lib/sleep.ts +25 -0
- package/src/lib/www-authenticate.ts +26 -0
- package/src/nodejs.test.ts +107 -0
- package/src/nodejs.ts +367 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.json +8 -0
- package/tsconfig.tests.json +9 -0
|
@@ -0,0 +1,1621 @@
|
|
|
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 { LexError, parseCid } from '@atproto/lex-data'
|
|
8
|
+
import { l } from '@atproto/lex-schema'
|
|
9
|
+
import {
|
|
10
|
+
LexRouter,
|
|
11
|
+
LexRouterAuth,
|
|
12
|
+
LexRouterMethodHandler,
|
|
13
|
+
} from './lex-server.js'
|
|
14
|
+
import { serve, upgradeWebSocket } from './nodejs.js'
|
|
15
|
+
|
|
16
|
+
// ============================================================================
|
|
17
|
+
// Schema Definitions
|
|
18
|
+
// ============================================================================
|
|
19
|
+
|
|
20
|
+
const io = {
|
|
21
|
+
example: {
|
|
22
|
+
echo: l.procedure(
|
|
23
|
+
'io.example.echo',
|
|
24
|
+
l.params(),
|
|
25
|
+
l.payload('*/*'),
|
|
26
|
+
l.payload('*/*'),
|
|
27
|
+
),
|
|
28
|
+
status: l.query(
|
|
29
|
+
'io.example.status',
|
|
30
|
+
l.params(),
|
|
31
|
+
l.payload('application/json', l.object({ status: l.string() })),
|
|
32
|
+
),
|
|
33
|
+
ipld: l.procedure(
|
|
34
|
+
'io.example.ipld',
|
|
35
|
+
l.params(),
|
|
36
|
+
l.payload(
|
|
37
|
+
'application/json',
|
|
38
|
+
l.object({
|
|
39
|
+
cid: l.cidLink(),
|
|
40
|
+
bytes: l.bytes(),
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
l.payload(
|
|
44
|
+
'application/json',
|
|
45
|
+
l.object({
|
|
46
|
+
cid: l.cidLink(),
|
|
47
|
+
bytes: l.bytes(),
|
|
48
|
+
}),
|
|
49
|
+
),
|
|
50
|
+
),
|
|
51
|
+
paramsToBody: l.query(
|
|
52
|
+
'io.example.paramsToBody',
|
|
53
|
+
l.params({
|
|
54
|
+
name: l.string(),
|
|
55
|
+
pronouns: l.array(l.string()),
|
|
56
|
+
}),
|
|
57
|
+
l.payload(
|
|
58
|
+
'application/json',
|
|
59
|
+
l.object({
|
|
60
|
+
params: l.object({
|
|
61
|
+
name: l.string(),
|
|
62
|
+
pronouns: l.array(l.string()),
|
|
63
|
+
}),
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const handlers: {
|
|
71
|
+
[K in keyof typeof io.example]: LexRouterMethodHandler<(typeof io.example)[K]>
|
|
72
|
+
} = {
|
|
73
|
+
echo: async ({ input }) => ({
|
|
74
|
+
encoding: input.encoding,
|
|
75
|
+
body: input.body.body!,
|
|
76
|
+
}),
|
|
77
|
+
status: async () => ({ body: { status: 'ok' } }),
|
|
78
|
+
ipld: async ({ input }) => ({ body: input.body! }),
|
|
79
|
+
paramsToBody: async ({ params }) => ({ body: { params } }),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ============================================================================
|
|
83
|
+
// Basic LexRouter Tests
|
|
84
|
+
// ============================================================================
|
|
85
|
+
|
|
86
|
+
describe('LexRouter', () => {
|
|
87
|
+
it('returns MethodNotImplemented when the route is not found', async () => {
|
|
88
|
+
const router = new LexRouter()
|
|
89
|
+
const request = new Request(`https://example.com/xrpc/foo.bar.baz`)
|
|
90
|
+
const response = await router.handle(request)
|
|
91
|
+
expect(response.status).toBe(501)
|
|
92
|
+
expect(await response.json()).toMatchObject({
|
|
93
|
+
error: 'MethodNotImplemented',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('streams payloads', async () => {
|
|
98
|
+
const router = new LexRouter().add(io.example.echo, handlers.echo)
|
|
99
|
+
const request = new Request('https://example.com/xrpc/io.example.echo', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'content-type': 'text/plain' },
|
|
102
|
+
// @ts-expect-error
|
|
103
|
+
duplex: 'half',
|
|
104
|
+
body: new ReadableStream({
|
|
105
|
+
start(controller) {
|
|
106
|
+
setTimeout(() => {
|
|
107
|
+
controller.enqueue(new TextEncoder().encode('aaa'))
|
|
108
|
+
setTimeout(() => {
|
|
109
|
+
controller.enqueue(new TextEncoder().encode('bbb'))
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
controller.error(new Error('Stream closed'))
|
|
112
|
+
}, 50)
|
|
113
|
+
}, 50)
|
|
114
|
+
}, 50)
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
})
|
|
118
|
+
const response = await router.handle(request)
|
|
119
|
+
|
|
120
|
+
const reader = response.body!.getReader()
|
|
121
|
+
const chunks: string[] = []
|
|
122
|
+
try {
|
|
123
|
+
// eslint-disable-next-line no-constant-condition
|
|
124
|
+
while (true) {
|
|
125
|
+
const { done, value } = await reader.read()
|
|
126
|
+
if (done) break
|
|
127
|
+
chunks.push(new TextDecoder().decode(value))
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
expect((err as Error).message).toBe('Stream closed')
|
|
131
|
+
}
|
|
132
|
+
expect(chunks).toEqual(['aaa', 'bbb'])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('maps params to body', async () => {
|
|
136
|
+
const router = new LexRouter().add(
|
|
137
|
+
io.example.paramsToBody,
|
|
138
|
+
handlers.paramsToBody,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const request = new Request(
|
|
142
|
+
'https://example.com/xrpc/io.example.paramsToBody?name=Alice&pronouns=she%2Fher&pronouns=they%2Fthem',
|
|
143
|
+
)
|
|
144
|
+
const response = await router.handle(request)
|
|
145
|
+
|
|
146
|
+
expect(response.status).toBe(200)
|
|
147
|
+
expect(await response.json()).toEqual({
|
|
148
|
+
params: {
|
|
149
|
+
name: 'Alice',
|
|
150
|
+
pronouns: ['she/her', 'they/them'],
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('lex-client integration', () => {
|
|
157
|
+
const router = new LexRouter()
|
|
158
|
+
.add(io.example.echo, handlers.echo)
|
|
159
|
+
.add(io.example.status, handlers.status)
|
|
160
|
+
|
|
161
|
+
it('echoes text', async () => {
|
|
162
|
+
const agent = buildAgent({
|
|
163
|
+
fetch: async (input, init) => {
|
|
164
|
+
const request = new Request(input, init)
|
|
165
|
+
return router.handle(request)
|
|
166
|
+
},
|
|
167
|
+
service: 'https://example.com',
|
|
168
|
+
})
|
|
169
|
+
const message = 'Hello, LexRouter!'
|
|
170
|
+
const response = await xrpc(agent, io.example.echo, {
|
|
171
|
+
body: message,
|
|
172
|
+
encoding: 'text/plain',
|
|
173
|
+
})
|
|
174
|
+
const responseText = new TextDecoder().decode(response.body)
|
|
175
|
+
expect(responseText).toBe(message)
|
|
176
|
+
expect(response.encoding).toBe('text/plain')
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('streams text', async () => {
|
|
180
|
+
const agent = buildAgent({
|
|
181
|
+
fetch: async (input, init) => {
|
|
182
|
+
const request = new Request(input, init)
|
|
183
|
+
return router.handle(request)
|
|
184
|
+
},
|
|
185
|
+
service: 'https://example.com',
|
|
186
|
+
})
|
|
187
|
+
const message = 'Hello, LexRouter Stream!'
|
|
188
|
+
const response = await xrpc(agent, io.example.echo, {
|
|
189
|
+
body: new ReadableStream({
|
|
190
|
+
start(controller) {
|
|
191
|
+
controller.enqueue(new TextEncoder().encode(message))
|
|
192
|
+
controller.close()
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
encoding: 'text/plain',
|
|
196
|
+
})
|
|
197
|
+
const responseText = new TextDecoder().decode(response.body)
|
|
198
|
+
expect(responseText).toBe(message)
|
|
199
|
+
expect(response.encoding).toBe('text/plain')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('performs simple query', async () => {
|
|
203
|
+
const agent = buildAgent({
|
|
204
|
+
fetch: async (input, init) => {
|
|
205
|
+
const request = new Request(input, init)
|
|
206
|
+
return router.handle(request)
|
|
207
|
+
},
|
|
208
|
+
service: 'https://example.com',
|
|
209
|
+
})
|
|
210
|
+
const response = await xrpc(agent, io.example.status)
|
|
211
|
+
expect(response.success).toBe(true)
|
|
212
|
+
expect(response.status).toBe(200)
|
|
213
|
+
expect(response.encoding).toBe('application/json')
|
|
214
|
+
expect(response.body.status).toBe('ok')
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
describe('IPLD values', () => {
|
|
219
|
+
it('can send and receive ipld vals', async () => {
|
|
220
|
+
const ipldHandler: LexRouterMethodHandler<typeof io.example.ipld> = vi.fn(
|
|
221
|
+
async ({ input }) => {
|
|
222
|
+
return { body: input.body! }
|
|
223
|
+
},
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
const router = new LexRouter().add(io.example.ipld, ipldHandler)
|
|
227
|
+
|
|
228
|
+
const agent = buildAgent({
|
|
229
|
+
fetch: async (input, init) => {
|
|
230
|
+
const request = new Request(input, init)
|
|
231
|
+
return router.handle(request)
|
|
232
|
+
},
|
|
233
|
+
service: 'https://example.com',
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
const cid = parseCid(
|
|
237
|
+
'bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a',
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
const bytes = new Uint8Array([0, 1, 2, 3])
|
|
241
|
+
|
|
242
|
+
const response = await xrpc(agent, io.example.ipld, {
|
|
243
|
+
body: { cid, bytes },
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
expect(ipldHandler).toHaveBeenCalledTimes(1)
|
|
247
|
+
expect(response.success).toBe(true)
|
|
248
|
+
expect(response.encoding).toBe('application/json')
|
|
249
|
+
expect(response.body.cid.equals(cid)).toBe(true)
|
|
250
|
+
expect(response.body.bytes).toEqual(bytes)
|
|
251
|
+
})
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
// ============================================================================
|
|
255
|
+
// Authentication Tests (ported from xrpc-server/tests/auth.test.ts)
|
|
256
|
+
// ============================================================================
|
|
257
|
+
|
|
258
|
+
describe('Authentication', () => {
|
|
259
|
+
// Basic auth schema
|
|
260
|
+
const io = {
|
|
261
|
+
example: {
|
|
262
|
+
authTest: l.procedure(
|
|
263
|
+
'io.example.authTest',
|
|
264
|
+
l.params(),
|
|
265
|
+
l.payload(
|
|
266
|
+
'application/json',
|
|
267
|
+
l.object({
|
|
268
|
+
present: l.literal(true),
|
|
269
|
+
}),
|
|
270
|
+
),
|
|
271
|
+
l.payload(
|
|
272
|
+
'application/json',
|
|
273
|
+
l.object({
|
|
274
|
+
username: l.string(),
|
|
275
|
+
original: l.string(),
|
|
276
|
+
}),
|
|
277
|
+
),
|
|
278
|
+
),
|
|
279
|
+
},
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
type BasicAuthCredentials = {
|
|
283
|
+
username: string
|
|
284
|
+
original: string
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function createBasicAuth(allowed: {
|
|
288
|
+
username: string
|
|
289
|
+
password: string
|
|
290
|
+
}): LexRouterAuth<typeof io.example.authTest, BasicAuthCredentials> {
|
|
291
|
+
return async ({ request }) => {
|
|
292
|
+
const header = request.headers.get('authorization') ?? ''
|
|
293
|
+
if (!header.startsWith('Basic ')) {
|
|
294
|
+
throw new LexError('AuthenticationRequired', 'Authentication required')
|
|
295
|
+
}
|
|
296
|
+
const original = header.slice(6)
|
|
297
|
+
const [username, password] = Buffer.from(original, 'base64')
|
|
298
|
+
.toString()
|
|
299
|
+
.split(':')
|
|
300
|
+
if (username !== allowed.username || password !== allowed.password) {
|
|
301
|
+
throw new LexError('AuthenticationRequired', 'Invalid credentials')
|
|
302
|
+
}
|
|
303
|
+
return { username, original }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function basicAuth(creds: { username: string; password: string }) {
|
|
308
|
+
return `Basic ${Buffer.from(`${creds.username}:${creds.password}`).toString('base64')}`
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const authTestHandler: LexRouterMethodHandler<
|
|
312
|
+
typeof io.example.authTest,
|
|
313
|
+
BasicAuthCredentials
|
|
314
|
+
> = async ({ credentials }) => ({
|
|
315
|
+
body: {
|
|
316
|
+
username: credentials.username,
|
|
317
|
+
original: credentials.original,
|
|
318
|
+
},
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('fails on bad auth before invalid request payload', async () => {
|
|
322
|
+
const router = new LexRouter().add(io.example.authTest, {
|
|
323
|
+
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
324
|
+
handler: authTestHandler,
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const request = new Request(
|
|
328
|
+
'https://example.com/xrpc/io.example.authTest',
|
|
329
|
+
{
|
|
330
|
+
method: 'POST',
|
|
331
|
+
headers: {
|
|
332
|
+
'content-type': 'application/json',
|
|
333
|
+
authorization: basicAuth({ username: 'admin', password: 'wrong' }),
|
|
334
|
+
},
|
|
335
|
+
body: JSON.stringify({ present: false }),
|
|
336
|
+
},
|
|
337
|
+
)
|
|
338
|
+
const response = await router.handle(request)
|
|
339
|
+
|
|
340
|
+
expect(response.status).toBe(400)
|
|
341
|
+
const data = await response.json()
|
|
342
|
+
expect(data.error).toBe('AuthenticationRequired')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('fails on invalid request payload after good auth', async () => {
|
|
346
|
+
const router = new LexRouter().add(io.example.authTest, {
|
|
347
|
+
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
348
|
+
handler: authTestHandler,
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
const request = new Request(
|
|
352
|
+
'https://example.com/xrpc/io.example.authTest',
|
|
353
|
+
{
|
|
354
|
+
method: 'POST',
|
|
355
|
+
headers: {
|
|
356
|
+
'content-type': 'application/json',
|
|
357
|
+
authorization: basicAuth({ username: 'admin', password: 'password' }),
|
|
358
|
+
},
|
|
359
|
+
body: JSON.stringify({ present: false }),
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
const response = await router.handle(request)
|
|
363
|
+
|
|
364
|
+
expect(response.status).toBe(400)
|
|
365
|
+
const data = await response.json()
|
|
366
|
+
expect(data.error).toBe('InvalidRequest')
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
it('succeeds on good auth and payload', async () => {
|
|
370
|
+
const router = new LexRouter().add(io.example.authTest, {
|
|
371
|
+
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
372
|
+
handler: authTestHandler,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
const request = new Request(
|
|
376
|
+
'https://example.com/xrpc/io.example.authTest',
|
|
377
|
+
{
|
|
378
|
+
method: 'POST',
|
|
379
|
+
headers: {
|
|
380
|
+
'content-type': 'application/json',
|
|
381
|
+
authorization: basicAuth({ username: 'admin', password: 'password' }),
|
|
382
|
+
},
|
|
383
|
+
body: JSON.stringify({ present: true }),
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
const response = await router.handle(request)
|
|
387
|
+
|
|
388
|
+
expect(response.status).toBe(200)
|
|
389
|
+
const data = await response.json()
|
|
390
|
+
expect(data.username).toBe('admin')
|
|
391
|
+
expect(data.original).toBe('YWRtaW46cGFzc3dvcmQ=')
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
it('handles missing auth header', async () => {
|
|
395
|
+
const router = new LexRouter().add(io.example.authTest, {
|
|
396
|
+
auth: createBasicAuth({ username: 'admin', password: 'password' }),
|
|
397
|
+
handler: authTestHandler,
|
|
398
|
+
})
|
|
399
|
+
|
|
400
|
+
const request = new Request(
|
|
401
|
+
'https://example.com/xrpc/io.example.authTest',
|
|
402
|
+
{
|
|
403
|
+
method: 'POST',
|
|
404
|
+
headers: { 'content-type': 'application/json' },
|
|
405
|
+
body: JSON.stringify({ present: true }),
|
|
406
|
+
},
|
|
407
|
+
)
|
|
408
|
+
const response = await router.handle(request)
|
|
409
|
+
|
|
410
|
+
expect(response.status).toBe(400)
|
|
411
|
+
const data = await response.json()
|
|
412
|
+
expect(data.error).toBe('AuthenticationRequired')
|
|
413
|
+
})
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
// ============================================================================
|
|
417
|
+
// Error Handling Tests (ported from xrpc-server/tests/errors.test.ts)
|
|
418
|
+
// ============================================================================
|
|
419
|
+
|
|
420
|
+
describe('Error Handling', () => {
|
|
421
|
+
const io = {
|
|
422
|
+
example: {
|
|
423
|
+
error: l.query(
|
|
424
|
+
'io.example.error',
|
|
425
|
+
l.params({
|
|
426
|
+
which: l.optional(l.string()),
|
|
427
|
+
}),
|
|
428
|
+
l.payload(),
|
|
429
|
+
),
|
|
430
|
+
throwFalsyValue: l.query(
|
|
431
|
+
'io.example.throwFalsyValue',
|
|
432
|
+
l.params(),
|
|
433
|
+
l.payload(),
|
|
434
|
+
),
|
|
435
|
+
invalidResponse: l.query(
|
|
436
|
+
'io.example.invalidResponse',
|
|
437
|
+
l.params(),
|
|
438
|
+
l.payload(
|
|
439
|
+
'application/json',
|
|
440
|
+
l.object({
|
|
441
|
+
expectedValue: l.string(),
|
|
442
|
+
}),
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
describe('Custom Errors', () => {
|
|
449
|
+
it('throws custom error using LexError', async () => {
|
|
450
|
+
const handler: LexRouterMethodHandler<typeof io.example.error> = async ({
|
|
451
|
+
params,
|
|
452
|
+
}) => {
|
|
453
|
+
if (params.which === 'foo') {
|
|
454
|
+
throw new LexError('Foo', 'It was this one!')
|
|
455
|
+
}
|
|
456
|
+
return {}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const router = new LexRouter().add(io.example.error, handler)
|
|
460
|
+
|
|
461
|
+
const request = new Request(
|
|
462
|
+
'https://example.com/xrpc/io.example.error?which=foo',
|
|
463
|
+
)
|
|
464
|
+
const response = await router.handle(request)
|
|
465
|
+
|
|
466
|
+
expect(response.status).toBe(400)
|
|
467
|
+
const data = await response.json()
|
|
468
|
+
expect(data.error).toBe('Foo')
|
|
469
|
+
expect(data.message).toBe('It was this one!')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('returns custom error via Response object', async () => {
|
|
473
|
+
const handler: LexRouterMethodHandler<typeof io.example.error> = async ({
|
|
474
|
+
params,
|
|
475
|
+
}) => {
|
|
476
|
+
if (params.which === 'bar') {
|
|
477
|
+
return Response.json(
|
|
478
|
+
{ error: 'Bar', message: 'It was that one!' },
|
|
479
|
+
{ status: 400 },
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
return {}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const router = new LexRouter().add(io.example.error, handler)
|
|
486
|
+
|
|
487
|
+
const request = new Request(
|
|
488
|
+
'https://example.com/xrpc/io.example.error?which=bar',
|
|
489
|
+
)
|
|
490
|
+
const response = await router.handle(request)
|
|
491
|
+
|
|
492
|
+
expect(response.status).toBe(400)
|
|
493
|
+
const data = await response.json()
|
|
494
|
+
expect(data.error).toBe('Bar')
|
|
495
|
+
expect(data.message).toBe('It was that one!')
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
it('handles falsy values thrown as InternalError', async () => {
|
|
499
|
+
const handler: LexRouterMethodHandler<
|
|
500
|
+
typeof io.example.throwFalsyValue
|
|
501
|
+
> = async () => {
|
|
502
|
+
throw ''
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const router = new LexRouter().add(io.example.throwFalsyValue, handler)
|
|
506
|
+
|
|
507
|
+
const request = new Request(
|
|
508
|
+
'https://example.com/xrpc/io.example.throwFalsyValue',
|
|
509
|
+
)
|
|
510
|
+
const response = await router.handle(request)
|
|
511
|
+
|
|
512
|
+
expect(response.status).toBe(500)
|
|
513
|
+
const data = await response.json()
|
|
514
|
+
expect(data.error).toBe('InternalError')
|
|
515
|
+
})
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
describe('HTTP Method Mismatches', () => {
|
|
519
|
+
it('rejects POST for query endpoints', async () => {
|
|
520
|
+
const handler: LexRouterMethodHandler<
|
|
521
|
+
typeof io.example.error
|
|
522
|
+
> = async () => ({})
|
|
523
|
+
|
|
524
|
+
const router = new LexRouter().add(io.example.error, handler)
|
|
525
|
+
|
|
526
|
+
const request = new Request('https://example.com/xrpc/io.example.error', {
|
|
527
|
+
method: 'POST',
|
|
528
|
+
})
|
|
529
|
+
const response = await router.handle(request)
|
|
530
|
+
|
|
531
|
+
expect(response.status).toBe(405)
|
|
532
|
+
const data = await response.json()
|
|
533
|
+
expect(data.error).toBe('InvalidRequest')
|
|
534
|
+
expect(data.message).toBe('Method not allowed')
|
|
535
|
+
})
|
|
536
|
+
|
|
537
|
+
it('rejects GET for procedure endpoints', async () => {
|
|
538
|
+
const procedure = l.procedure(
|
|
539
|
+
'io.example.procedure',
|
|
540
|
+
l.params(),
|
|
541
|
+
l.payload('application/json', l.object({ data: l.string() })),
|
|
542
|
+
l.payload(),
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
const handler: LexRouterMethodHandler<typeof procedure> = async () => ({})
|
|
546
|
+
|
|
547
|
+
const router = new LexRouter().add(procedure, handler)
|
|
548
|
+
|
|
549
|
+
const request = new Request(
|
|
550
|
+
'https://example.com/xrpc/io.example.procedure',
|
|
551
|
+
{ method: 'GET' },
|
|
552
|
+
)
|
|
553
|
+
const response = await router.handle(request)
|
|
554
|
+
|
|
555
|
+
expect(response.status).toBe(405)
|
|
556
|
+
const data = await response.json()
|
|
557
|
+
expect(data.error).toBe('InvalidRequest')
|
|
558
|
+
expect(data.message).toBe('Method not allowed')
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
describe('Method Not Found', () => {
|
|
563
|
+
it('returns MethodNotImplemented for non-existent methods', async () => {
|
|
564
|
+
const router = new LexRouter()
|
|
565
|
+
|
|
566
|
+
const request = new Request(
|
|
567
|
+
'https://example.com/xrpc/io.example.doesNotExist',
|
|
568
|
+
)
|
|
569
|
+
const response = await router.handle(request)
|
|
570
|
+
|
|
571
|
+
expect(response.status).toBe(501)
|
|
572
|
+
expect(await response.json()).toMatchObject({
|
|
573
|
+
error: 'MethodNotImplemented',
|
|
574
|
+
})
|
|
575
|
+
})
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
describe('Custom Error Handlers', () => {
|
|
579
|
+
it('allows custom onHandlerError handler', async () => {
|
|
580
|
+
const onHandlerError = vi.fn()
|
|
581
|
+
const customRouter = new LexRouter({
|
|
582
|
+
onHandlerError,
|
|
583
|
+
})
|
|
584
|
+
|
|
585
|
+
const handler: LexRouterMethodHandler<
|
|
586
|
+
typeof io.example.error
|
|
587
|
+
> = async () => {
|
|
588
|
+
throw new Error('Test error')
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
customRouter.add(io.example.error, handler)
|
|
592
|
+
|
|
593
|
+
const request = new Request('https://example.com/xrpc/io.example.error')
|
|
594
|
+
const response = await customRouter.handle(request)
|
|
595
|
+
|
|
596
|
+
expect(onHandlerError).toHaveBeenCalled()
|
|
597
|
+
expect(response.status).toBe(500)
|
|
598
|
+
})
|
|
599
|
+
})
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
// ============================================================================
|
|
603
|
+
// Parameter Tests (ported from xrpc-server/tests/parameters.test.ts)
|
|
604
|
+
// ============================================================================
|
|
605
|
+
|
|
606
|
+
describe('Parameters', () => {
|
|
607
|
+
const io = {
|
|
608
|
+
example: {
|
|
609
|
+
paramTest: l.query(
|
|
610
|
+
'io.example.paramTest',
|
|
611
|
+
l.params({
|
|
612
|
+
str: l.string({ minLength: 2, maxLength: 10 }),
|
|
613
|
+
int: l.integer({ minimum: 2, maximum: 10 }),
|
|
614
|
+
bool: l.boolean(),
|
|
615
|
+
arr: l.array(l.integer(), { maxLength: 2 }),
|
|
616
|
+
def: l.optional(l.integer({ default: 0 })),
|
|
617
|
+
}),
|
|
618
|
+
l.payload(
|
|
619
|
+
'application/json',
|
|
620
|
+
l.object({
|
|
621
|
+
str: l.string(),
|
|
622
|
+
int: l.integer(),
|
|
623
|
+
bool: l.boolean(),
|
|
624
|
+
arr: l.array(l.integer()),
|
|
625
|
+
def: l.optional(l.integer()),
|
|
626
|
+
}),
|
|
627
|
+
),
|
|
628
|
+
),
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const handler: LexRouterMethodHandler<typeof io.example.paramTest> = async ({
|
|
633
|
+
params,
|
|
634
|
+
}) => ({
|
|
635
|
+
body: {
|
|
636
|
+
str: params.str,
|
|
637
|
+
int: params.int,
|
|
638
|
+
bool: params.bool,
|
|
639
|
+
arr: params.arr,
|
|
640
|
+
def: params.def,
|
|
641
|
+
},
|
|
642
|
+
})
|
|
643
|
+
|
|
644
|
+
const router = new LexRouter().add(io.example.paramTest, handler)
|
|
645
|
+
|
|
646
|
+
it('validates query params - valid input', async () => {
|
|
647
|
+
const request = new Request(
|
|
648
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true&arr=1&arr=2&def=5',
|
|
649
|
+
)
|
|
650
|
+
const response = await router.handle(request)
|
|
651
|
+
|
|
652
|
+
expect(response.status).toBe(200)
|
|
653
|
+
const data = await response.json()
|
|
654
|
+
expect(data.str).toBe('valid')
|
|
655
|
+
expect(data.int).toBe(5)
|
|
656
|
+
expect(data.bool).toBe(true)
|
|
657
|
+
expect(data.arr).toEqual([1, 2])
|
|
658
|
+
expect(data.def).toBe(5)
|
|
659
|
+
})
|
|
660
|
+
|
|
661
|
+
it('applies default values', async () => {
|
|
662
|
+
const request = new Request(
|
|
663
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true&arr=3&arr=4',
|
|
664
|
+
)
|
|
665
|
+
const response = await router.handle(request)
|
|
666
|
+
|
|
667
|
+
expect(response.status).toBe(200)
|
|
668
|
+
const data = await response.json()
|
|
669
|
+
// def should be undefined or 0 (default) when not provided
|
|
670
|
+
expect(data.def).toBe(0)
|
|
671
|
+
})
|
|
672
|
+
|
|
673
|
+
it('coerces types from query strings', async () => {
|
|
674
|
+
const request = new Request(
|
|
675
|
+
'https://example.com/xrpc/io.example.paramTest?str=10&int=5&bool=true&arr=3&arr=4',
|
|
676
|
+
)
|
|
677
|
+
const response = await router.handle(request)
|
|
678
|
+
|
|
679
|
+
expect(response.status).toBe(200)
|
|
680
|
+
const data = await response.json()
|
|
681
|
+
expect(data.str).toBe('10')
|
|
682
|
+
expect(data.int).toBe(5)
|
|
683
|
+
expect(data.bool).toBe(true)
|
|
684
|
+
expect(data.arr).toEqual([3, 4])
|
|
685
|
+
})
|
|
686
|
+
|
|
687
|
+
it('rejects string too short', async () => {
|
|
688
|
+
const request = new Request(
|
|
689
|
+
'https://example.com/xrpc/io.example.paramTest?str=n&int=5&bool=true&arr=1',
|
|
690
|
+
)
|
|
691
|
+
const response = await router.handle(request)
|
|
692
|
+
|
|
693
|
+
expect(response.status).toBe(400)
|
|
694
|
+
const data = await response.json()
|
|
695
|
+
expect(data.message).toContain('str')
|
|
696
|
+
})
|
|
697
|
+
|
|
698
|
+
it('rejects string too long', async () => {
|
|
699
|
+
const request = new Request(
|
|
700
|
+
'https://example.com/xrpc/io.example.paramTest?str=loooooooooooooong&int=5&bool=true&arr=1',
|
|
701
|
+
)
|
|
702
|
+
const response = await router.handle(request)
|
|
703
|
+
|
|
704
|
+
expect(response.status).toBe(400)
|
|
705
|
+
const data = await response.json()
|
|
706
|
+
expect(data.message).toContain('str')
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
it('rejects missing required parameter str', async () => {
|
|
710
|
+
const request = new Request(
|
|
711
|
+
'https://example.com/xrpc/io.example.paramTest?int=5&bool=true&arr=1',
|
|
712
|
+
)
|
|
713
|
+
const response = await router.handle(request)
|
|
714
|
+
|
|
715
|
+
expect(response.status).toBe(400)
|
|
716
|
+
const data = await response.json()
|
|
717
|
+
expect(data.message).toContain('str')
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
it('rejects missing required parameter int', async () => {
|
|
721
|
+
const request = new Request(
|
|
722
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&bool=true&arr=1',
|
|
723
|
+
)
|
|
724
|
+
const response = await router.handle(request)
|
|
725
|
+
|
|
726
|
+
expect(response.status).toBe(400)
|
|
727
|
+
const data = await response.json()
|
|
728
|
+
expect(data.message).toContain('int')
|
|
729
|
+
})
|
|
730
|
+
|
|
731
|
+
it('rejects missing required parameter bool', async () => {
|
|
732
|
+
const request = new Request(
|
|
733
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&arr=1',
|
|
734
|
+
)
|
|
735
|
+
const response = await router.handle(request)
|
|
736
|
+
|
|
737
|
+
expect(response.status).toBe(400)
|
|
738
|
+
const data = await response.json()
|
|
739
|
+
expect(data.message).toContain('bool')
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it('rejects integer too small', async () => {
|
|
743
|
+
const request = new Request(
|
|
744
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=-1&bool=true&arr=1',
|
|
745
|
+
)
|
|
746
|
+
const response = await router.handle(request)
|
|
747
|
+
|
|
748
|
+
expect(response.status).toBe(400)
|
|
749
|
+
const data = await response.json()
|
|
750
|
+
expect(data.message).toContain('int')
|
|
751
|
+
})
|
|
752
|
+
|
|
753
|
+
it('rejects integer too large', async () => {
|
|
754
|
+
const request = new Request(
|
|
755
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=11&bool=true&arr=1',
|
|
756
|
+
)
|
|
757
|
+
const response = await router.handle(request)
|
|
758
|
+
|
|
759
|
+
expect(response.status).toBe(400)
|
|
760
|
+
const data = await response.json()
|
|
761
|
+
expect(data.message).toContain('int')
|
|
762
|
+
})
|
|
763
|
+
|
|
764
|
+
it('rejects missing required array parameter', async () => {
|
|
765
|
+
const request = new Request(
|
|
766
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true',
|
|
767
|
+
)
|
|
768
|
+
const response = await router.handle(request)
|
|
769
|
+
|
|
770
|
+
expect(response.status).toBe(400)
|
|
771
|
+
const data = await response.json()
|
|
772
|
+
expect(data.message).toContain('arr')
|
|
773
|
+
})
|
|
774
|
+
|
|
775
|
+
it('rejects array too large', async () => {
|
|
776
|
+
const request = new Request(
|
|
777
|
+
'https://example.com/xrpc/io.example.paramTest?str=valid&int=5&bool=true&arr=1&arr=2&arr=3',
|
|
778
|
+
)
|
|
779
|
+
const response = await router.handle(request)
|
|
780
|
+
|
|
781
|
+
expect(response.status).toBe(400)
|
|
782
|
+
const data = await response.json()
|
|
783
|
+
expect(data.message).toContain('arr')
|
|
784
|
+
})
|
|
785
|
+
})
|
|
786
|
+
|
|
787
|
+
// ============================================================================
|
|
788
|
+
// Procedure Tests (ported from xrpc-server/tests/procedures.test.ts)
|
|
789
|
+
// ============================================================================
|
|
790
|
+
|
|
791
|
+
describe('Procedures', () => {
|
|
792
|
+
const io = {
|
|
793
|
+
example: {
|
|
794
|
+
pingOne: l.procedure(
|
|
795
|
+
'io.example.pingOne',
|
|
796
|
+
l.params({
|
|
797
|
+
message: l.string(),
|
|
798
|
+
}),
|
|
799
|
+
l.payload(),
|
|
800
|
+
l.payload('text/plain'),
|
|
801
|
+
),
|
|
802
|
+
pingTwo: l.procedure(
|
|
803
|
+
'io.example.pingTwo',
|
|
804
|
+
l.params(),
|
|
805
|
+
l.payload('text/plain'),
|
|
806
|
+
l.payload('text/plain'),
|
|
807
|
+
),
|
|
808
|
+
pingThree: l.procedure(
|
|
809
|
+
'io.example.pingThree',
|
|
810
|
+
l.params(),
|
|
811
|
+
l.payload('application/octet-stream'),
|
|
812
|
+
l.payload('application/octet-stream'),
|
|
813
|
+
),
|
|
814
|
+
pingFour: l.procedure(
|
|
815
|
+
'io.example.pingFour',
|
|
816
|
+
l.params(),
|
|
817
|
+
l.payload(
|
|
818
|
+
'application/json',
|
|
819
|
+
l.object({
|
|
820
|
+
message: l.string(),
|
|
821
|
+
}),
|
|
822
|
+
),
|
|
823
|
+
l.payload(
|
|
824
|
+
'application/json',
|
|
825
|
+
l.object({
|
|
826
|
+
message: l.string(),
|
|
827
|
+
}),
|
|
828
|
+
),
|
|
829
|
+
),
|
|
830
|
+
},
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const handlers = {
|
|
834
|
+
pingOne: (async ({ params }) => ({
|
|
835
|
+
encoding: 'text/plain',
|
|
836
|
+
body: params.message,
|
|
837
|
+
})) as LexRouterMethodHandler<typeof io.example.pingOne>,
|
|
838
|
+
pingTwo: (async ({ input }) => ({
|
|
839
|
+
encoding: 'text/plain',
|
|
840
|
+
body: input.body.body!,
|
|
841
|
+
})) as LexRouterMethodHandler<typeof io.example.pingTwo>,
|
|
842
|
+
pingThree: (async ({ input }) => ({
|
|
843
|
+
encoding: 'application/octet-stream',
|
|
844
|
+
body: input.body.body!,
|
|
845
|
+
})) as LexRouterMethodHandler<typeof io.example.pingThree>,
|
|
846
|
+
pingFour: (async ({ input }) => ({
|
|
847
|
+
body: { message: input.body.message },
|
|
848
|
+
})) as LexRouterMethodHandler<typeof io.example.pingFour>,
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const router = new LexRouter()
|
|
852
|
+
.add(io.example.pingOne, handlers.pingOne)
|
|
853
|
+
.add(io.example.pingTwo, handlers.pingTwo)
|
|
854
|
+
.add(io.example.pingThree, handlers.pingThree)
|
|
855
|
+
.add(io.example.pingFour, handlers.pingFour)
|
|
856
|
+
|
|
857
|
+
it('serves procedure with params returning text', async () => {
|
|
858
|
+
const request = new Request(
|
|
859
|
+
'https://example.com/xrpc/io.example.pingOne?message=hello%20world',
|
|
860
|
+
{ method: 'POST' },
|
|
861
|
+
)
|
|
862
|
+
const response = await router.handle(request)
|
|
863
|
+
|
|
864
|
+
expect(response.status).toBe(200)
|
|
865
|
+
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
866
|
+
expect(await response.text()).toBe('hello world')
|
|
867
|
+
})
|
|
868
|
+
|
|
869
|
+
it('serves procedure with text input/output', async () => {
|
|
870
|
+
const request = new Request('https://example.com/xrpc/io.example.pingTwo', {
|
|
871
|
+
method: 'POST',
|
|
872
|
+
headers: { 'content-type': 'text/plain' },
|
|
873
|
+
body: 'hello world',
|
|
874
|
+
})
|
|
875
|
+
const response = await router.handle(request)
|
|
876
|
+
|
|
877
|
+
expect(response.status).toBe(200)
|
|
878
|
+
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
879
|
+
expect(await response.text()).toBe('hello world')
|
|
880
|
+
})
|
|
881
|
+
|
|
882
|
+
it('serves procedure with binary input/output', async () => {
|
|
883
|
+
const request = new Request(
|
|
884
|
+
'https://example.com/xrpc/io.example.pingThree',
|
|
885
|
+
{
|
|
886
|
+
method: 'POST',
|
|
887
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
888
|
+
body: new TextEncoder().encode('hello world'),
|
|
889
|
+
},
|
|
890
|
+
)
|
|
891
|
+
const response = await router.handle(request)
|
|
892
|
+
|
|
893
|
+
expect(response.status).toBe(200)
|
|
894
|
+
expect(response.headers.get('content-type')).toBe(
|
|
895
|
+
'application/octet-stream',
|
|
896
|
+
)
|
|
897
|
+
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
898
|
+
expect(new TextDecoder().decode(responseBytes)).toBe('hello world')
|
|
899
|
+
})
|
|
900
|
+
|
|
901
|
+
it('serves procedure with JSON input/output', async () => {
|
|
902
|
+
const request = new Request(
|
|
903
|
+
'https://example.com/xrpc/io.example.pingFour',
|
|
904
|
+
{
|
|
905
|
+
method: 'POST',
|
|
906
|
+
headers: { 'content-type': 'application/json' },
|
|
907
|
+
body: JSON.stringify({ message: 'hello world' }),
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
const response = await router.handle(request)
|
|
911
|
+
|
|
912
|
+
expect(response.status).toBe(200)
|
|
913
|
+
expect(response.headers.get('content-type')).toBe('application/json')
|
|
914
|
+
const data = await response.json()
|
|
915
|
+
expect(data.message).toBe('hello world')
|
|
916
|
+
})
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
// ============================================================================
|
|
920
|
+
// Query Tests (ported from xrpc-server/tests/queries.test.ts)
|
|
921
|
+
// ============================================================================
|
|
922
|
+
|
|
923
|
+
describe('Queries', () => {
|
|
924
|
+
const io = {
|
|
925
|
+
example: {
|
|
926
|
+
pingOne: l.query(
|
|
927
|
+
'io.example.pingOne',
|
|
928
|
+
l.params({
|
|
929
|
+
message: l.string(),
|
|
930
|
+
}),
|
|
931
|
+
l.payload('text/plain'),
|
|
932
|
+
),
|
|
933
|
+
pingTwo: l.query(
|
|
934
|
+
'io.example.pingTwo',
|
|
935
|
+
l.params({
|
|
936
|
+
message: l.string(),
|
|
937
|
+
}),
|
|
938
|
+
l.payload('application/octet-stream'),
|
|
939
|
+
),
|
|
940
|
+
pingThree: l.query(
|
|
941
|
+
'io.example.pingThree',
|
|
942
|
+
l.params({
|
|
943
|
+
message: l.string(),
|
|
944
|
+
}),
|
|
945
|
+
l.payload('application/json', l.object({ message: l.string() })),
|
|
946
|
+
),
|
|
947
|
+
},
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const handlers = {
|
|
951
|
+
pingOne: (async ({ params }) => ({
|
|
952
|
+
encoding: 'text/plain',
|
|
953
|
+
body: params.message,
|
|
954
|
+
})) satisfies LexRouterMethodHandler<typeof io.example.pingOne>,
|
|
955
|
+
pingTwo: (async ({ params }) => ({
|
|
956
|
+
encoding: 'application/octet-stream',
|
|
957
|
+
body: new TextEncoder().encode(params.message),
|
|
958
|
+
})) satisfies LexRouterMethodHandler<typeof io.example.pingTwo>,
|
|
959
|
+
pingThree: (async ({ params }) => ({
|
|
960
|
+
body: { message: params.message },
|
|
961
|
+
headers: { 'x-test-header-name': 'test-value' },
|
|
962
|
+
})) satisfies LexRouterMethodHandler<typeof io.example.pingThree>,
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
const router = new LexRouter()
|
|
966
|
+
.add(io.example.pingOne, handlers.pingOne)
|
|
967
|
+
.add(io.example.pingTwo, handlers.pingTwo)
|
|
968
|
+
.add(io.example.pingThree, handlers.pingThree)
|
|
969
|
+
|
|
970
|
+
it('serves query with text response', async () => {
|
|
971
|
+
const request = new Request(
|
|
972
|
+
'https://example.com/xrpc/io.example.pingOne?message=hello%20world',
|
|
973
|
+
)
|
|
974
|
+
const response = await router.handle(request)
|
|
975
|
+
|
|
976
|
+
expect(response.status).toBe(200)
|
|
977
|
+
expect(response.headers.get('content-type')).toBe('text/plain')
|
|
978
|
+
expect(await response.text()).toBe('hello world')
|
|
979
|
+
})
|
|
980
|
+
|
|
981
|
+
it('serves query with binary response', async () => {
|
|
982
|
+
const request = new Request(
|
|
983
|
+
'https://example.com/xrpc/io.example.pingTwo?message=hello%20world',
|
|
984
|
+
)
|
|
985
|
+
const response = await router.handle(request)
|
|
986
|
+
|
|
987
|
+
expect(response.status).toBe(200)
|
|
988
|
+
expect(response.headers.get('content-type')).toBe(
|
|
989
|
+
'application/octet-stream',
|
|
990
|
+
)
|
|
991
|
+
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
992
|
+
expect(new TextDecoder().decode(bytes)).toBe('hello world')
|
|
993
|
+
})
|
|
994
|
+
|
|
995
|
+
it('serves query with JSON response and custom headers', async () => {
|
|
996
|
+
const request = new Request(
|
|
997
|
+
'https://example.com/xrpc/io.example.pingThree?message=hello%20world',
|
|
998
|
+
)
|
|
999
|
+
const response = await router.handle(request)
|
|
1000
|
+
|
|
1001
|
+
expect(response.status).toBe(200)
|
|
1002
|
+
expect(response.headers.get('content-type')).toBe('application/json')
|
|
1003
|
+
expect(response.headers.get('x-test-header-name')).toBe('test-value')
|
|
1004
|
+
const data = await response.json()
|
|
1005
|
+
expect(data.message).toBe('hello world')
|
|
1006
|
+
})
|
|
1007
|
+
|
|
1008
|
+
it('rejects query with content-type header', async () => {
|
|
1009
|
+
// GET requests can't have a body, but they can have content-type headers
|
|
1010
|
+
// The server should reject queries that have content-type/content-length headers
|
|
1011
|
+
const request = new Request(
|
|
1012
|
+
'https://example.com/xrpc/io.example.pingOne?message=hello',
|
|
1013
|
+
{
|
|
1014
|
+
method: 'GET',
|
|
1015
|
+
headers: { 'content-type': 'application/json' },
|
|
1016
|
+
},
|
|
1017
|
+
)
|
|
1018
|
+
const response = await router.handle(request)
|
|
1019
|
+
|
|
1020
|
+
expect(response.status).toBe(400)
|
|
1021
|
+
const data = await response.json()
|
|
1022
|
+
expect(data.error).toBe('InvalidRequest')
|
|
1023
|
+
})
|
|
1024
|
+
})
|
|
1025
|
+
|
|
1026
|
+
// ============================================================================
|
|
1027
|
+
// Response Handling Tests (ported from xrpc-server/tests/responses.test.ts)
|
|
1028
|
+
// ============================================================================
|
|
1029
|
+
|
|
1030
|
+
describe('Responses', () => {
|
|
1031
|
+
describe('Streaming Responses', () => {
|
|
1032
|
+
const io = {
|
|
1033
|
+
example: {
|
|
1034
|
+
readableStream: l.query(
|
|
1035
|
+
'io.example.readableStream',
|
|
1036
|
+
l.params({
|
|
1037
|
+
shouldErr: l.optional(l.boolean()),
|
|
1038
|
+
}),
|
|
1039
|
+
l.payload('application/vnd.ipld.car'),
|
|
1040
|
+
),
|
|
1041
|
+
},
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
it('returns readable streams of bytes', async () => {
|
|
1045
|
+
const handler: LexRouterMethodHandler<
|
|
1046
|
+
typeof io.example.readableStream
|
|
1047
|
+
> = async () => {
|
|
1048
|
+
const stream = new ReadableStream({
|
|
1049
|
+
start(controller) {
|
|
1050
|
+
for (let i = 0; i < 5; i++) {
|
|
1051
|
+
controller.enqueue(new Uint8Array([i]))
|
|
1052
|
+
}
|
|
1053
|
+
controller.close()
|
|
1054
|
+
},
|
|
1055
|
+
})
|
|
1056
|
+
|
|
1057
|
+
return {
|
|
1058
|
+
encoding: 'application/vnd.ipld.car',
|
|
1059
|
+
body: stream,
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
const router = new LexRouter().add(io.example.readableStream, handler)
|
|
1064
|
+
|
|
1065
|
+
const request = new Request(
|
|
1066
|
+
'https://example.com/xrpc/io.example.readableStream',
|
|
1067
|
+
)
|
|
1068
|
+
const response = await router.handle(request)
|
|
1069
|
+
|
|
1070
|
+
expect(response.status).toBe(200)
|
|
1071
|
+
expect(response.headers.get('content-type')).toBe(
|
|
1072
|
+
'application/vnd.ipld.car',
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
const reader = response.body!.getReader()
|
|
1076
|
+
const chunks: number[] = []
|
|
1077
|
+
// eslint-disable-next-line no-constant-condition
|
|
1078
|
+
while (true) {
|
|
1079
|
+
const { done, value } = await reader.read()
|
|
1080
|
+
if (done) break
|
|
1081
|
+
chunks.push(...value)
|
|
1082
|
+
}
|
|
1083
|
+
expect(chunks).toEqual([0, 1, 2, 3, 4])
|
|
1084
|
+
})
|
|
1085
|
+
|
|
1086
|
+
it('handles errors on readable streams of bytes', async () => {
|
|
1087
|
+
const handler: LexRouterMethodHandler<
|
|
1088
|
+
typeof io.example.readableStream
|
|
1089
|
+
> = async ({ params }) => {
|
|
1090
|
+
const stream = new ReadableStream({
|
|
1091
|
+
start(controller) {
|
|
1092
|
+
for (let i = 0; i < 5; i++) {
|
|
1093
|
+
controller.enqueue(new Uint8Array([i]))
|
|
1094
|
+
}
|
|
1095
|
+
if (params.shouldErr) {
|
|
1096
|
+
controller.error(new Error('Stream error'))
|
|
1097
|
+
} else {
|
|
1098
|
+
controller.close()
|
|
1099
|
+
}
|
|
1100
|
+
},
|
|
1101
|
+
})
|
|
1102
|
+
|
|
1103
|
+
return {
|
|
1104
|
+
encoding: 'application/vnd.ipld.car',
|
|
1105
|
+
body: stream,
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
const router = new LexRouter().add(io.example.readableStream, handler)
|
|
1110
|
+
|
|
1111
|
+
const request = new Request(
|
|
1112
|
+
'https://example.com/xrpc/io.example.readableStream?shouldErr=true',
|
|
1113
|
+
)
|
|
1114
|
+
const response = await router.handle(request)
|
|
1115
|
+
|
|
1116
|
+
expect(response.status).toBe(200)
|
|
1117
|
+
|
|
1118
|
+
const reader = response.body!.getReader()
|
|
1119
|
+
await expect(async () => {
|
|
1120
|
+
// eslint-disable-next-line no-constant-condition
|
|
1121
|
+
while (true) {
|
|
1122
|
+
const { done } = await reader.read()
|
|
1123
|
+
if (done) break
|
|
1124
|
+
}
|
|
1125
|
+
}).rejects.toThrow('Stream error')
|
|
1126
|
+
})
|
|
1127
|
+
})
|
|
1128
|
+
|
|
1129
|
+
describe('Empty Responses', () => {
|
|
1130
|
+
const io = {
|
|
1131
|
+
example: {
|
|
1132
|
+
emptyResponse: l.query(
|
|
1133
|
+
'io.example.emptyResponse',
|
|
1134
|
+
l.params(),
|
|
1135
|
+
l.payload(),
|
|
1136
|
+
),
|
|
1137
|
+
},
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
it('handles responses with no body', async () => {
|
|
1141
|
+
const handler: LexRouterMethodHandler<
|
|
1142
|
+
typeof io.example.emptyResponse
|
|
1143
|
+
> = async () => ({})
|
|
1144
|
+
|
|
1145
|
+
const router = new LexRouter().add(io.example.emptyResponse, handler)
|
|
1146
|
+
|
|
1147
|
+
const request = new Request(
|
|
1148
|
+
'https://example.com/xrpc/io.example.emptyResponse',
|
|
1149
|
+
)
|
|
1150
|
+
const response = await router.handle(request)
|
|
1151
|
+
|
|
1152
|
+
expect(response.status).toBe(200)
|
|
1153
|
+
expect(response.body).toBeNull()
|
|
1154
|
+
})
|
|
1155
|
+
|
|
1156
|
+
it('handles responses with headers but no body', async () => {
|
|
1157
|
+
const handler: LexRouterMethodHandler<
|
|
1158
|
+
typeof io.example.emptyResponse
|
|
1159
|
+
> = async () => ({
|
|
1160
|
+
headers: { 'x-custom-header': 'value' },
|
|
1161
|
+
})
|
|
1162
|
+
|
|
1163
|
+
const router = new LexRouter().add(io.example.emptyResponse, handler)
|
|
1164
|
+
|
|
1165
|
+
const request = new Request(
|
|
1166
|
+
'https://example.com/xrpc/io.example.emptyResponse',
|
|
1167
|
+
)
|
|
1168
|
+
const response = await router.handle(request)
|
|
1169
|
+
|
|
1170
|
+
expect(response.status).toBe(200)
|
|
1171
|
+
expect(response.headers.get('x-custom-header')).toBe('value')
|
|
1172
|
+
expect(response.body).toBeNull()
|
|
1173
|
+
})
|
|
1174
|
+
})
|
|
1175
|
+
|
|
1176
|
+
describe('Custom Response Objects', () => {
|
|
1177
|
+
const io = {
|
|
1178
|
+
example: {
|
|
1179
|
+
customResponse: l.query(
|
|
1180
|
+
'io.example.customResponse',
|
|
1181
|
+
l.params({
|
|
1182
|
+
status: l.integer(),
|
|
1183
|
+
}),
|
|
1184
|
+
l.payload(),
|
|
1185
|
+
),
|
|
1186
|
+
},
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
it('allows returning custom Response objects', async () => {
|
|
1190
|
+
const handler: LexRouterMethodHandler<
|
|
1191
|
+
typeof io.example.customResponse
|
|
1192
|
+
> = async ({ params }) => {
|
|
1193
|
+
return new Response(JSON.stringify({ code: params.status }), {
|
|
1194
|
+
status: params.status,
|
|
1195
|
+
headers: { 'content-type': 'application/json' },
|
|
1196
|
+
})
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
const router = new LexRouter().add(io.example.customResponse, handler)
|
|
1200
|
+
|
|
1201
|
+
const request = new Request(
|
|
1202
|
+
'https://example.com/xrpc/io.example.customResponse?status=201',
|
|
1203
|
+
)
|
|
1204
|
+
const response = await router.handle(request)
|
|
1205
|
+
|
|
1206
|
+
expect(response.status).toBe(201)
|
|
1207
|
+
const data = await response.json()
|
|
1208
|
+
expect(data.code).toBe(201)
|
|
1209
|
+
})
|
|
1210
|
+
})
|
|
1211
|
+
})
|
|
1212
|
+
|
|
1213
|
+
// ============================================================================
|
|
1214
|
+
// Body Handling Tests (ported from xrpc-server/tests/bodies.test.ts)
|
|
1215
|
+
// ============================================================================
|
|
1216
|
+
|
|
1217
|
+
describe('Body Handling', () => {
|
|
1218
|
+
describe('Input Validation', () => {
|
|
1219
|
+
const io = {
|
|
1220
|
+
example: {
|
|
1221
|
+
validationTest: l.procedure(
|
|
1222
|
+
'io.example.validationTest',
|
|
1223
|
+
l.params(),
|
|
1224
|
+
l.payload(
|
|
1225
|
+
'application/json',
|
|
1226
|
+
l.object({
|
|
1227
|
+
foo: l.string(),
|
|
1228
|
+
bar: l.optional(l.integer()),
|
|
1229
|
+
}),
|
|
1230
|
+
),
|
|
1231
|
+
l.payload(
|
|
1232
|
+
'application/json',
|
|
1233
|
+
l.object({
|
|
1234
|
+
foo: l.string(),
|
|
1235
|
+
bar: l.optional(l.integer()),
|
|
1236
|
+
}),
|
|
1237
|
+
),
|
|
1238
|
+
),
|
|
1239
|
+
},
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const handler: LexRouterMethodHandler<
|
|
1243
|
+
typeof io.example.validationTest
|
|
1244
|
+
> = async ({ input }) => ({
|
|
1245
|
+
body: input.body!,
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
const router = new LexRouter().add(io.example.validationTest, handler)
|
|
1249
|
+
|
|
1250
|
+
it('validates input and output bodies', async () => {
|
|
1251
|
+
const request = new Request(
|
|
1252
|
+
'https://example.com/xrpc/io.example.validationTest',
|
|
1253
|
+
{
|
|
1254
|
+
method: 'POST',
|
|
1255
|
+
headers: { 'content-type': 'application/json' },
|
|
1256
|
+
body: JSON.stringify({ foo: 'hello', bar: 123 }),
|
|
1257
|
+
},
|
|
1258
|
+
)
|
|
1259
|
+
const response = await router.handle(request)
|
|
1260
|
+
|
|
1261
|
+
expect(response.status).toBe(200)
|
|
1262
|
+
const data = await response.json()
|
|
1263
|
+
expect(data.foo).toBe('hello')
|
|
1264
|
+
expect(data.bar).toBe(123)
|
|
1265
|
+
})
|
|
1266
|
+
|
|
1267
|
+
it('rejects missing required fields', async () => {
|
|
1268
|
+
const request = new Request(
|
|
1269
|
+
'https://example.com/xrpc/io.example.validationTest',
|
|
1270
|
+
{
|
|
1271
|
+
method: 'POST',
|
|
1272
|
+
headers: { 'content-type': 'application/json' },
|
|
1273
|
+
body: JSON.stringify({}),
|
|
1274
|
+
},
|
|
1275
|
+
)
|
|
1276
|
+
const response = await router.handle(request)
|
|
1277
|
+
|
|
1278
|
+
expect(response.status).toBe(400)
|
|
1279
|
+
const data = await response.json()
|
|
1280
|
+
expect(data.message).toContain('foo')
|
|
1281
|
+
})
|
|
1282
|
+
|
|
1283
|
+
it('rejects wrong types', async () => {
|
|
1284
|
+
const request = new Request(
|
|
1285
|
+
'https://example.com/xrpc/io.example.validationTest',
|
|
1286
|
+
{
|
|
1287
|
+
method: 'POST',
|
|
1288
|
+
headers: { 'content-type': 'application/json' },
|
|
1289
|
+
body: JSON.stringify({ foo: 123 }),
|
|
1290
|
+
},
|
|
1291
|
+
)
|
|
1292
|
+
const response = await router.handle(request)
|
|
1293
|
+
|
|
1294
|
+
expect(response.status).toBe(400)
|
|
1295
|
+
const data = await response.json()
|
|
1296
|
+
expect(data.message).toContain('foo')
|
|
1297
|
+
})
|
|
1298
|
+
|
|
1299
|
+
it('rejects wrong content-type', async () => {
|
|
1300
|
+
const request = new Request(
|
|
1301
|
+
'https://example.com/xrpc/io.example.validationTest',
|
|
1302
|
+
{
|
|
1303
|
+
method: 'POST',
|
|
1304
|
+
headers: { 'content-type': 'image/jpeg' },
|
|
1305
|
+
body: new Uint8Array([1, 2, 3]),
|
|
1306
|
+
},
|
|
1307
|
+
)
|
|
1308
|
+
const response = await router.handle(request)
|
|
1309
|
+
|
|
1310
|
+
expect(response.status).toBe(400)
|
|
1311
|
+
const data = await response.json()
|
|
1312
|
+
expect(data.error).toBe('InvalidRequest')
|
|
1313
|
+
})
|
|
1314
|
+
})
|
|
1315
|
+
|
|
1316
|
+
describe('Binary Data Support', () => {
|
|
1317
|
+
const io = {
|
|
1318
|
+
example: {
|
|
1319
|
+
blobTest: l.procedure(
|
|
1320
|
+
'io.example.blobTest',
|
|
1321
|
+
l.params(),
|
|
1322
|
+
l.payload('*/*'),
|
|
1323
|
+
l.payload('application/octet-stream'),
|
|
1324
|
+
),
|
|
1325
|
+
},
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
const handler: LexRouterMethodHandler<typeof io.example.blobTest> = async ({
|
|
1329
|
+
input,
|
|
1330
|
+
}) => {
|
|
1331
|
+
return {
|
|
1332
|
+
encoding: 'application/octet-stream',
|
|
1333
|
+
body: new Uint8Array(await input.body.arrayBuffer()),
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const router = new LexRouter().add(io.example.blobTest, handler)
|
|
1338
|
+
|
|
1339
|
+
it('supports ArrayBuffers', async () => {
|
|
1340
|
+
const bytes = new Uint8Array([1, 2, 3, 4, 5])
|
|
1341
|
+
const request = new Request(
|
|
1342
|
+
'https://example.com/xrpc/io.example.blobTest',
|
|
1343
|
+
{
|
|
1344
|
+
method: 'POST',
|
|
1345
|
+
// @NOTE content-type will default to application/octet-stream
|
|
1346
|
+
body: bytes,
|
|
1347
|
+
},
|
|
1348
|
+
)
|
|
1349
|
+
const response = await router.handle(request)
|
|
1350
|
+
|
|
1351
|
+
expect(response.status).toBe(200)
|
|
1352
|
+
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1353
|
+
expect(responseBytes).toEqual(bytes)
|
|
1354
|
+
expect(response.headers.get('content-type')).toBe(
|
|
1355
|
+
'application/octet-stream',
|
|
1356
|
+
)
|
|
1357
|
+
})
|
|
1358
|
+
|
|
1359
|
+
it('supports empty payload', async () => {
|
|
1360
|
+
const bytes = new Uint8Array(0)
|
|
1361
|
+
const request = new Request(
|
|
1362
|
+
'https://example.com/xrpc/io.example.blobTest',
|
|
1363
|
+
{
|
|
1364
|
+
method: 'POST',
|
|
1365
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
1366
|
+
body: bytes,
|
|
1367
|
+
},
|
|
1368
|
+
)
|
|
1369
|
+
const response = await router.handle(request)
|
|
1370
|
+
|
|
1371
|
+
expect(response.status).toBe(200)
|
|
1372
|
+
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1373
|
+
expect(responseBytes).toEqual(bytes)
|
|
1374
|
+
})
|
|
1375
|
+
|
|
1376
|
+
it('supports ReadableStream', async () => {
|
|
1377
|
+
const message = 'hello world'
|
|
1378
|
+
const stream = new ReadableStream({
|
|
1379
|
+
start(controller) {
|
|
1380
|
+
controller.enqueue(new TextEncoder().encode(message))
|
|
1381
|
+
controller.close()
|
|
1382
|
+
},
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
const request = new Request(
|
|
1386
|
+
'https://example.com/xrpc/io.example.blobTest',
|
|
1387
|
+
{
|
|
1388
|
+
method: 'POST',
|
|
1389
|
+
headers: { 'content-type': 'application/octet-stream' },
|
|
1390
|
+
// @ts-expect-error
|
|
1391
|
+
duplex: 'half',
|
|
1392
|
+
body: stream,
|
|
1393
|
+
},
|
|
1394
|
+
)
|
|
1395
|
+
const response = await router.handle(request)
|
|
1396
|
+
|
|
1397
|
+
expect(response.status).toBe(200)
|
|
1398
|
+
const responseBytes = new Uint8Array(await response.arrayBuffer())
|
|
1399
|
+
expect(new TextDecoder().decode(responseBytes)).toBe(message)
|
|
1400
|
+
})
|
|
1401
|
+
|
|
1402
|
+
it('requires any parsable Content-Type for blob uploads', async () => {
|
|
1403
|
+
const bytes = new Uint8Array([1, 2, 3])
|
|
1404
|
+
const request = new Request(
|
|
1405
|
+
'https://example.com/xrpc/io.example.blobTest',
|
|
1406
|
+
{
|
|
1407
|
+
method: 'POST',
|
|
1408
|
+
headers: { 'content-type': 'some/thing' },
|
|
1409
|
+
body: bytes,
|
|
1410
|
+
},
|
|
1411
|
+
)
|
|
1412
|
+
const response = await router.handle(request)
|
|
1413
|
+
|
|
1414
|
+
expect(response.status).toBe(200)
|
|
1415
|
+
})
|
|
1416
|
+
})
|
|
1417
|
+
|
|
1418
|
+
describe('Edge Cases', () => {
|
|
1419
|
+
it('errors on missing Content-Type for JSON payload', async () => {
|
|
1420
|
+
const io = {
|
|
1421
|
+
example: {
|
|
1422
|
+
emptyContentType: l.procedure(
|
|
1423
|
+
'io.example.emptyContentType',
|
|
1424
|
+
l.params(),
|
|
1425
|
+
l.payload('application/json', l.object({ data: l.string() })),
|
|
1426
|
+
l.payload('application/json', l.object({ data: l.string() })),
|
|
1427
|
+
),
|
|
1428
|
+
},
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const handler: LexRouterMethodHandler<
|
|
1432
|
+
typeof io.example.emptyContentType
|
|
1433
|
+
> = async ({ input }) => ({
|
|
1434
|
+
body: { data: input.body!.data },
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
const router = new LexRouter().add(io.example.emptyContentType, handler)
|
|
1438
|
+
|
|
1439
|
+
const request = new Request(
|
|
1440
|
+
'https://example.com/xrpc/io.example.emptyContentType',
|
|
1441
|
+
{
|
|
1442
|
+
method: 'POST',
|
|
1443
|
+
body: JSON.stringify({ data: 'test' }),
|
|
1444
|
+
},
|
|
1445
|
+
)
|
|
1446
|
+
|
|
1447
|
+
const response = await router.handle(request)
|
|
1448
|
+
|
|
1449
|
+
expect(response.status).toBe(400)
|
|
1450
|
+
const data = await response.json()
|
|
1451
|
+
expect(data.error).toBe('InvalidRequest')
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
it('defaults to application/octet-stream for empty Content-Type', async () => {
|
|
1455
|
+
const io = {
|
|
1456
|
+
example: {
|
|
1457
|
+
emptyContentTypeBlob: l.procedure(
|
|
1458
|
+
'io.example.emptyContentTypeBlob',
|
|
1459
|
+
l.params(),
|
|
1460
|
+
l.payload('*/*'),
|
|
1461
|
+
l.payload('application/json', l.object({ encoding: l.string() })),
|
|
1462
|
+
),
|
|
1463
|
+
},
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
const handler: LexRouterMethodHandler<
|
|
1467
|
+
typeof io.example.emptyContentTypeBlob
|
|
1468
|
+
> = async ({ input }) => ({
|
|
1469
|
+
body: { encoding: input.encoding },
|
|
1470
|
+
})
|
|
1471
|
+
|
|
1472
|
+
const router = new LexRouter().add(
|
|
1473
|
+
io.example.emptyContentTypeBlob,
|
|
1474
|
+
handler,
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
const request = new Request(
|
|
1478
|
+
'https://example.com/xrpc/io.example.emptyContentTypeBlob',
|
|
1479
|
+
{
|
|
1480
|
+
method: 'POST',
|
|
1481
|
+
body: new Uint8Array([1, 2, 3]),
|
|
1482
|
+
},
|
|
1483
|
+
)
|
|
1484
|
+
const response = await router.handle(request)
|
|
1485
|
+
|
|
1486
|
+
expect(response.status).toBe(200)
|
|
1487
|
+
const data = await response.json()
|
|
1488
|
+
expect(response.headers.get('content-type')).toBe('application/json')
|
|
1489
|
+
expect(data.encoding).toBe('application/octet-stream')
|
|
1490
|
+
})
|
|
1491
|
+
})
|
|
1492
|
+
})
|
|
1493
|
+
|
|
1494
|
+
describe('Subscription', () => {
|
|
1495
|
+
const io = {
|
|
1496
|
+
example: {
|
|
1497
|
+
subscribe: l.subscription(
|
|
1498
|
+
'io.example.subscribe',
|
|
1499
|
+
l.params({
|
|
1500
|
+
message: l.string({ default: 'hello' }),
|
|
1501
|
+
}),
|
|
1502
|
+
l.object({
|
|
1503
|
+
message: l.string(),
|
|
1504
|
+
count: l.integer(),
|
|
1505
|
+
}),
|
|
1506
|
+
),
|
|
1507
|
+
},
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
it('handles subscriptions with cleanup', async () => {
|
|
1511
|
+
let sentCount = 0
|
|
1512
|
+
|
|
1513
|
+
const { resolve, promise: finallyPromise } = timeoutDeferred(5000)
|
|
1514
|
+
|
|
1515
|
+
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1516
|
+
io.example.subscribe,
|
|
1517
|
+
async function* ({ params: { message }, request: { signal } }) {
|
|
1518
|
+
try {
|
|
1519
|
+
for (; sentCount < 10; ) {
|
|
1520
|
+
await scheduler.wait(5, { signal })
|
|
1521
|
+
yield { message, count: ++sentCount }
|
|
1522
|
+
}
|
|
1523
|
+
} finally {
|
|
1524
|
+
resolve()
|
|
1525
|
+
}
|
|
1526
|
+
},
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
await using server = await serve(router)
|
|
1530
|
+
|
|
1531
|
+
const { port } = server.address() as AddressInfo
|
|
1532
|
+
const ws = new WebSocket(
|
|
1533
|
+
`ws://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1534
|
+
)
|
|
1535
|
+
ws.binaryType = 'arraybuffer'
|
|
1536
|
+
|
|
1537
|
+
const messages: unknown[] = []
|
|
1538
|
+
ws.addEventListener('message', (event) => {
|
|
1539
|
+
try {
|
|
1540
|
+
const bytes = new Uint8Array(event.data as ArrayBuffer)
|
|
1541
|
+
const data = [...decodeAll(bytes)]
|
|
1542
|
+
messages.push(data)
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
messages.push(err)
|
|
1545
|
+
}
|
|
1546
|
+
if (messages.length >= 3) {
|
|
1547
|
+
ws.close()
|
|
1548
|
+
}
|
|
1549
|
+
})
|
|
1550
|
+
|
|
1551
|
+
// Ensures that "finally" block is indeed called
|
|
1552
|
+
await finallyPromise
|
|
1553
|
+
|
|
1554
|
+
expect(messages).toStrictEqual([
|
|
1555
|
+
[{ op: 1 }, { message: 'ping', count: 1 }],
|
|
1556
|
+
[{ op: 1 }, { message: 'ping', count: 2 }],
|
|
1557
|
+
[{ op: 1 }, { message: 'ping', count: 3 }],
|
|
1558
|
+
])
|
|
1559
|
+
|
|
1560
|
+
expect(sentCount).toBeGreaterThanOrEqual(3)
|
|
1561
|
+
expect(sentCount).toBeLessThan(5)
|
|
1562
|
+
})
|
|
1563
|
+
|
|
1564
|
+
it('returns 405 for non-GET request', async () => {
|
|
1565
|
+
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1566
|
+
io.example.subscribe,
|
|
1567
|
+
async function* () {},
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
await using server = await serve(router)
|
|
1571
|
+
const { port } = server.address() as AddressInfo
|
|
1572
|
+
|
|
1573
|
+
const response = await fetch(
|
|
1574
|
+
`http://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1575
|
+
{ method: 'POST' },
|
|
1576
|
+
)
|
|
1577
|
+
|
|
1578
|
+
expect(response.status).toBe(405)
|
|
1579
|
+
const data = await response.json()
|
|
1580
|
+
expect(data.error).toBe('InvalidRequest')
|
|
1581
|
+
expect(data.message).toBe('Method not allowed')
|
|
1582
|
+
})
|
|
1583
|
+
|
|
1584
|
+
it('returns 426 for non-WebSocket request', async () => {
|
|
1585
|
+
const router = new LexRouter({ upgradeWebSocket }).add(
|
|
1586
|
+
io.example.subscribe,
|
|
1587
|
+
async function* () {},
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
await using server = await serve(router)
|
|
1591
|
+
const { port } = server.address() as AddressInfo
|
|
1592
|
+
|
|
1593
|
+
const response = await fetch(
|
|
1594
|
+
`http://localhost:${port}/xrpc/io.example.subscribe?message=ping`,
|
|
1595
|
+
{ method: 'GET' },
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
expect(response.status).toBe(426)
|
|
1599
|
+
expect(response.headers.get('upgrade')).toBe('websocket')
|
|
1600
|
+
expect(response.headers.get('connection')).toBe('Upgrade')
|
|
1601
|
+
const data = await response.json()
|
|
1602
|
+
expect(data.error).toBe('InvalidRequest')
|
|
1603
|
+
expect(data.message).toBe(
|
|
1604
|
+
'XRPC subscriptions are only available over WebSocket',
|
|
1605
|
+
)
|
|
1606
|
+
})
|
|
1607
|
+
})
|
|
1608
|
+
|
|
1609
|
+
function timeoutDeferred(ms: number) {
|
|
1610
|
+
let resolve: () => void
|
|
1611
|
+
let reject: (err: unknown) => void
|
|
1612
|
+
const promise = new Promise<void>((res, rej) => {
|
|
1613
|
+
resolve = res
|
|
1614
|
+
reject = rej
|
|
1615
|
+
})
|
|
1616
|
+
const to = setTimeout(() => reject(new Error('Timed out')), ms).unref()
|
|
1617
|
+
promise.finally(() => {
|
|
1618
|
+
clearTimeout(to)
|
|
1619
|
+
})
|
|
1620
|
+
return { resolve: resolve!, promise }
|
|
1621
|
+
}
|