@nmtjs/http-transport 0.15.0-beta.3 → 0.15.0-beta.4

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/package.json CHANGED
@@ -9,20 +9,20 @@
9
9
  },
10
10
  "devDependencies": {
11
11
  "uWebSockets.js": "github:uNetworking/uWebSockets.js#v20.56.0",
12
- "@nmtjs/client": "0.15.0-beta.3",
13
- "@nmtjs/common": "0.15.0-beta.3",
14
- "@nmtjs/gateway": "0.15.0-beta.3",
15
- "@nmtjs/protocol": "0.15.0-beta.3"
12
+ "@nmtjs/client": "0.15.0-beta.4",
13
+ "@nmtjs/common": "0.15.0-beta.4",
14
+ "@nmtjs/gateway": "0.15.0-beta.4",
15
+ "@nmtjs/protocol": "0.15.0-beta.4"
16
16
  },
17
17
  "peerDependencies": {
18
18
  "@types/bun": "^1.3.0",
19
19
  "@types/deno": "^2.3.0",
20
20
  "@types/node": "^24",
21
21
  "uWebSockets.js": "^20.56.0",
22
- "@nmtjs/common": "0.15.0-beta.3",
23
- "@nmtjs/core": "0.15.0-beta.3",
24
- "@nmtjs/gateway": "0.15.0-beta.3",
25
- "@nmtjs/protocol": "0.15.0-beta.3"
22
+ "@nmtjs/common": "0.15.0-beta.4",
23
+ "@nmtjs/core": "0.15.0-beta.4",
24
+ "@nmtjs/gateway": "0.15.0-beta.4",
25
+ "@nmtjs/protocol": "0.15.0-beta.4"
26
26
  },
27
27
  "peerDependenciesMeta": {
28
28
  "@types/bun": {
@@ -45,10 +45,11 @@
45
45
  },
46
46
  "files": [
47
47
  "dist",
48
+ "src",
48
49
  "LICENSE.md",
49
50
  "README.md"
50
51
  ],
51
- "version": "0.15.0-beta.3",
52
+ "version": "0.15.0-beta.4",
52
53
  "scripts": {
53
54
  "clean-build": "rm -rf ./dist",
54
55
  "build": "tsc --declaration --sourcemap",
@@ -0,0 +1,152 @@
1
+ import { createMetadataKey } from '@nmtjs/core'
2
+ import { ErrorCode } from '@nmtjs/protocol'
3
+
4
+ export enum HttpStatus {
5
+ Continue = 100,
6
+ SwitchingProtocols = 101,
7
+ Processing = 102,
8
+ EarlyHints = 103,
9
+ OK = 200,
10
+ Created = 201,
11
+ Accepted = 202,
12
+ NonAuthoritativeInformation = 203,
13
+ NoContent = 204,
14
+ ResetContent = 205,
15
+ PartialContent = 206,
16
+ MultiStatus = 207,
17
+ AlreadyReported = 208,
18
+ IMUsed = 226,
19
+ MultipleChoices = 300,
20
+ MovedPermanently = 301,
21
+ Found = 302,
22
+ SeeOther = 303,
23
+ NotModified = 304,
24
+ UseProxy = 305,
25
+ TemporaryRedirect = 307,
26
+ PermanentRedirect = 308,
27
+ BadRequest = 400,
28
+ Unauthorized = 401,
29
+ PaymentRequired = 402,
30
+ Forbidden = 403,
31
+ NotFound = 404,
32
+ MethodNotAllowed = 405,
33
+ NotAcceptable = 406,
34
+ ProxyAuthenticationRequired = 407,
35
+ RequestTimeout = 408,
36
+ Conflict = 409,
37
+ Gone = 410,
38
+ LengthRequired = 411,
39
+ PreconditionFailed = 412,
40
+ PayloadTooLarge = 413,
41
+ URITooLong = 414,
42
+ UnsupportedMediaType = 415,
43
+ RangeNotSatisfiable = 416,
44
+ ExpectationFailed = 417,
45
+ ImATeapot = 418,
46
+ MisdirectedRequest = 421,
47
+ UnprocessableEntity = 422,
48
+ Locked = 423,
49
+ FailedDependency = 424,
50
+ TooEarly = 425,
51
+ UpgradeRequired = 426,
52
+ PreconditionRequired = 428,
53
+ TooManyRequests = 429,
54
+ RequestHeaderFieldsTooLarge = 431,
55
+ UnavailableForLegalReasons = 451,
56
+ InternalServerError = 500,
57
+ NotImplemented = 501,
58
+ BadGateway = 502,
59
+ ServiceUnavailable = 503,
60
+ GatewayTimeout = 504,
61
+ HTTPVersionNotSupported = 505,
62
+ VariantAlsoNegotiates = 506,
63
+ InsufficientStorage = 507,
64
+ LoopDetected = 508,
65
+ NotExtended = 510,
66
+ NetworkAuthenticationRequired = 511,
67
+ }
68
+
69
+ export const HttpStatusText: Record<HttpStatus, string> = {
70
+ [HttpStatus.Continue]: 'Continue',
71
+ [HttpStatus.SwitchingProtocols]: 'Switching Protocols',
72
+ [HttpStatus.Processing]: 'Processing',
73
+ [HttpStatus.EarlyHints]: 'Early Hints',
74
+ [HttpStatus.OK]: 'OK',
75
+ [HttpStatus.Created]: 'Created',
76
+ [HttpStatus.Accepted]: 'Accepted',
77
+ [HttpStatus.NonAuthoritativeInformation]: 'Non-Authoritative Information',
78
+ [HttpStatus.NoContent]: 'No Content',
79
+ [HttpStatus.ResetContent]: 'Reset Content',
80
+ [HttpStatus.PartialContent]: 'Partial Content',
81
+ [HttpStatus.MultiStatus]: 'Multi-Status',
82
+ [HttpStatus.AlreadyReported]: 'Already Reported',
83
+ [HttpStatus.IMUsed]: 'IM Used',
84
+ [HttpStatus.MultipleChoices]: 'Multiple Choices',
85
+ [HttpStatus.MovedPermanently]: 'Moved Permanently',
86
+ [HttpStatus.Found]: 'Found',
87
+ [HttpStatus.SeeOther]: 'See Other',
88
+ [HttpStatus.NotModified]: 'Not Modified',
89
+ [HttpStatus.UseProxy]: 'Use Proxy',
90
+ [HttpStatus.TemporaryRedirect]: 'Temporary Redirect',
91
+ [HttpStatus.PermanentRedirect]: 'Permanent Redirect',
92
+ [HttpStatus.BadRequest]: 'Bad Request',
93
+ [HttpStatus.Unauthorized]: 'Unauthorized',
94
+ [HttpStatus.PaymentRequired]: 'Payment Required',
95
+ [HttpStatus.Forbidden]: 'Forbidden',
96
+ [HttpStatus.NotFound]: 'Not Found',
97
+ [HttpStatus.MethodNotAllowed]: 'Method Not Allowed',
98
+ [HttpStatus.NotAcceptable]: 'Not Acceptable',
99
+ [HttpStatus.ProxyAuthenticationRequired]: 'Proxy Authentication Required',
100
+ [HttpStatus.RequestTimeout]: 'Request Timeout',
101
+ [HttpStatus.Conflict]: 'Conflict',
102
+ [HttpStatus.Gone]: 'Gone',
103
+ [HttpStatus.LengthRequired]: 'Length Required',
104
+ [HttpStatus.PreconditionFailed]: 'Precondition Failed',
105
+ [HttpStatus.PayloadTooLarge]: 'Payload Too Large',
106
+ [HttpStatus.URITooLong]: 'URI Too Long',
107
+ [HttpStatus.UnsupportedMediaType]: 'Unsupported Media Type',
108
+ [HttpStatus.RangeNotSatisfiable]: 'Range Not Satisfiable',
109
+ [HttpStatus.ExpectationFailed]: 'Expectation Failed',
110
+ [HttpStatus.ImATeapot]: "I'm a Teapot",
111
+ [HttpStatus.MisdirectedRequest]: 'Misdirected Request',
112
+ [HttpStatus.UnprocessableEntity]: 'Unprocessable Entity',
113
+ [HttpStatus.Locked]: 'Locked',
114
+ [HttpStatus.FailedDependency]: 'Failed Dependency',
115
+ [HttpStatus.TooEarly]: 'Too Early',
116
+ [HttpStatus.UpgradeRequired]: 'Upgrade Required',
117
+ [HttpStatus.PreconditionRequired]: 'Precondition Required',
118
+ [HttpStatus.TooManyRequests]: 'Too Many Requests',
119
+ [HttpStatus.RequestHeaderFieldsTooLarge]: 'Request Header Fields Too Large',
120
+ [HttpStatus.UnavailableForLegalReasons]: 'Unavailable For Legal Reasons',
121
+ [HttpStatus.InternalServerError]: 'Internal Server Error',
122
+ [HttpStatus.NotImplemented]: 'Not Implemented',
123
+ [HttpStatus.BadGateway]: 'Bad Gateway',
124
+ [HttpStatus.ServiceUnavailable]: 'Service Unavailable',
125
+ [HttpStatus.GatewayTimeout]: 'Gateway Timeout',
126
+ [HttpStatus.HTTPVersionNotSupported]: 'HTTP Version Not Supported',
127
+ [HttpStatus.VariantAlsoNegotiates]: 'Variant Also Negotiates',
128
+ [HttpStatus.InsufficientStorage]: 'Insufficient Storage',
129
+ [HttpStatus.LoopDetected]: 'Loop Detected',
130
+ [HttpStatus.NotExtended]: 'Not Extended',
131
+ [HttpStatus.NetworkAuthenticationRequired]: 'Network Authentication Required',
132
+ }
133
+
134
+ export const HttpCodeMap = {
135
+ [ErrorCode.ValidationError]: HttpStatus.BadRequest,
136
+ [ErrorCode.BadRequest]: HttpStatus.BadRequest,
137
+ [ErrorCode.NotFound]: HttpStatus.NotFound,
138
+ [ErrorCode.Forbidden]: HttpStatus.Forbidden,
139
+ [ErrorCode.Unauthorized]: HttpStatus.Unauthorized,
140
+ [ErrorCode.InternalServerError]: HttpStatus.InternalServerError,
141
+ [ErrorCode.NotAcceptable]: HttpStatus.NotAcceptable,
142
+ [ErrorCode.RequestTimeout]: HttpStatus.RequestTimeout,
143
+ [ErrorCode.GatewayTimeout]: HttpStatus.GatewayTimeout,
144
+ [ErrorCode.ServiceUnavailable]: HttpStatus.ServiceUnavailable,
145
+ [ErrorCode.ClientRequestError]: HttpStatus.BadRequest,
146
+ [ErrorCode.ConnectionError]: HttpStatus.NotAcceptable,
147
+ }
148
+
149
+ export const AllowedHttpMethod =
150
+ createMetadataKey<Array<'get' | 'post' | 'put' | 'delete' | 'patch'>>(
151
+ 'http:method',
152
+ )
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './injectables.ts'
2
+ export * from './server.ts'
3
+ export * from './types.ts'
4
+ export * from './utils.ts'
@@ -0,0 +1,11 @@
1
+ import { createLazyInjectable, Scope } from '@nmtjs/core'
2
+ import { connectionData as connectionDataInjectable } from '@nmtjs/gateway'
3
+
4
+ import type { HttpTransportServerRequest } from './types.ts'
5
+
6
+ export const connectionData =
7
+ connectionDataInjectable.$withType<HttpTransportServerRequest>()
8
+
9
+ export const httpResponseHeaders = createLazyInjectable<Headers, Scope.Call>(
10
+ Scope.Call,
11
+ )
@@ -0,0 +1,91 @@
1
+ import type { Transport } from '@nmtjs/gateway'
2
+ import type { ConnectionType } from '@nmtjs/protocol'
3
+ import { ProxyableTransportType } from '@nmtjs/gateway'
4
+
5
+ import type {
6
+ HttpAdapterParams,
7
+ HttpAdapterServer,
8
+ HttpTransportOptions,
9
+ } from '../types.ts'
10
+ import * as injectables from '../injectables.ts'
11
+ import { createHTTPTransportWorker } from '../server.ts'
12
+ import {
13
+ InternalServerErrorHttpResponse,
14
+ NotFoundHttpResponse,
15
+ StatusResponse,
16
+ } from '../utils.ts'
17
+
18
+ function adapterFactory(params: HttpAdapterParams<'bun'>): HttpAdapterServer {
19
+ let server: Bun.Server<undefined> | null = null
20
+
21
+ function createServer() {
22
+ return globalThis.Bun.serve({
23
+ ...params.runtime,
24
+ unix: params.listen.unix as string,
25
+ port: params.listen.port ?? 0,
26
+ hostname: params.listen.hostname,
27
+ reusePort: params.listen.reusePort,
28
+ tls: params.tls
29
+ ? {
30
+ cert: params.tls.cert,
31
+ key: params.tls.key,
32
+ passphrase: params.tls.passphrase,
33
+ }
34
+ : undefined,
35
+ // @ts-expect-error
36
+ routes: {
37
+ ...params.runtime?.routes,
38
+ '/healthy': { GET: StatusResponse },
39
+ },
40
+ async fetch(request, server) {
41
+ const url = new URL(request.url)
42
+ try {
43
+ if (request.headers.get('upgrade') === 'websocket')
44
+ return NotFoundHttpResponse()
45
+ const { body, headers, method } = request
46
+ return await params.fetchHandler(
47
+ { url, method, headers },
48
+ body,
49
+ request.signal,
50
+ )
51
+ } catch (err) {
52
+ // TODO: proper logging
53
+ console.error(err)
54
+ // params.logger.error({ err }, 'Error in fetch handler')
55
+ return InternalServerErrorHttpResponse()
56
+ }
57
+ },
58
+ })
59
+ }
60
+
61
+ return {
62
+ runtime: {
63
+ get bun() {
64
+ return server!
65
+ },
66
+ },
67
+ start: async () => {
68
+ server = createServer()
69
+ return server!.url.href
70
+ },
71
+ stop: async () => {
72
+ if (server) {
73
+ await server.stop()
74
+ server = null
75
+ }
76
+ },
77
+ }
78
+ }
79
+
80
+ export const HttpTransport: Transport<
81
+ ConnectionType.Unidirectional,
82
+ HttpTransportOptions<'deno'>,
83
+ typeof injectables,
84
+ ProxyableTransportType.HTTP
85
+ > = {
86
+ proxyable: ProxyableTransportType.HTTP,
87
+ injectables,
88
+ factory(options) {
89
+ return createHTTPTransportWorker(adapterFactory, options)
90
+ },
91
+ }
@@ -0,0 +1,139 @@
1
+ import type { Transport } from '@nmtjs/gateway'
2
+ import type { ConnectionType } from '@nmtjs/protocol'
3
+ import { ProxyableTransportType } from '@nmtjs/gateway'
4
+
5
+ import type {
6
+ DenoServer,
7
+ HttpAdapterParams,
8
+ HttpAdapterServer,
9
+ HttpTransportOptions,
10
+ } from '../types.ts'
11
+ import * as injectables from '../injectables.ts'
12
+ import { createHTTPTransportWorker } from '../server.ts'
13
+ import {
14
+ InternalServerErrorHttpResponse,
15
+ NotFoundHttpResponse,
16
+ StatusResponse,
17
+ } from '../utils.ts'
18
+
19
+ interface DenoNetAddr {
20
+ transport: 'tcp' | 'udp'
21
+ hostname: string
22
+ port: number
23
+ }
24
+
25
+ interface DenoUnixAddr {
26
+ transport: 'unix' | 'unixpacket'
27
+ path: string
28
+ }
29
+
30
+ interface DenoVsockAddr {
31
+ transport: 'vsock'
32
+ cid: number
33
+ port: number
34
+ }
35
+
36
+ type DenoAddr = DenoNetAddr | DenoUnixAddr | DenoVsockAddr
37
+
38
+ function adapterFactory(params: HttpAdapterParams<'deno'>): HttpAdapterServer {
39
+ let server: DenoServer | null = null
40
+
41
+ function createServer() {
42
+ const listenOptions = params.listen.unix
43
+ ? { path: params.listen.unix }
44
+ : {
45
+ port: params.listen.port,
46
+ hostname: params.listen.hostname,
47
+ reusePort: params.listen.reusePort,
48
+ }
49
+ const options = {
50
+ ...listenOptions,
51
+ tls: params.tls
52
+ ? {
53
+ cert: params.tls.cert,
54
+ key: params.tls.key,
55
+ passphrase: params.tls.passphrase,
56
+ }
57
+ : undefined,
58
+ }
59
+
60
+ return new Promise<{ server: DenoServer; addr: DenoAddr }>((resolve) => {
61
+ const server = globalThis.Deno.serve({
62
+ ...params.runtime,
63
+ ...options,
64
+ handler: async (request: Request, info: any) => {
65
+ const url = new URL(request.url)
66
+ if (url.pathname === '/healthy') {
67
+ return StatusResponse()
68
+ }
69
+ try {
70
+ if (request.headers.get('upgrade') === 'websocket') {
71
+ return NotFoundHttpResponse()
72
+ }
73
+ const { headers, method, body } = request
74
+ return await params.fetchHandler(
75
+ { url, method, headers },
76
+ body,
77
+ request.signal,
78
+ )
79
+ } catch (err) {
80
+ // TODO: proper logging
81
+ console.error(err)
82
+ // params.logger.error({ err }, 'Error in fetch handler')
83
+ return InternalServerErrorHttpResponse()
84
+ }
85
+ },
86
+ onListen(addr: DenoAddr) {
87
+ setTimeout(() => {
88
+ resolve({ server, addr })
89
+ }, 1)
90
+ },
91
+ })
92
+ })
93
+ }
94
+
95
+ return {
96
+ runtime: {
97
+ get deno() {
98
+ return server!
99
+ },
100
+ },
101
+ start: async () => {
102
+ const { server: _server, addr } = await createServer()
103
+ server = _server
104
+ switch (addr.transport) {
105
+ case 'unix':
106
+ case 'unixpacket':
107
+ return `unix://${addr.path}`
108
+ case 'tcp':
109
+ case 'udp': {
110
+ const proto = params.tls ? 'https' : 'http'
111
+ return `${proto}://${addr.hostname}:${addr.port}`
112
+ }
113
+ case 'vsock':
114
+ return `vsock://${addr.cid}:${addr.port}`
115
+ default:
116
+ throw new Error(`Unsupported address transport`)
117
+ }
118
+ },
119
+ stop: async () => {
120
+ if (server) {
121
+ await server.shutdown()
122
+ server = null
123
+ }
124
+ },
125
+ }
126
+ }
127
+
128
+ export const HttpTransport: Transport<
129
+ ConnectionType.Unidirectional,
130
+ HttpTransportOptions<'deno'>,
131
+ typeof injectables,
132
+ ProxyableTransportType.HTTP
133
+ > = {
134
+ proxyable: ProxyableTransportType.HTTP,
135
+ injectables,
136
+ factory(options) {
137
+ return createHTTPTransportWorker(adapterFactory, options)
138
+ },
139
+ }
@@ -0,0 +1,157 @@
1
+ import {} from 'node:dns'
2
+ import { setTimeout } from 'node:timers/promises'
3
+
4
+ import type { Transport } from '@nmtjs/gateway'
5
+ import type { ConnectionType } from '@nmtjs/protocol'
6
+ import { ProxyableTransportType } from '@nmtjs/gateway'
7
+
8
+ import type {
9
+ HttpAdapterParams,
10
+ HttpAdapterServer,
11
+ HttpTransportOptions,
12
+ } from '../types.ts'
13
+ import * as injectables from '../injectables.ts'
14
+ import { createHTTPTransportWorker } from '../server.ts'
15
+ import {
16
+ InternalServerErrorHttpResponse,
17
+ NotFoundHttpResponse,
18
+ StatusResponse,
19
+ } from '../utils.ts'
20
+
21
+ import { App, SSLApp, us_socket_local_port } from 'uWebSockets.js'
22
+
23
+ function adapterFactory(params: HttpAdapterParams<'node'>): HttpAdapterServer {
24
+ const server = params.tls
25
+ ? SSLApp({
26
+ passphrase: params.tls.passphrase,
27
+ key_file_name: params.tls.key,
28
+ cert_file_name: params.tls.cert,
29
+ })
30
+ : App()
31
+
32
+ server
33
+ .get('/healthy', async (res) => {
34
+ res.onAborted(() => {})
35
+ const response = StatusResponse()
36
+ res.cork(async () => {
37
+ res
38
+ .writeStatus(`${response.status} ${response.statusText}`)
39
+ .end(await response.arrayBuffer())
40
+ })
41
+ })
42
+ .any('/*', async (res, req) => {
43
+ const controller = new AbortController()
44
+ res.onAborted(() => {
45
+ res.aborted = true
46
+ controller.abort()
47
+ })
48
+
49
+ let response = NotFoundHttpResponse()
50
+
51
+ const headers = new Headers()
52
+ const method = req.getMethod()
53
+ req.forEach((k, v) => headers.append(k, v))
54
+
55
+ const host = headers.get('host') || 'localhost'
56
+ const proto =
57
+ headers.get('x-forwarded-proto') || params.tls ? 'https' : 'http'
58
+ const url = new URL(req.getUrl(), `${proto}://${host}`)
59
+
60
+ try {
61
+ const body = new ReadableStream({
62
+ start(controller) {
63
+ res.onData((chunk, isLast) => {
64
+ if (chunk) {
65
+ const copy = Buffer.allocUnsafe(chunk.byteLength)
66
+ copy.set(new Uint8Array(chunk))
67
+ controller.enqueue(copy)
68
+ }
69
+ if (isLast) controller.close()
70
+ })
71
+ res.onAborted(() => controller.error())
72
+ },
73
+ })
74
+ response = await params.fetchHandler(
75
+ { url, method, headers },
76
+ body,
77
+ controller.signal,
78
+ )
79
+ } catch (err) {
80
+ // TODO: proper logging
81
+ console.error(err)
82
+ // params.logger.error({ err }, 'Error in fetch handler')
83
+ response = InternalServerErrorHttpResponse()
84
+ }
85
+ if (res.aborted) return undefined
86
+ else {
87
+ res.cork(() => {
88
+ if (res.aborted) return undefined
89
+ res.writeStatus(
90
+ `${response.status.toString()} ${response.statusText}`,
91
+ )
92
+ response.headers.forEach((v, k) => res.writeHeader(k, v))
93
+ })
94
+ if (response.body) {
95
+ try {
96
+ const reader = response.body.getReader()
97
+ let chunk = await reader.read()
98
+ do {
99
+ if (res.aborted) break
100
+ if (chunk.value) res.cork(() => res.write(chunk.value!))
101
+ chunk = await reader.read()
102
+ } while (!chunk.done)
103
+ if (!res.aborted) res.cork(() => res.end())
104
+ } catch {
105
+ if (!res.aborted) res.cork(() => res.close())
106
+ }
107
+ } else {
108
+ if (!res.aborted) res.cork(() => res.end())
109
+ }
110
+ }
111
+ })
112
+
113
+ return {
114
+ runtime: { node: server },
115
+ start: () =>
116
+ new Promise<string>((resolve, reject) => {
117
+ if (params.listen.unix) {
118
+ server.listen_unix((socket) => {
119
+ if (socket) {
120
+ resolve('unix://' + params.listen.unix)
121
+ } else {
122
+ reject(new Error('Failed to start WebSockets server'))
123
+ }
124
+ }, params.listen.unix)
125
+ } else if (typeof params.listen.port === 'number') {
126
+ const proto = params.tls ? 'https' : 'http'
127
+ const hostname = params.listen.hostname || '127.0.0.1'
128
+
129
+ server.listen(hostname, params.listen.port, (socket) => {
130
+ if (socket) {
131
+ resolve(`${proto}://${hostname}:${us_socket_local_port(socket)}`)
132
+ } else {
133
+ reject(new Error('Failed to start WebSockets server'))
134
+ }
135
+ })
136
+ } else {
137
+ reject(new Error('Invalid listen parameters'))
138
+ }
139
+ }),
140
+ stop: () => {
141
+ server.close()
142
+ },
143
+ }
144
+ }
145
+
146
+ export const HttpTransport: Transport<
147
+ ConnectionType.Unidirectional,
148
+ HttpTransportOptions<'node'>,
149
+ typeof injectables,
150
+ ProxyableTransportType.HTTP
151
+ > = {
152
+ proxyable: ProxyableTransportType.HTTP,
153
+ injectables,
154
+ factory(options) {
155
+ return createHTTPTransportWorker(adapterFactory, options)
156
+ },
157
+ }
package/src/server.ts ADDED
@@ -0,0 +1,365 @@
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'],
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 HttpTransportCorsCustomParams
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,
138
+ contentType: isBlob ? '*/*' : 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
+ // Handle blob responses
193
+ if (result instanceof ProtocolBlob) {
194
+ const { source, metadata } = result
195
+ const { type } = metadata
196
+
197
+ responseHeaders.set(NEEMATA_BLOB_HEADER, 'true')
198
+ responseHeaders.set('Content-Type', type)
199
+ if (metadata.size) {
200
+ responseHeaders.set('Content-Length', metadata.size.toString())
201
+ }
202
+
203
+ // Convert source to ReadableStream
204
+ let stream: ReadableStream
205
+
206
+ if (source instanceof ReadableStream) {
207
+ stream = source
208
+ } else if (source instanceof Readable || source instanceof Duplex) {
209
+ stream = Readable.toWeb(source) as unknown as ReadableStream
210
+ } else {
211
+ throw new Error('Invalid stream source')
212
+ }
213
+
214
+ return new Response(stream, {
215
+ status: HttpStatus.OK,
216
+ statusText: HttpStatusText[HttpStatus.OK],
217
+ headers: responseHeaders,
218
+ })
219
+ } else if (isAsyncIterable(result)) {
220
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
221
+ responseHeaders.set('Transfer-Encoding', 'chunked')
222
+ const stream = new ReadableStream({
223
+ async start(controller) {
224
+ try {
225
+ for await (const chunk of result) {
226
+ const encoded = connection.encoder.encode(chunk)
227
+ const base64 = Buffer.from(
228
+ encoded.buffer,
229
+ encoded.byteOffset,
230
+ encoded.byteLength,
231
+ ).toString('base64')
232
+ controller.enqueue(`data: ${base64}\n\n`)
233
+ }
234
+ controller.close()
235
+ } catch (error) {
236
+ if (isAbortError(error)) controller.close()
237
+ else controller.error(error)
238
+ }
239
+ },
240
+ })
241
+ return new Response(stream, {
242
+ status: HttpStatus.OK,
243
+ statusText: HttpStatusText[HttpStatus.OK],
244
+ headers: responseHeaders,
245
+ })
246
+ } else {
247
+ // Handle regular responses
248
+ const buffer = connection.encoder.encode(result)
249
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
250
+
251
+ // @ts-expect-error
252
+ return new Response(buffer, {
253
+ status: HttpStatus.OK,
254
+ statusText: HttpStatusText[HttpStatus.OK],
255
+ headers: responseHeaders,
256
+ })
257
+ }
258
+ } catch (error) {
259
+ console.error(error)
260
+ if (error instanceof UnsupportedFormatError) {
261
+ const status =
262
+ error instanceof UnsupportedContentTypeError
263
+ ? HttpStatus.UnsupportedMediaType
264
+ : HttpStatus.NotAcceptable
265
+ const text = HttpStatusText[status]
266
+
267
+ return new Response(text, {
268
+ status,
269
+ statusText: text,
270
+ headers: responseHeaders,
271
+ })
272
+ }
273
+
274
+ if (error instanceof ProtocolError) {
275
+ const status =
276
+ error.code in HttpCodeMap
277
+ ? HttpCodeMap[error.code]
278
+ : HttpStatus.InternalServerError
279
+ const text = HttpStatusText[status]
280
+ const payload = connection.encoder.encode(error)
281
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
282
+
283
+ // @ts-expect-error
284
+ return new Response(payload, {
285
+ status,
286
+ statusText: text,
287
+ headers: responseHeaders,
288
+ })
289
+ }
290
+
291
+ // Unknown error
292
+ // this.logError(error, 'Unknown error while processing HTTP request')
293
+ console.error(error)
294
+
295
+ const payload = connection.encoder.encode(
296
+ new ProtocolError(
297
+ ErrorCode.InternalServerError,
298
+ 'Internal Server Error',
299
+ ),
300
+ )
301
+ responseHeaders.set('Content-Type', connection.encoder.contentType)
302
+
303
+ // @ts-expect-error
304
+ return new Response(payload, {
305
+ status: HttpStatus.InternalServerError,
306
+ statusText: HttpStatusText[HttpStatus.InternalServerError],
307
+ headers: responseHeaders,
308
+ })
309
+ }
310
+ }
311
+
312
+ private applyCors(
313
+ origin: string,
314
+ request: HttpTransportServerRequest,
315
+ headers: Headers,
316
+ ) {
317
+ if (!this.#corsOptions) return
318
+
319
+ let params: HttpTransportCorsCustomParams | null = null
320
+
321
+ if (this.options.cors === true) {
322
+ params = { ...DEFAULT_CORS_PARAMS }
323
+ } else if (
324
+ Array.isArray(this.options.cors) &&
325
+ this.options.cors.includes(origin)
326
+ ) {
327
+ params = { ...DEFAULT_CORS_PARAMS }
328
+ } else if (typeof this.options.cors === 'function') {
329
+ const result = this.options.cors(origin, request)
330
+ if (typeof result === 'boolean') {
331
+ if (result) {
332
+ params = { ...DEFAULT_CORS_PARAMS }
333
+ }
334
+ } else if (typeof result === 'object') {
335
+ params = { ...DEFAULT_CORS_PARAMS }
336
+ for (const key in DEFAULT_CORS_PARAMS) {
337
+ params[key] = result[key]
338
+ }
339
+ }
340
+ }
341
+
342
+ if (params === null) return
343
+
344
+ headers.set(CORS_HEADERS_MAP.origin, origin)
345
+
346
+ for (const key in params) {
347
+ const header = CORS_HEADERS_MAP[key]
348
+ if (header) {
349
+ let value = params[key]
350
+ if (Array.isArray(value)) value = value.filter(Boolean).join(', ')
351
+ if (value) headers.set(header, value)
352
+ }
353
+ }
354
+ }
355
+
356
+ private createServer() {
357
+ // const hooks = this.createWsHooks()
358
+ const opts: HttpAdapterParams = {
359
+ ...this.options,
360
+ // logger: this.logger.child({ $lable: 'WsServer' }),
361
+ fetchHandler: this.httpHandler.bind(this),
362
+ }
363
+ return this.adapterFactory(opts)
364
+ }
365
+ }
package/src/types.ts ADDED
@@ -0,0 +1,102 @@
1
+ import type { Async, 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
+ allowMethods?: string[]
20
+ allowHeaders?: string[]
21
+ allowCredentials?: string
22
+ maxAge?: string
23
+ exposeHeaders?: string[]
24
+ requestHeaders?: string[]
25
+ requestMethod?: string
26
+ }
27
+
28
+ export type HttpTransportCorsOptions =
29
+ | true
30
+ | string[]
31
+ | HttpTransportCorsCustomParams
32
+ | ((
33
+ origin: string,
34
+ request: HttpTransportServerRequest,
35
+ ) => boolean | HttpTransportCorsCustomParams)
36
+
37
+ export type HttpTransportListenOptions = OneOf<
38
+ [{ port: number; hostname?: string; reusePort?: boolean }, { unix: string }]
39
+ >
40
+
41
+ export type HttpTransportRuntimeBun = Partial<
42
+ Pick<
43
+ import('bun').Serve.Options<undefined>,
44
+ 'development' | 'id' | 'maxRequestBodySize' | 'idleTimeout' | 'ipv6Only'
45
+ > &
46
+ import('bun').Serve.Routes<any, any>
47
+ >
48
+
49
+ export type HttpTransportRuntimeNode = {}
50
+
51
+ export type HttpTransportRuntimeDeno = {}
52
+
53
+ export type HttpTransportRuntimes = {
54
+ bun: HttpTransportRuntimeBun
55
+ node: HttpTransportRuntimeNode
56
+ deno: HttpTransportRuntimeDeno
57
+ }
58
+
59
+ export type HttpTransportTlsOptions = {
60
+ /**
61
+ * File path or inlined TLS certificate in PEM format (required).
62
+ */
63
+ cert?: string
64
+ /**
65
+ * File path or inlined TLS private key in PEM format (required).
66
+ */
67
+ key?: string
68
+ /**
69
+ * Passphrase for the private key (optional).
70
+ */
71
+ passphrase?: string
72
+ }
73
+
74
+ export type HttpAdapterParams<
75
+ R extends keyof HttpTransportRuntimes = keyof HttpTransportRuntimes,
76
+ > = {
77
+ listen: HttpTransportListenOptions
78
+ fetchHandler: (
79
+ request: HttpTransportServerRequest,
80
+ body: ReadableStream | null,
81
+ signal: AbortSignal,
82
+ ) => Async<Response>
83
+ cors?: HttpTransportCorsOptions
84
+ tls?: HttpTransportTlsOptions
85
+ runtime?: HttpTransportRuntimes[R]
86
+ }
87
+
88
+ export type DenoServer = ReturnType<typeof globalThis.Deno.serve>
89
+
90
+ export interface HttpAdapterServer {
91
+ runtime: {
92
+ bun?: import('bun').Server<undefined>
93
+ node?: import('uWebSockets.js').TemplatedApp
94
+ deno?: DenoServer
95
+ }
96
+ stop: () => Async<any>
97
+ start: () => Async<string>
98
+ }
99
+
100
+ export type HttpAdapterServerFactory<
101
+ R extends keyof HttpTransportRuntimes = keyof HttpTransportRuntimes,
102
+ > = (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 })