@nmtjs/http-transport 0.15.0-beta.2 → 0.15.0-beta.20

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/src/server.ts ADDED
@@ -0,0 +1,384 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { Duplex, Readable } from 'node:stream'
3
+
4
+ import type {
5
+ GatewayApiCallOptions,
6
+ TransportWorker,
7
+ TransportWorkerParams,
8
+ } from '@nmtjs/gateway'
9
+ import { anyAbortSignal, isAbortError, isAsyncIterable } from '@nmtjs/common'
10
+ import { provide } from '@nmtjs/core'
11
+ import {
12
+ ConnectionType,
13
+ ErrorCode,
14
+ ProtocolBlob,
15
+ ProtocolVersion,
16
+ } from '@nmtjs/protocol'
17
+ import {
18
+ ProtocolClientStream,
19
+ ProtocolError,
20
+ UnsupportedContentTypeError,
21
+ UnsupportedFormatError,
22
+ } from '@nmtjs/protocol/server'
23
+
24
+ import type {
25
+ HttpAdapterParams,
26
+ HttpAdapterServer,
27
+ HttpAdapterServerFactory,
28
+ HttpTransportCorsCustomParams,
29
+ HttpTransportCorsOptions,
30
+ HttpTransportOptions,
31
+ HttpTransportServerRequest,
32
+ } from './types.ts'
33
+ import {
34
+ AllowedHttpMethod,
35
+ HttpCodeMap,
36
+ HttpStatus,
37
+ HttpStatusText,
38
+ } from './constants.ts'
39
+ import * as injections from './injectables.ts'
40
+
41
+ const NEEMATA_BLOB_HEADER = 'X-Neemata-Blob'
42
+ const DEFAULT_ALLOWED_METHODS = Object.freeze(['post']) as ('get' | 'post')[]
43
+ const DEFAULT_CORS_PARAMS = Object.freeze({
44
+ allowCredentials: 'true',
45
+ allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
46
+ allowHeaders: [
47
+ 'Content-Type',
48
+ 'Content-Disposition',
49
+ 'Content-Length',
50
+ 'Accept',
51
+ 'Transfer-Encoding',
52
+ ],
53
+ maxAge: undefined,
54
+ requestMethod: undefined,
55
+ exposeHeaders: [],
56
+ requestHeaders: [],
57
+ }) satisfies Omit<HttpTransportCorsCustomParams, 'origin'>
58
+ const CORS_HEADERS_MAP: Record<
59
+ keyof HttpTransportCorsCustomParams | 'origin',
60
+ string
61
+ > = {
62
+ origin: 'Access-Control-Allow-Origin',
63
+ allowMethods: 'Access-Control-Allow-Methods',
64
+ allowHeaders: 'Access-Control-Allow-Headers',
65
+ allowCredentials: 'Access-Control-Allow-Credentials',
66
+ maxAge: 'Access-Control-Max-Age',
67
+ exposeHeaders: 'Access-Control-Expose-Headers',
68
+ requestHeaders: 'Access-Control-Request-Headers',
69
+ requestMethod: 'Access-Control-Request-Method',
70
+ }
71
+
72
+ export function createHTTPTransportWorker(
73
+ adapterFactory: HttpAdapterServerFactory<any>,
74
+ options: HttpTransportOptions,
75
+ ): TransportWorker<ConnectionType.Unidirectional> {
76
+ return new HttpTransportServer(adapterFactory, options)
77
+ }
78
+
79
+ export class HttpTransportServer
80
+ implements TransportWorker<ConnectionType.Unidirectional>
81
+ {
82
+ #server: HttpAdapterServer
83
+ #corsOptions?:
84
+ | null
85
+ | true
86
+ | string[]
87
+ | HttpTransportCorsOptions
88
+ | ((origin: string) => boolean | HttpTransportCorsOptions)
89
+
90
+ params!: TransportWorkerParams<ConnectionType.Unidirectional>
91
+
92
+ constructor(
93
+ protected readonly adapterFactory: HttpAdapterServerFactory<any>,
94
+ protected readonly options: HttpTransportOptions,
95
+ ) {
96
+ this.#server = this.createServer()
97
+ this.#corsOptions = this.options.cors
98
+ }
99
+
100
+ async start(hooks: TransportWorkerParams<ConnectionType.Unidirectional>) {
101
+ this.params = hooks
102
+ return await this.#server.start()
103
+ }
104
+
105
+ async stop() {
106
+ await this.#server.stop()
107
+ }
108
+
109
+ async httpHandler(
110
+ request: HttpTransportServerRequest,
111
+ body: ReadableStream | null,
112
+ requestSignal: AbortSignal,
113
+ ): Promise<Response> {
114
+ const url = new URL(request.url)
115
+ const procedure = url.pathname.slice(1) // remove leading '/'
116
+ const method = request.method.toLowerCase()
117
+ const origin = request.headers.get('origin')
118
+ const responseHeaders = new Headers()
119
+ if (origin) this.applyCors(origin, request, responseHeaders)
120
+
121
+ // Handle preflight requests
122
+ if (method === 'options') {
123
+ return new Response(null, {
124
+ status: HttpStatus.OK,
125
+ headers: responseHeaders,
126
+ })
127
+ }
128
+
129
+ const controller = new AbortController()
130
+ const signal = anyAbortSignal(requestSignal, controller.signal)
131
+ const canHaveBody = method !== 'get'
132
+ const isBlob = request.headers.get(NEEMATA_BLOB_HEADER) === 'true'
133
+ const contentType = request.headers.get('content-type')
134
+ const accept = request.headers.get('accept') || '*/*'
135
+
136
+ await using connection = await this.params.onConnect({
137
+ accept: canHaveBody ? accept : '*/*',
138
+ contentType: isBlob || !contentType ? '*/*' : contentType,
139
+ data: request,
140
+ protocolVersion: ProtocolVersion.v1,
141
+ type: ConnectionType.Unidirectional,
142
+ })
143
+
144
+ try {
145
+ // Parse request body if present
146
+ let payload: any
147
+ if (canHaveBody && body) {
148
+ const bodyStream = Readable.fromWeb(body as any)
149
+ const cannotDecode =
150
+ !contentType || !this.params.formats.supportsDecoder(contentType)
151
+ if (isBlob || cannotDecode) {
152
+ const type = contentType || 'application/octet-stream'
153
+ const contentLength = request.headers.get('content-length')
154
+ const size = contentLength
155
+ ? Number.parseInt(contentLength)
156
+ : undefined
157
+ payload = new ProtocolClientStream(-1, { size, type })
158
+ bodyStream.pipe(payload)
159
+ } else {
160
+ const buffer = Buffer.concat(await bodyStream.toArray())
161
+ if (buffer.byteLength > 0) {
162
+ payload = connection.decoder.decode(buffer)
163
+ }
164
+ }
165
+ } else {
166
+ const querystring = url.searchParams.get('payload')
167
+ if (querystring) {
168
+ payload = JSON.parse(querystring)
169
+ }
170
+ }
171
+
172
+ const metadata: GatewayApiCallOptions['metadata'] = (metadata) => {
173
+ const allowHttpMethod =
174
+ metadata.get(AllowedHttpMethod) ?? DEFAULT_ALLOWED_METHODS
175
+ if (!allowHttpMethod.includes(method as any)) {
176
+ throw new ProtocolError(ErrorCode.NotFound)
177
+ }
178
+ }
179
+
180
+ const result = await this.params.onRpc(
181
+ connection,
182
+ {
183
+ callId: 0, // since the connection is closed after the call, only one call exists per connection
184
+ payload,
185
+ procedure,
186
+ metadata,
187
+ },
188
+ signal,
189
+ provide(injections.httpResponseHeaders, responseHeaders),
190
+ )
191
+
192
+ if (result instanceof Response) {
193
+ const { status, statusText, headers, body } = result
194
+ headers.forEach((value, key) => {
195
+ responseHeaders.set(key, value)
196
+ })
197
+
198
+ return new Response(body, {
199
+ status,
200
+ statusText,
201
+ headers: responseHeaders,
202
+ })
203
+ } else if (result instanceof ProtocolBlob) {
204
+ const { source, metadata } = result
205
+ const { type } = metadata
206
+
207
+ responseHeaders.set(NEEMATA_BLOB_HEADER, 'true')
208
+ responseHeaders.set('Content-Type', type)
209
+ if (metadata.size) {
210
+ responseHeaders.set('Content-Length', metadata.size.toString())
211
+ }
212
+
213
+ // Convert source to ReadableStream
214
+ let stream: ReadableStream
215
+
216
+ if (source instanceof ReadableStream) {
217
+ stream = source
218
+ } else if (source instanceof Readable || source instanceof Duplex) {
219
+ stream = Readable.toWeb(source) as unknown as ReadableStream
220
+ } else {
221
+ throw new Error('Invalid stream source')
222
+ }
223
+
224
+ return new Response(stream, {
225
+ status: HttpStatus.OK,
226
+ statusText: HttpStatusText[HttpStatus.OK],
227
+ headers: responseHeaders,
228
+ })
229
+ } else if (isAsyncIterable(result)) {
230
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
231
+ responseHeaders.set('Transfer-Encoding', 'chunked')
232
+ const stream = new ReadableStream({
233
+ async start(controller) {
234
+ try {
235
+ for await (const chunk of result) {
236
+ const encoded = connection.encoder.encode(chunk)
237
+ const base64 = Buffer.from(
238
+ encoded.buffer,
239
+ encoded.byteOffset,
240
+ encoded.byteLength,
241
+ ).toString('base64')
242
+ controller.enqueue(`data: ${base64}\n\n`)
243
+ }
244
+ controller.close()
245
+ } catch (error) {
246
+ if (isAbortError(error)) controller.close()
247
+ else controller.error(error)
248
+ }
249
+ },
250
+ })
251
+ return new Response(stream, {
252
+ status: HttpStatus.OK,
253
+ statusText: HttpStatusText[HttpStatus.OK],
254
+ headers: responseHeaders,
255
+ })
256
+ } else {
257
+ // Handle regular responses
258
+ const buffer = connection.encoder.encode(result)
259
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
260
+
261
+ // @ts-expect-error
262
+ return new Response(buffer, {
263
+ status: HttpStatus.OK,
264
+ statusText: HttpStatusText[HttpStatus.OK],
265
+ headers: responseHeaders,
266
+ })
267
+ }
268
+ } catch (error) {
269
+ console.error(error)
270
+ if (error instanceof UnsupportedFormatError) {
271
+ const status =
272
+ error instanceof UnsupportedContentTypeError
273
+ ? HttpStatus.UnsupportedMediaType
274
+ : HttpStatus.NotAcceptable
275
+ const text = HttpStatusText[status]
276
+
277
+ return new Response(text, {
278
+ status,
279
+ statusText: text,
280
+ headers: responseHeaders,
281
+ })
282
+ }
283
+
284
+ if (error instanceof ProtocolError) {
285
+ const status =
286
+ error.code in HttpCodeMap
287
+ ? HttpCodeMap[error.code]
288
+ : HttpStatus.InternalServerError
289
+ const text = HttpStatusText[status]
290
+ const payload = connection.encoder.encode(error)
291
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
292
+
293
+ // @ts-expect-error
294
+ return new Response(payload, {
295
+ status,
296
+ statusText: text,
297
+ headers: responseHeaders,
298
+ })
299
+ }
300
+
301
+ // Unknown error
302
+ // this.logError(error, 'Unknown error while processing HTTP request')
303
+ console.error(error)
304
+
305
+ const payload = connection.encoder.encode(
306
+ new ProtocolError(
307
+ ErrorCode.InternalServerError,
308
+ 'Internal Server Error',
309
+ ),
310
+ )
311
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
312
+
313
+ // @ts-expect-error
314
+ return new Response(payload, {
315
+ status: HttpStatus.InternalServerError,
316
+ statusText: HttpStatusText[HttpStatus.InternalServerError],
317
+ headers: responseHeaders,
318
+ })
319
+ }
320
+ }
321
+
322
+ private applyCors(
323
+ origin: string,
324
+ request: HttpTransportServerRequest,
325
+ headers: Headers,
326
+ ) {
327
+ if (!this.#corsOptions) return
328
+
329
+ let params: Omit<HttpTransportCorsCustomParams, 'origin'> | null = null
330
+
331
+ if (this.#corsOptions === true) {
332
+ params = { ...DEFAULT_CORS_PARAMS }
333
+ } else if (Array.isArray(this.#corsOptions)) {
334
+ if (this.#corsOptions.includes(origin)) {
335
+ params = { ...DEFAULT_CORS_PARAMS }
336
+ }
337
+ } else if (typeof this.#corsOptions === 'object') {
338
+ if (
339
+ this.#corsOptions.origin === true ||
340
+ this.#corsOptions.origin.includes(origin)
341
+ ) {
342
+ params = { ...DEFAULT_CORS_PARAMS }
343
+ for (const key in DEFAULT_CORS_PARAMS) {
344
+ params[key] = this.#corsOptions[key]
345
+ }
346
+ }
347
+ } else if (typeof this.#corsOptions === 'function') {
348
+ const result = this.#corsOptions(origin, request)
349
+ if (typeof result === 'boolean') {
350
+ if (result) {
351
+ params = { ...DEFAULT_CORS_PARAMS }
352
+ }
353
+ } else if (typeof result === 'object') {
354
+ params = { ...DEFAULT_CORS_PARAMS }
355
+ for (const key in DEFAULT_CORS_PARAMS) {
356
+ params[key] = result[key]
357
+ }
358
+ }
359
+ }
360
+
361
+ if (params === null) return
362
+
363
+ headers.set(CORS_HEADERS_MAP.origin, origin)
364
+
365
+ for (const key in params) {
366
+ const header = CORS_HEADERS_MAP[key]
367
+ if (header) {
368
+ let value = params[key]
369
+ if (Array.isArray(value)) value = value.filter(Boolean).join(', ')
370
+ if (value) headers.set(header, value)
371
+ }
372
+ }
373
+ }
374
+
375
+ private createServer() {
376
+ // const hooks = this.createWsHooks()
377
+ const opts: HttpAdapterParams = {
378
+ ...this.options,
379
+ // logger: this.logger.child({ $lable: 'WsServer' }),
380
+ fetchHandler: this.httpHandler.bind(this),
381
+ }
382
+ return this.adapterFactory(opts)
383
+ }
384
+ }
package/src/types.ts ADDED
@@ -0,0 +1,103 @@
1
+ import type { MaybePromise, OneOf } from '@nmtjs/common'
2
+
3
+ export type HttpTransportServerRequest = {
4
+ url: URL
5
+ method: string
6
+ headers: Headers
7
+ }
8
+
9
+ export type HttpTransportOptions<
10
+ R extends keyof HttpTransportRuntimes = keyof HttpTransportRuntimes,
11
+ > = {
12
+ listen: HttpTransportListenOptions
13
+ cors?: HttpTransportCorsOptions
14
+ tls?: HttpTransportTlsOptions
15
+ runtime?: HttpTransportRuntimes[R]
16
+ }
17
+
18
+ export type HttpTransportCorsCustomParams = {
19
+ origin: true | string[]
20
+ allowMethods?: string[]
21
+ allowHeaders?: string[]
22
+ allowCredentials?: string
23
+ maxAge?: string
24
+ exposeHeaders?: string[]
25
+ requestHeaders?: string[]
26
+ requestMethod?: string
27
+ }
28
+
29
+ export type HttpTransportCorsOptions =
30
+ | true
31
+ | string[]
32
+ | HttpTransportCorsCustomParams
33
+ | ((
34
+ origin: string,
35
+ request: HttpTransportServerRequest,
36
+ ) => boolean | HttpTransportCorsCustomParams)
37
+
38
+ export type HttpTransportListenOptions = OneOf<
39
+ [{ port: number; hostname?: string; reusePort?: boolean }, { unix: string }]
40
+ >
41
+
42
+ export type HttpTransportRuntimeBun = Partial<
43
+ Pick<
44
+ import('bun').Serve.Options<undefined>,
45
+ 'development' | 'id' | 'maxRequestBodySize' | 'idleTimeout' | 'ipv6Only'
46
+ > &
47
+ import('bun').Serve.Routes<any, any>
48
+ >
49
+
50
+ export type HttpTransportRuntimeNode = {}
51
+
52
+ export type HttpTransportRuntimeDeno = {}
53
+
54
+ export type HttpTransportRuntimes = {
55
+ bun: HttpTransportRuntimeBun
56
+ node: HttpTransportRuntimeNode
57
+ deno: HttpTransportRuntimeDeno
58
+ }
59
+
60
+ export type HttpTransportTlsOptions = {
61
+ /**
62
+ * File path or inlined TLS certificate in PEM format (required).
63
+ */
64
+ cert?: string
65
+ /**
66
+ * File path or inlined TLS private key in PEM format (required).
67
+ */
68
+ key?: string
69
+ /**
70
+ * Passphrase for the private key (optional).
71
+ */
72
+ passphrase?: string
73
+ }
74
+
75
+ export type HttpAdapterParams<
76
+ R extends keyof HttpTransportRuntimes = keyof HttpTransportRuntimes,
77
+ > = {
78
+ listen: HttpTransportListenOptions
79
+ fetchHandler: (
80
+ request: HttpTransportServerRequest,
81
+ body: ReadableStream | null,
82
+ signal: AbortSignal,
83
+ ) => MaybePromise<Response>
84
+ cors?: HttpTransportCorsOptions
85
+ tls?: HttpTransportTlsOptions
86
+ runtime?: HttpTransportRuntimes[R]
87
+ }
88
+
89
+ export type DenoServer = ReturnType<typeof globalThis.Deno.serve>
90
+
91
+ export interface HttpAdapterServer {
92
+ runtime: {
93
+ bun?: import('bun').Server<undefined>
94
+ node?: import('uWebSockets.js').TemplatedApp
95
+ deno?: DenoServer
96
+ }
97
+ stop: () => MaybePromise<any>
98
+ start: () => MaybePromise<string>
99
+ }
100
+
101
+ export type HttpAdapterServerFactory<
102
+ R extends keyof HttpTransportRuntimes = keyof HttpTransportRuntimes,
103
+ > = (params: HttpAdapterParams<R>) => HttpAdapterServer
package/src/utils.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { ErrorCode } from '@nmtjs/protocol'
2
+ import { ProtocolError } from '@nmtjs/protocol/server'
3
+
4
+ export const InternalError = (message = 'Internal Server Error') =>
5
+ new ProtocolError(ErrorCode.InternalServerError, message)
6
+
7
+ export const NotFoundError = (message = 'Not Found') =>
8
+ new ProtocolError(ErrorCode.NotFound, message)
9
+
10
+ export const ForbiddenError = (message = 'Forbidden') =>
11
+ new ProtocolError(ErrorCode.Forbidden, message)
12
+
13
+ export const RequestTimeoutError = (message = 'Request Timeout') =>
14
+ new ProtocolError(ErrorCode.RequestTimeout, message)
15
+
16
+ export const NotFoundHttpResponse = () =>
17
+ new Response('Not Found', {
18
+ status: 404,
19
+ headers: { 'Content-Type': 'text/plain' },
20
+ })
21
+
22
+ export const InternalServerErrorHttpResponse = () =>
23
+ new Response('Internal Server Error', {
24
+ status: 500,
25
+ headers: { 'Content-Type': 'text/plain' },
26
+ })
27
+
28
+ export const StatusResponse = () => new Response('OK', { status: 200 })