@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,551 @@
|
|
|
1
|
+
import { encode } from '@atproto/lex-cbor'
|
|
2
|
+
import { LexError, LexValue, isPlainObject, ui8Concat } from '@atproto/lex-data'
|
|
3
|
+
import { lexParse, lexToJson } from '@atproto/lex-json'
|
|
4
|
+
import {
|
|
5
|
+
InferMethodInput,
|
|
6
|
+
InferMethodMessage,
|
|
7
|
+
InferMethodOutput,
|
|
8
|
+
InferMethodOutputBody,
|
|
9
|
+
InferMethodOutputEncoding,
|
|
10
|
+
InferMethodParams,
|
|
11
|
+
Main,
|
|
12
|
+
NsidString,
|
|
13
|
+
Procedure,
|
|
14
|
+
Query,
|
|
15
|
+
Subscription,
|
|
16
|
+
getMain,
|
|
17
|
+
isNsidString,
|
|
18
|
+
} from '@atproto/lex-schema'
|
|
19
|
+
import { drainWebsocket } from './lib/drain-websocket.js'
|
|
20
|
+
|
|
21
|
+
type LexMethod = Query | Procedure | Subscription
|
|
22
|
+
|
|
23
|
+
export type NetAddr = {
|
|
24
|
+
hostname: string
|
|
25
|
+
port: number
|
|
26
|
+
transport: 'tcp' | 'udp'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type UnixAddr = {
|
|
30
|
+
path: string
|
|
31
|
+
transport: 'unix' | 'unixpacket'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Addr = NetAddr | UnixAddr
|
|
35
|
+
|
|
36
|
+
export type ConnectionInfo = {
|
|
37
|
+
localAddr?: Addr
|
|
38
|
+
remoteAddr?: Addr
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Handler = (
|
|
42
|
+
request: Request,
|
|
43
|
+
connection?: ConnectionInfo,
|
|
44
|
+
) => Promise<Response>
|
|
45
|
+
|
|
46
|
+
export type LexRouterHandlerContext<Method extends LexMethod, Credentials> = {
|
|
47
|
+
credentials: Credentials
|
|
48
|
+
input: InferMethodInput<Method, Body>
|
|
49
|
+
params: InferMethodParams<Method>
|
|
50
|
+
request: Request
|
|
51
|
+
connection?: ConnectionInfo
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type AsOptionalPayloadOptions<T> = T extends undefined | void
|
|
55
|
+
? { encoding?: undefined; body?: undefined }
|
|
56
|
+
: T
|
|
57
|
+
|
|
58
|
+
export type LexRouterHandlerOutput<Method extends Query | Procedure> =
|
|
59
|
+
| Response
|
|
60
|
+
| ({
|
|
61
|
+
headers?: HeadersInit
|
|
62
|
+
} & (InferMethodOutputEncoding<Method> extends 'application/json'
|
|
63
|
+
? {
|
|
64
|
+
// Allow omitting body when output is JSON
|
|
65
|
+
encoding?: 'application/json'
|
|
66
|
+
body: InferMethodOutputBody<Method>
|
|
67
|
+
}
|
|
68
|
+
: AsOptionalPayloadOptions<InferMethodOutput<Method, BodyInit>>))
|
|
69
|
+
|
|
70
|
+
export type LexRouterMethodHandler<
|
|
71
|
+
Method extends Query | Procedure = Query | Procedure,
|
|
72
|
+
Credentials = unknown,
|
|
73
|
+
> = (
|
|
74
|
+
ctx: LexRouterHandlerContext<Method, Credentials>,
|
|
75
|
+
) => Promise<LexRouterHandlerOutput<Method>>
|
|
76
|
+
|
|
77
|
+
export type LexRouterMethodConfig<
|
|
78
|
+
Method extends Query | Procedure = Query | Procedure,
|
|
79
|
+
Credentials = unknown,
|
|
80
|
+
> = {
|
|
81
|
+
handler: LexRouterMethodHandler<Method, Credentials>
|
|
82
|
+
auth: LexRouterAuth<Method, Credentials>
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type LexRouterSubscriptionHandler<
|
|
86
|
+
Method extends Subscription = Subscription,
|
|
87
|
+
Credentials = unknown,
|
|
88
|
+
> = (
|
|
89
|
+
ctx: LexRouterHandlerContext<Method, Credentials>,
|
|
90
|
+
) => AsyncIterable<InferMethodMessage<Method>>
|
|
91
|
+
|
|
92
|
+
export type LexRouterSubscriptionConfig<
|
|
93
|
+
Method extends Subscription = Subscription,
|
|
94
|
+
Credentials = unknown,
|
|
95
|
+
> = {
|
|
96
|
+
handler: LexRouterSubscriptionHandler<Method, Credentials>
|
|
97
|
+
auth: LexRouterAuth<Method, Credentials>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export type LexRouterAuthContext<Method extends LexMethod = LexMethod> = {
|
|
101
|
+
params: InferMethodParams<Method>
|
|
102
|
+
request: Request
|
|
103
|
+
connection?: ConnectionInfo
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type LexRouterAuth<
|
|
107
|
+
Method extends LexMethod = LexMethod,
|
|
108
|
+
Credentials = unknown,
|
|
109
|
+
> = (ctx: LexRouterAuthContext<Method>) => Credentials | Promise<Credentials>
|
|
110
|
+
|
|
111
|
+
export type LexErrorHandlerContext = {
|
|
112
|
+
error: unknown
|
|
113
|
+
request: Request
|
|
114
|
+
method: LexMethod
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type UpgradeWebSocket = (request: Request) => {
|
|
118
|
+
socket: WebSocket
|
|
119
|
+
response: Response
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type LexRouterOptions = {
|
|
123
|
+
upgradeWebSocket?: UpgradeWebSocket
|
|
124
|
+
onHandlerError?: (ctx: LexErrorHandlerContext) => void | Promise<void>
|
|
125
|
+
highWaterMark?: number
|
|
126
|
+
lowWaterMark?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export class LexRouter {
|
|
130
|
+
private handlers: Map<NsidString, Handler> = new Map()
|
|
131
|
+
|
|
132
|
+
constructor(readonly options: LexRouterOptions = {}) {}
|
|
133
|
+
|
|
134
|
+
add<M extends Subscription>(
|
|
135
|
+
ns: Main<M>,
|
|
136
|
+
handler: LexRouterSubscriptionHandler<M, void>,
|
|
137
|
+
): this
|
|
138
|
+
add<M extends Subscription, Credentials>(
|
|
139
|
+
ns: Main<M>,
|
|
140
|
+
config: LexRouterSubscriptionConfig<M, Credentials>,
|
|
141
|
+
): this
|
|
142
|
+
add<M extends Query | Procedure>(
|
|
143
|
+
ns: Main<M>,
|
|
144
|
+
handler: LexRouterMethodHandler<M, void>,
|
|
145
|
+
): this
|
|
146
|
+
add<M extends Query | Procedure, Credentials>(
|
|
147
|
+
ns: Main<M>,
|
|
148
|
+
config: LexRouterMethodConfig<M, Credentials>,
|
|
149
|
+
): this
|
|
150
|
+
add<M extends LexMethod>(
|
|
151
|
+
ns: Main<M>,
|
|
152
|
+
config:
|
|
153
|
+
| LexRouterSubscriptionHandler<any, any>
|
|
154
|
+
| LexRouterSubscriptionConfig<any, any>
|
|
155
|
+
| LexRouterMethodHandler<any, any>
|
|
156
|
+
| LexRouterMethodConfig<any, any>,
|
|
157
|
+
) {
|
|
158
|
+
const method = getMain(ns)
|
|
159
|
+
if (this.handlers.has(method.nsid)) {
|
|
160
|
+
throw new TypeError(`Method ${method.nsid} already registered`)
|
|
161
|
+
}
|
|
162
|
+
const methodConfig =
|
|
163
|
+
typeof config === 'function'
|
|
164
|
+
? { handler: config, auth: undefined }
|
|
165
|
+
: config
|
|
166
|
+
|
|
167
|
+
const handler: Handler =
|
|
168
|
+
method.type === 'subscription'
|
|
169
|
+
? this.buildSubscriptionHandler(
|
|
170
|
+
method,
|
|
171
|
+
methodConfig.handler as LexRouterSubscriptionHandler<any, any>,
|
|
172
|
+
methodConfig.auth,
|
|
173
|
+
)
|
|
174
|
+
: this.buildMethodHandler(
|
|
175
|
+
method,
|
|
176
|
+
methodConfig.handler as LexRouterMethodHandler<any, any>,
|
|
177
|
+
methodConfig.auth,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
this.handlers.set(method.nsid, handler)
|
|
181
|
+
|
|
182
|
+
return this
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private buildMethodHandler<Method extends Query | Procedure, Credentials>(
|
|
186
|
+
method: Method,
|
|
187
|
+
methodHandler: LexRouterMethodHandler<Method, Credentials>,
|
|
188
|
+
auth?: LexRouterAuth<Method, Credentials>,
|
|
189
|
+
): Handler {
|
|
190
|
+
const getInput = (
|
|
191
|
+
method.type === 'procedure'
|
|
192
|
+
? getProcedureInput.bind(method)
|
|
193
|
+
: getQueryInput.bind(method)
|
|
194
|
+
) as (request: Request) => Promise<InferMethodInput<Method, Body>>
|
|
195
|
+
|
|
196
|
+
return async (
|
|
197
|
+
request: Request,
|
|
198
|
+
connection?: ConnectionInfo,
|
|
199
|
+
): Promise<Response> => {
|
|
200
|
+
// @NOTE CORS requests should be handled by a middleware before reaching
|
|
201
|
+
// this point.
|
|
202
|
+
if (
|
|
203
|
+
(method.type === 'procedure' && request.method !== 'POST') ||
|
|
204
|
+
(method.type === 'query' &&
|
|
205
|
+
request.method !== 'GET' &&
|
|
206
|
+
request.method !== 'HEAD')
|
|
207
|
+
) {
|
|
208
|
+
return Response.json(
|
|
209
|
+
{ error: 'InvalidRequest', message: 'Method not allowed' },
|
|
210
|
+
{ status: 405 },
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(request.url)
|
|
216
|
+
const params = method.parameters.fromURLSearchParams(url.searchParams)
|
|
217
|
+
|
|
218
|
+
const credentials = auth
|
|
219
|
+
? await auth({ params, request, connection })
|
|
220
|
+
: (undefined as Credentials)
|
|
221
|
+
|
|
222
|
+
const input = await getInput(request)
|
|
223
|
+
|
|
224
|
+
const output = await methodHandler({
|
|
225
|
+
credentials,
|
|
226
|
+
params,
|
|
227
|
+
input,
|
|
228
|
+
request,
|
|
229
|
+
connection,
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
if (output instanceof Response) {
|
|
233
|
+
return output
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// @TODO add validation of output based on method.output.schema?
|
|
237
|
+
|
|
238
|
+
if (output.body === undefined && output.encoding === undefined) {
|
|
239
|
+
return new Response(null, { status: 200, headers: output.headers })
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (method.output?.encoding === 'application/json') {
|
|
243
|
+
return Response.json(lexToJson(output.body as LexValue), {
|
|
244
|
+
status: 200,
|
|
245
|
+
headers: output.headers,
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const headers = new Headers(output.headers)
|
|
250
|
+
headers.set('content-type', output.encoding!)
|
|
251
|
+
return new Response(output.body, { status: 200, headers })
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return this.handleError(request, method, error)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private buildSubscriptionHandler<Method extends Subscription, Credentials>(
|
|
259
|
+
method: Method,
|
|
260
|
+
methodHandler: LexRouterSubscriptionHandler<Method, Credentials>,
|
|
261
|
+
auth?: LexRouterAuth<Method, Credentials>,
|
|
262
|
+
): Handler {
|
|
263
|
+
const {
|
|
264
|
+
onHandlerError,
|
|
265
|
+
upgradeWebSocket = (globalThis as any).Deno?.upgradeWebSocket as
|
|
266
|
+
| UpgradeWebSocket
|
|
267
|
+
| undefined,
|
|
268
|
+
} = this.options
|
|
269
|
+
if (!upgradeWebSocket) {
|
|
270
|
+
throw new TypeError(
|
|
271
|
+
'WebSocket upgrade not supported in this environment. Please provide an upgradeWebSocket option when creating the LexRouter.',
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return async (
|
|
276
|
+
request: Request,
|
|
277
|
+
connection?: ConnectionInfo,
|
|
278
|
+
): Promise<Response> => {
|
|
279
|
+
if (request.method !== 'GET') {
|
|
280
|
+
return Response.json(
|
|
281
|
+
{ error: 'InvalidRequest', message: 'Method not allowed' },
|
|
282
|
+
{ status: 405 },
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
request.headers.get('connection')?.toLowerCase() !== 'upgrade' ||
|
|
288
|
+
request.headers.get('upgrade')?.toLowerCase() !== 'websocket'
|
|
289
|
+
) {
|
|
290
|
+
return Response.json(
|
|
291
|
+
{
|
|
292
|
+
error: 'InvalidRequest',
|
|
293
|
+
message: 'XRPC subscriptions are only available over WebSocket',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
status: 426,
|
|
297
|
+
headers: {
|
|
298
|
+
Connection: 'Upgrade',
|
|
299
|
+
Upgrade: 'websocket',
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const { response, socket } = upgradeWebSocket(request)
|
|
307
|
+
|
|
308
|
+
socket.addEventListener('message', () => {
|
|
309
|
+
const error = new LexError(
|
|
310
|
+
'InvalidRequest',
|
|
311
|
+
'XRPC subscriptions do not accept messages',
|
|
312
|
+
)
|
|
313
|
+
socket.send(encodeErrorFrame(error))
|
|
314
|
+
socket.close(1008, error.error)
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
socket.addEventListener('open', async () => {
|
|
318
|
+
try {
|
|
319
|
+
const url = new URL(request.url)
|
|
320
|
+
const params = method.parameters.fromURLSearchParams(
|
|
321
|
+
url.searchParams,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const credentials: Credentials = auth
|
|
325
|
+
? await auth({ params, request, connection })
|
|
326
|
+
: (undefined as Credentials)
|
|
327
|
+
|
|
328
|
+
request.signal.throwIfAborted()
|
|
329
|
+
|
|
330
|
+
const iterable = methodHandler({
|
|
331
|
+
credentials,
|
|
332
|
+
params,
|
|
333
|
+
input: undefined as InferMethodInput<Method, Body>,
|
|
334
|
+
request,
|
|
335
|
+
connection,
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
const iterator = iterable[Symbol.asyncIterator]()
|
|
339
|
+
|
|
340
|
+
if (iterator.return) {
|
|
341
|
+
const abort = async () => {
|
|
342
|
+
socket.removeEventListener('error', abort)
|
|
343
|
+
socket.removeEventListener('close', abort)
|
|
344
|
+
try {
|
|
345
|
+
await iterator.return!()
|
|
346
|
+
} catch {
|
|
347
|
+
// Ignore
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
socket.addEventListener('error', abort)
|
|
351
|
+
socket.addEventListener('close', abort)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
while (socket.readyState === 1) {
|
|
355
|
+
const result = await iterator.next()
|
|
356
|
+
if (result.done) break
|
|
357
|
+
|
|
358
|
+
// Should not be needed (socket would emit "close" event)
|
|
359
|
+
request.signal.throwIfAborted()
|
|
360
|
+
|
|
361
|
+
// @TODO add validation of output based on method.output.schema?
|
|
362
|
+
|
|
363
|
+
const data = encodeMessageFrame(method, result.value)
|
|
364
|
+
|
|
365
|
+
socket.send(data)
|
|
366
|
+
|
|
367
|
+
// Apply backpressure by waiting for the buffered data to drain
|
|
368
|
+
// before generating the next message
|
|
369
|
+
await drainWebsocket(socket, request.signal, this.options)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
socket.close(1000)
|
|
373
|
+
} catch (error) {
|
|
374
|
+
// If the socket is still open, send an error frame before closing
|
|
375
|
+
if (socket.readyState === 1) {
|
|
376
|
+
const lexError =
|
|
377
|
+
error instanceof LexError
|
|
378
|
+
? error
|
|
379
|
+
: new LexError('InternalError', 'An internal error occurred')
|
|
380
|
+
|
|
381
|
+
socket.send(encodeErrorFrame(lexError))
|
|
382
|
+
|
|
383
|
+
socket.close(
|
|
384
|
+
// https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1
|
|
385
|
+
error instanceof LexError ? 1008 : 1011,
|
|
386
|
+
lexError.error,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Only report unexpected processing errors
|
|
391
|
+
if (onHandlerError && !isAbortReason(request.signal, error)) {
|
|
392
|
+
await onHandlerError({ error, request, method })
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
return response
|
|
398
|
+
} catch (error) {
|
|
399
|
+
return this.handleError(request, method, error)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private async handleError(
|
|
405
|
+
request: Request,
|
|
406
|
+
method: LexMethod,
|
|
407
|
+
error: unknown,
|
|
408
|
+
) {
|
|
409
|
+
// Only report unexpected processing errors
|
|
410
|
+
const { onHandlerError } = this.options
|
|
411
|
+
if (onHandlerError && !isAbortReason(request.signal, error)) {
|
|
412
|
+
await onHandlerError({ error, request, method })
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (error instanceof LexError) {
|
|
416
|
+
return error.toResponse()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return Response.json(
|
|
420
|
+
{ error: 'InternalError', message: 'An internal error occurred' },
|
|
421
|
+
{ status: 500 },
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
handle: Handler = async (
|
|
426
|
+
request: Request,
|
|
427
|
+
connection?: ConnectionInfo,
|
|
428
|
+
): Promise<Response> => {
|
|
429
|
+
const nsid = extractMethodNsid(request)
|
|
430
|
+
|
|
431
|
+
const handler = (this.handlers as Map<string | null, Handler>).get(nsid)
|
|
432
|
+
if (handler) return handler(request, connection)
|
|
433
|
+
|
|
434
|
+
if (!nsid || !isNsidString(nsid)) {
|
|
435
|
+
return Response.json(
|
|
436
|
+
{
|
|
437
|
+
error: 'InvalidRequest',
|
|
438
|
+
message: 'Invalid XRPC method path',
|
|
439
|
+
},
|
|
440
|
+
{ status: 404 },
|
|
441
|
+
)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return Response.json(
|
|
445
|
+
{
|
|
446
|
+
error: 'MethodNotImplemented',
|
|
447
|
+
message: `XRPC method "${nsid}" not implemented on this server`,
|
|
448
|
+
},
|
|
449
|
+
{ status: 501 },
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function extractMethodNsid(request: Request): string | null {
|
|
455
|
+
const { pathname } = new URL(request.url)
|
|
456
|
+
if (!pathname.startsWith('/xrpc/')) return null
|
|
457
|
+
if (pathname.includes('/', 6)) return null
|
|
458
|
+
// We don't really need to validate the NSID here, the existence of the route
|
|
459
|
+
// (which is looked up based on an NSID) is sufficient.
|
|
460
|
+
return pathname.slice(6)
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function getProcedureInput<M extends Procedure>(
|
|
464
|
+
this: M,
|
|
465
|
+
request: Request,
|
|
466
|
+
): Promise<InferMethodInput<M, Body>> {
|
|
467
|
+
const encodingRaw = request.headers
|
|
468
|
+
.get('content-type')
|
|
469
|
+
?.split(';')[0]
|
|
470
|
+
.trim()
|
|
471
|
+
.toLowerCase()
|
|
472
|
+
|
|
473
|
+
const encoding =
|
|
474
|
+
encodingRaw ||
|
|
475
|
+
// If the caller did not provide a content-type, but the method
|
|
476
|
+
// expects an input, assume binary
|
|
477
|
+
(request.body != null && this.input.encoding != null
|
|
478
|
+
? 'application/octet-stream'
|
|
479
|
+
: undefined)
|
|
480
|
+
|
|
481
|
+
if (!this.input.matchesEncoding(encoding)) {
|
|
482
|
+
throw new LexError('InvalidRequest', `Invalid content-type: ${encoding}`)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (this.input.encoding === 'application/json') {
|
|
486
|
+
// @TODO limit size?
|
|
487
|
+
const body = this.input.schema
|
|
488
|
+
? this.input.schema.parse(lexParse(await request.text()))
|
|
489
|
+
: lexParse(await request.text())
|
|
490
|
+
return { encoding, body } as InferMethodInput<M, Body>
|
|
491
|
+
} else if (this.input.encoding) {
|
|
492
|
+
const body: Body = request
|
|
493
|
+
return { encoding, body } as InferMethodInput<M, Body>
|
|
494
|
+
} else {
|
|
495
|
+
return undefined as InferMethodInput<M, Body>
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function getQueryInput<M extends Query>(
|
|
500
|
+
this: M,
|
|
501
|
+
request: Request,
|
|
502
|
+
): Promise<InferMethodInput<M, Body>> {
|
|
503
|
+
if (
|
|
504
|
+
request.body ||
|
|
505
|
+
request.headers.has('content-type') ||
|
|
506
|
+
request.headers.has('content-length')
|
|
507
|
+
) {
|
|
508
|
+
throw new LexError('InvalidRequest', 'GET requests must not have a body')
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return undefined as InferMethodInput<M, Body>
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Pre-encoded frame header for error frames
|
|
515
|
+
const ERROR_FRAME_HEADER = /*#__PURE__*/ encode({ op: -1 })
|
|
516
|
+
|
|
517
|
+
function encodeErrorFrame(error: LexError): Uint8Array {
|
|
518
|
+
return ui8Concat([ERROR_FRAME_HEADER, encode(error.toJSON())])
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Pre-encoded frame header for message frames with unknown type
|
|
522
|
+
const UNKNOWN_MESSAGE_FRAME_HEADER = /*#__PURE__*/ encode({ op: 1 })
|
|
523
|
+
|
|
524
|
+
function encodeMessageFrame(method: Subscription, value: LexValue): Uint8Array {
|
|
525
|
+
if (isPlainObject(value) && typeof value.$type === 'string') {
|
|
526
|
+
const { $type, ...rest } = value
|
|
527
|
+
return ui8Concat([
|
|
528
|
+
encode({
|
|
529
|
+
op: 1,
|
|
530
|
+
t:
|
|
531
|
+
// If $type starts with `nsid#`, strip the NSID prefix
|
|
532
|
+
$type.charCodeAt(0) !== 0x23 && // '#'
|
|
533
|
+
$type.charCodeAt(method.nsid.length) === 0x23 && // '#'
|
|
534
|
+
$type.startsWith(method.nsid)
|
|
535
|
+
? $type.slice(method.nsid.length)
|
|
536
|
+
: $type,
|
|
537
|
+
}),
|
|
538
|
+
encode(rest),
|
|
539
|
+
])
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return ui8Concat([UNKNOWN_MESSAGE_FRAME_HEADER, encode(value)])
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function isAbortReason(signal: AbortSignal, error: unknown): boolean {
|
|
546
|
+
if (!signal.aborted || signal.reason == null) return false
|
|
547
|
+
return (
|
|
548
|
+
error === signal.reason ||
|
|
549
|
+
(error instanceof Error && error.cause === signal.reason)
|
|
550
|
+
)
|
|
551
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { abortableSleep } from './sleep.js'
|
|
2
|
+
|
|
3
|
+
export async function drainWebsocket(
|
|
4
|
+
socket: WebSocket,
|
|
5
|
+
signal: AbortSignal,
|
|
6
|
+
{
|
|
7
|
+
highWaterMark = 250_000, // 250 KB
|
|
8
|
+
lowWaterMark = 50_000, // 50 KB
|
|
9
|
+
}: {
|
|
10
|
+
highWaterMark?: number
|
|
11
|
+
lowWaterMark?: number
|
|
12
|
+
} = {},
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
if (socket.bufferedAmount > highWaterMark) {
|
|
15
|
+
while (
|
|
16
|
+
socket.readyState === 1 &&
|
|
17
|
+
socket.bufferedAmount !== 0 &&
|
|
18
|
+
socket.bufferedAmount > lowWaterMark
|
|
19
|
+
) {
|
|
20
|
+
await abortableSleep(10, signal)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/lib/sleep.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function abortableSleep(
|
|
2
|
+
ms: number,
|
|
3
|
+
signal: AbortSignal,
|
|
4
|
+
): Promise<void> {
|
|
5
|
+
signal.throwIfAborted()
|
|
6
|
+
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const cleanup = () => {
|
|
9
|
+
signal.removeEventListener('abort', onAbort)
|
|
10
|
+
clearTimeout(timeoutHandle)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const timeoutHandle = setTimeout(() => {
|
|
14
|
+
cleanup()
|
|
15
|
+
resolve()
|
|
16
|
+
}, ms)
|
|
17
|
+
|
|
18
|
+
const onAbort = () => {
|
|
19
|
+
cleanup()
|
|
20
|
+
reject(signal.reason)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
signal.addEventListener('abort', onAbort)
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type WWWAuthenticate = {
|
|
2
|
+
[authScheme in string]?:
|
|
3
|
+
| string // token68
|
|
4
|
+
| { [authParam in string]?: string }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function formatWWWAuthenticateHeader(
|
|
8
|
+
wwwAuthenticate: WWWAuthenticate,
|
|
9
|
+
): string {
|
|
10
|
+
return Object.entries(wwwAuthenticate)
|
|
11
|
+
.map(([authScheme, authParams]) => {
|
|
12
|
+
if (authParams === undefined) return null
|
|
13
|
+
const paramsEnc =
|
|
14
|
+
typeof authParams === 'string'
|
|
15
|
+
? [authParams]
|
|
16
|
+
: Object.entries(authParams)
|
|
17
|
+
.filter(([_, val]) => val != null)
|
|
18
|
+
.map(([name, val]) => `${name}=${JSON.stringify(val)}`)
|
|
19
|
+
const authChallenge = paramsEnc?.length
|
|
20
|
+
? `${authScheme} ${paramsEnc.join(', ')}`
|
|
21
|
+
: authScheme
|
|
22
|
+
return authChallenge
|
|
23
|
+
})
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.join(', ')
|
|
26
|
+
}
|