@furystack/rest-service 10.0.28 → 10.1.0

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.
Files changed (74) hide show
  1. package/README.md +249 -12
  2. package/esm/api-manager.d.ts +1 -3
  3. package/esm/api-manager.d.ts.map +1 -1
  4. package/esm/api-manager.js +4 -6
  5. package/esm/api-manager.js.map +1 -1
  6. package/esm/header-processor.d.ts +39 -0
  7. package/esm/header-processor.d.ts.map +1 -0
  8. package/esm/header-processor.js +113 -0
  9. package/esm/header-processor.js.map +1 -0
  10. package/esm/header-processor.spec.d.ts +2 -0
  11. package/esm/header-processor.spec.d.ts.map +1 -0
  12. package/esm/header-processor.spec.js +420 -0
  13. package/esm/header-processor.spec.js.map +1 -0
  14. package/esm/helpers.d.ts +69 -1
  15. package/esm/helpers.d.ts.map +1 -1
  16. package/esm/helpers.js +70 -1
  17. package/esm/helpers.js.map +1 -1
  18. package/esm/helpers.spec.js +21 -5
  19. package/esm/helpers.spec.js.map +1 -1
  20. package/esm/http-proxy-handler.d.ts +53 -0
  21. package/esm/http-proxy-handler.d.ts.map +1 -0
  22. package/esm/http-proxy-handler.js +179 -0
  23. package/esm/http-proxy-handler.js.map +1 -0
  24. package/esm/http-user-context.d.ts +4 -4
  25. package/esm/http-user-context.d.ts.map +1 -1
  26. package/esm/http-user-context.js +4 -4
  27. package/esm/http-user-context.js.map +1 -1
  28. package/esm/index.d.ts +1 -0
  29. package/esm/index.d.ts.map +1 -1
  30. package/esm/index.js +1 -0
  31. package/esm/index.js.map +1 -1
  32. package/esm/path-processor.d.ts +33 -0
  33. package/esm/path-processor.d.ts.map +1 -0
  34. package/esm/path-processor.js +58 -0
  35. package/esm/path-processor.js.map +1 -0
  36. package/esm/path-processor.spec.d.ts +2 -0
  37. package/esm/path-processor.spec.d.ts.map +1 -0
  38. package/esm/path-processor.spec.js +256 -0
  39. package/esm/path-processor.spec.js.map +1 -0
  40. package/esm/proxy-manager.d.ts +52 -0
  41. package/esm/proxy-manager.d.ts.map +1 -0
  42. package/esm/proxy-manager.js +84 -0
  43. package/esm/proxy-manager.js.map +1 -0
  44. package/esm/proxy-manager.spec.d.ts +2 -0
  45. package/esm/proxy-manager.spec.d.ts.map +1 -0
  46. package/esm/proxy-manager.spec.js +1781 -0
  47. package/esm/proxy-manager.spec.js.map +1 -0
  48. package/esm/server-manager.d.ts +7 -0
  49. package/esm/server-manager.d.ts.map +1 -1
  50. package/esm/server-manager.js +12 -0
  51. package/esm/server-manager.js.map +1 -1
  52. package/esm/static-server-manager.d.ts.map +1 -1
  53. package/esm/static-server-manager.js +5 -7
  54. package/esm/static-server-manager.js.map +1 -1
  55. package/esm/websocket-proxy-handler.d.ts +44 -0
  56. package/esm/websocket-proxy-handler.d.ts.map +1 -0
  57. package/esm/websocket-proxy-handler.js +157 -0
  58. package/esm/websocket-proxy-handler.js.map +1 -0
  59. package/package.json +11 -9
  60. package/src/api-manager.ts +5 -15
  61. package/src/header-processor.spec.ts +514 -0
  62. package/src/header-processor.ts +140 -0
  63. package/src/helpers.spec.ts +23 -5
  64. package/src/helpers.ts +72 -1
  65. package/src/http-proxy-handler.ts +215 -0
  66. package/src/http-user-context.ts +6 -6
  67. package/src/index.ts +1 -0
  68. package/src/path-processor.spec.ts +318 -0
  69. package/src/path-processor.ts +69 -0
  70. package/src/proxy-manager.spec.ts +2094 -0
  71. package/src/proxy-manager.ts +101 -0
  72. package/src/server-manager.ts +19 -0
  73. package/src/static-server-manager.ts +5 -7
  74. package/src/websocket-proxy-handler.ts +204 -0
@@ -0,0 +1,101 @@
1
+ import { Injectable, Injected } from '@furystack/inject'
2
+ import { EventHub, PathHelper } from '@furystack/utils'
3
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
4
+ import type { Duplex } from 'stream'
5
+ import { HttpProxyHandler } from './http-proxy-handler.js'
6
+ import { PathProcessor } from './path-processor.js'
7
+ import { ServerManager } from './server-manager.js'
8
+ import { WebSocketProxyHandler } from './websocket-proxy-handler.js'
9
+
10
+ export interface ProxyOptions {
11
+ sourceBaseUrl: string
12
+ targetBaseUrl: string
13
+ pathRewrite?: (sourcePath: string) => string
14
+ sourceHostName?: string
15
+ sourcePort: number
16
+ headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
17
+ cookies?: (originalCookies: string[]) => string[]
18
+ responseCookies?: (responseCookies: string[]) => string[]
19
+ timeout?: number
20
+ enableWebsockets?: boolean
21
+ }
22
+
23
+ /**
24
+ * Manages HTTP and WebSocket proxy configurations and routing
25
+ */
26
+ @Injectable({ lifetime: 'singleton' })
27
+ export class ProxyManager extends EventHub<{
28
+ onProxyFailed: { from: string; to: string; error: unknown }
29
+ onWebSocketProxyFailed: { from: string; to: string; error: unknown }
30
+ }> {
31
+ @Injected(ServerManager)
32
+ declare private readonly serverManager: ServerManager
33
+
34
+ private readonly pathProcessor = new PathProcessor()
35
+
36
+ /**
37
+ * Creates a function that determines if a request should be handled by this proxy
38
+ */
39
+ public shouldExec =
40
+ (sourceBaseUrl: string) =>
41
+ ({ req }: { req: Pick<IncomingMessage, 'url' | 'method'> }) =>
42
+ req.url ? PathHelper.matchesBaseUrl(req.url, sourceBaseUrl) : false
43
+
44
+ /**
45
+ * Creates an HTTP request handler for the proxy
46
+ */
47
+ private createRequestHandler(options: ProxyOptions) {
48
+ const handler = new HttpProxyHandler({
49
+ sourceBaseUrl: options.sourceBaseUrl,
50
+ targetBaseUrl: options.targetBaseUrl,
51
+ pathRewrite: options.pathRewrite,
52
+ headers: options.headers,
53
+ cookies: options.cookies,
54
+ responseCookies: options.responseCookies,
55
+ timeout: options.timeout,
56
+ onError: (error) => this.emit('onProxyFailed', error),
57
+ })
58
+
59
+ return async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => {
60
+ await handler.handle(req, res)
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Creates a WebSocket upgrade handler for the proxy
66
+ */
67
+ private createUpgradeHandler(options: ProxyOptions) {
68
+ const handler = new WebSocketProxyHandler({
69
+ sourceBaseUrl: options.sourceBaseUrl,
70
+ targetBaseUrl: options.targetBaseUrl,
71
+ pathRewrite: options.pathRewrite,
72
+ headers: options.headers,
73
+ timeout: options.timeout,
74
+ onError: (error) => this.emit('onWebSocketProxyFailed', error),
75
+ })
76
+
77
+ return async ({ req, socket, head }: { req: IncomingMessage; socket: Duplex; head: Buffer }) => {
78
+ await handler.handle(req, socket, head)
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Adds a new proxy configuration
84
+ * @throws Error if targetBaseUrl is invalid or uses non-HTTP/HTTPS protocol
85
+ */
86
+ public async addProxy(options: ProxyOptions): Promise<void> {
87
+ // Validate targetBaseUrl format
88
+ const url = this.pathProcessor.validateUrl(options.targetBaseUrl, 'targetBaseUrl')
89
+ this.pathProcessor.validateHttpProtocol(url)
90
+
91
+ const server = await this.serverManager.getOrCreate({ hostName: options.sourceHostName, port: options.sourcePort })
92
+
93
+ const api = {
94
+ shouldExec: this.shouldExec(options.sourceBaseUrl),
95
+ onRequest: this.createRequestHandler(options),
96
+ ...(options.enableWebsockets ? { onUpgrade: this.createUpgradeHandler(options) } : {}),
97
+ }
98
+
99
+ server.apis.push(api)
100
+ }
101
+ }
@@ -4,6 +4,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'http'
4
4
  import { createServer } from 'http'
5
5
  import type { Socket } from 'net'
6
6
  import { Lock } from 'semaphore-async-await'
7
+ import type { Duplex } from 'stream'
7
8
 
8
9
  export interface ServerOptions {
9
10
  hostName?: string
@@ -15,9 +16,16 @@ export interface OnRequest {
15
16
  res: ServerResponse
16
17
  }
17
18
 
19
+ export interface OnUpgrade {
20
+ req: IncomingMessage
21
+ socket: Duplex
22
+ head: Buffer
23
+ }
24
+
18
25
  export interface ServerApi {
19
26
  shouldExec: (options: OnRequest) => boolean
20
27
  onRequest: (options: OnRequest) => Promise<void>
28
+ onUpgrade?: (options: OnUpgrade) => Promise<void>
21
29
  }
22
30
 
23
31
  export interface ServerRecord {
@@ -79,6 +87,17 @@ export class ServerManager
79
87
  res.destroy()
80
88
  }
81
89
  })
90
+ server.on('upgrade', (req, socket, head) => {
91
+ const apiMatch = apis.find((api) => api.shouldExec({ req, res: {} as ServerResponse }))
92
+ if (apiMatch?.onUpgrade) {
93
+ apiMatch.onUpgrade({ req, socket, head }).catch((error) => {
94
+ this.emit('onRequestFailed', [error, req, {} as ServerResponse])
95
+ socket.destroy()
96
+ })
97
+ } else {
98
+ socket.destroy()
99
+ }
100
+ })
82
101
  server.on('connection', this.onConnection)
83
102
  server.on('listening', () => resolve())
84
103
  server.on('error', (err) => reject(err))
@@ -1,9 +1,10 @@
1
1
  import { Injectable, Injected } from '@furystack/inject'
2
+ import { PathHelper } from '@furystack/utils'
2
3
  import { createReadStream } from 'fs'
3
4
  import { access, stat } from 'fs/promises'
4
5
  import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
5
- import { getMimeForFile } from './mime-types.js'
6
6
  import { join, normalize, sep } from 'path'
7
+ import { getMimeForFile } from './mime-types.js'
7
8
  import { ServerManager } from './server-manager.js'
8
9
 
9
10
  export interface StaticServerOptions {
@@ -47,11 +48,7 @@ export class StaticServerManager {
47
48
  public shouldExec =
48
49
  (baseUrl: string) =>
49
50
  ({ req }: { req: Pick<IncomingMessage, 'url' | 'method'> }) =>
50
- req.url &&
51
- req.method?.toUpperCase() === 'GET' &&
52
- (req.url === baseUrl || req.url.startsWith(baseUrl[baseUrl.length - 1] === '/' ? baseUrl : `${baseUrl}/`))
53
- ? true
54
- : false
51
+ req.url && req.method?.toUpperCase() === 'GET' && PathHelper.matchesBaseUrl(req.url, baseUrl) ? true : false
55
52
 
56
53
  private onRequest = ({
57
54
  path,
@@ -65,7 +62,8 @@ export class StaticServerManager {
65
62
  headers?: OutgoingHttpHeaders
66
63
  }) => {
67
64
  return async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => {
68
- const filePath = (req.url as string).substring(baseUrl.length - 1).replaceAll('/', sep)
65
+ const extractedPath = PathHelper.extractPath(req.url as string, baseUrl)
66
+ const filePath = (extractedPath || '/').replaceAll('/', sep)
69
67
  const fullPath = normalize(join(path, filePath))
70
68
 
71
69
  try {
@@ -0,0 +1,204 @@
1
+ import type { IncomingMessage, OutgoingHttpHeaders } from 'http'
2
+ import { request as httpRequest } from 'http'
3
+ import { request as httpsRequest } from 'https'
4
+ import type { Duplex } from 'stream'
5
+ import { HeaderProcessor } from './header-processor.js'
6
+ import { PathProcessor } from './path-processor.js'
7
+
8
+ export interface WebSocketProxyOptions {
9
+ sourceBaseUrl: string
10
+ targetBaseUrl: string
11
+ pathRewrite?: (sourcePath: string) => string
12
+ headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
13
+ timeout?: number
14
+ onError?: (error: { from: string; to: string; error: unknown }) => void
15
+ }
16
+
17
+ /**
18
+ * Handles WebSocket upgrade proxying with bidirectional streaming
19
+ */
20
+ export class WebSocketProxyHandler {
21
+ private readonly headerProcessor = new HeaderProcessor()
22
+ private readonly pathProcessor = new PathProcessor()
23
+
24
+ constructor(private readonly options: WebSocketProxyOptions) {}
25
+
26
+ /**
27
+ * Builds WebSocket-specific upgrade headers
28
+ */
29
+ private buildUpgradeHeaders(
30
+ req: IncomingMessage,
31
+ targetHost: string,
32
+ finalHeaders: OutgoingHttpHeaders,
33
+ ): Record<string, string> {
34
+ const upgradeHeaders = this.headerProcessor.convertHeadersToRecord(finalHeaders)
35
+
36
+ // Add required WebSocket upgrade headers
37
+ upgradeHeaders.Host = targetHost
38
+ upgradeHeaders.Connection = 'Upgrade'
39
+ upgradeHeaders.Upgrade = 'websocket'
40
+ upgradeHeaders['Sec-WebSocket-Version'] = req.headers['sec-websocket-version'] as string
41
+ upgradeHeaders['Sec-WebSocket-Key'] = req.headers['sec-websocket-key'] as string
42
+
43
+ // Add optional WebSocket headers
44
+ if (req.headers['sec-websocket-protocol']) {
45
+ upgradeHeaders['Sec-WebSocket-Protocol'] = req.headers['sec-websocket-protocol']
46
+ }
47
+ if (req.headers['sec-websocket-extensions']) {
48
+ upgradeHeaders['Sec-WebSocket-Extensions'] = req.headers['sec-websocket-extensions']
49
+ }
50
+
51
+ return upgradeHeaders
52
+ }
53
+
54
+ /**
55
+ * Writes the upgrade response headers to the client socket
56
+ */
57
+ private writeUpgradeResponse(socket: Duplex, proxyRes: IncomingMessage): void {
58
+ const responseHeaders = [`HTTP/1.1 ${proxyRes.statusCode} ${proxyRes.statusMessage}`]
59
+ for (const [key, value] of Object.entries(proxyRes.headers)) {
60
+ if (Array.isArray(value)) {
61
+ value.forEach((v) => responseHeaders.push(`${key}: ${v}`))
62
+ } else {
63
+ responseHeaders.push(`${key}: ${value}`)
64
+ }
65
+ }
66
+ responseHeaders.push('', '')
67
+ socket.write(responseHeaders.join('\r\n'))
68
+ }
69
+
70
+ /**
71
+ * Sets up bidirectional piping between client and target sockets with error handling
72
+ */
73
+ private setupBidirectionalPipe(
74
+ clientSocket: Duplex,
75
+ proxySocket: Duplex,
76
+ clientHead: Buffer,
77
+ proxyHead: Buffer,
78
+ ): void {
79
+ // Write initial data
80
+ if (proxyHead.length > 0) {
81
+ clientSocket.write(proxyHead)
82
+ }
83
+ if (clientHead.length > 0) {
84
+ proxySocket.write(clientHead)
85
+ }
86
+
87
+ // Bidirectional pipe
88
+ proxySocket.pipe(clientSocket)
89
+ clientSocket.pipe(proxySocket)
90
+
91
+ // Handle errors and cleanup
92
+ const cleanup = () => {
93
+ proxySocket.destroy()
94
+ clientSocket.destroy()
95
+ }
96
+
97
+ const handleError = (error: Error) => {
98
+ if (this.options.onError) {
99
+ this.options.onError({
100
+ from: this.options.sourceBaseUrl,
101
+ to: this.options.targetBaseUrl,
102
+ error,
103
+ })
104
+ }
105
+ cleanup()
106
+ }
107
+
108
+ proxySocket.on('error', handleError)
109
+ clientSocket.on('error', handleError)
110
+
111
+ proxySocket.on('close', () => {
112
+ clientSocket.destroy()
113
+ })
114
+
115
+ clientSocket.on('close', () => {
116
+ proxySocket.destroy()
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Handles WebSocket upgrade errors
122
+ */
123
+ private handleUpgradeError(error: unknown, socket: Duplex): void {
124
+ if (this.options.onError) {
125
+ this.options.onError({
126
+ from: this.options.sourceBaseUrl,
127
+ to: this.options.targetBaseUrl,
128
+ error,
129
+ })
130
+ }
131
+ socket.destroy()
132
+ }
133
+
134
+ /**
135
+ * Main handler for WebSocket upgrade requests
136
+ */
137
+ public async handle(req: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> {
138
+ try {
139
+ // Build target URL
140
+ const targetUrl = this.pathProcessor.processUrl(
141
+ req.url as string,
142
+ this.options.sourceBaseUrl,
143
+ this.options.targetBaseUrl,
144
+ this.options.pathRewrite,
145
+ )
146
+
147
+ const parsedTargetUrl = new URL(targetUrl)
148
+
149
+ // Process headers
150
+ const originalHeaders: OutgoingHttpHeaders = {}
151
+ for (const [key, value] of Object.entries(req.headers)) {
152
+ originalHeaders[key] = Array.isArray(value) ? value.join(', ') : value
153
+ }
154
+
155
+ const filteredHeaders = this.headerProcessor.filterHeaders(originalHeaders)
156
+ const finalHeaders = this.options.headers ? this.options.headers(filteredHeaders) : filteredHeaders
157
+
158
+ // Build WebSocket upgrade headers
159
+ const upgradeHeaders = this.buildUpgradeHeaders(req, parsedTargetUrl.host, finalHeaders)
160
+
161
+ // Set up timeout
162
+ const timeoutMs = this.options.timeout ?? 30000
163
+ const timeoutId = setTimeout(() => {
164
+ socket.destroy()
165
+ }, timeoutMs)
166
+
167
+ // Create upgrade request to target server
168
+ const requestFn = parsedTargetUrl.protocol === 'https:' ? httpsRequest : httpRequest
169
+ const proxyReq = requestFn({
170
+ host: parsedTargetUrl.hostname,
171
+ port: parsedTargetUrl.port || (parsedTargetUrl.protocol === 'https:' ? 443 : 80),
172
+ path: parsedTargetUrl.pathname + parsedTargetUrl.search,
173
+ method: 'GET',
174
+ headers: upgradeHeaders,
175
+ })
176
+
177
+ proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
178
+ clearTimeout(timeoutId)
179
+
180
+ // Write the upgrade response to the client socket
181
+ this.writeUpgradeResponse(socket, proxyRes)
182
+
183
+ // Set up bidirectional piping
184
+ this.setupBidirectionalPipe(socket, proxySocket, head, proxyHead)
185
+ })
186
+
187
+ proxyReq.on('error', (error) => {
188
+ clearTimeout(timeoutId)
189
+ this.handleUpgradeError(error, socket)
190
+ })
191
+
192
+ proxyReq.on('timeout', () => {
193
+ clearTimeout(timeoutId)
194
+ const timeoutError = new Error('WebSocket upgrade timeout')
195
+ this.handleUpgradeError(timeoutError, socket)
196
+ proxyReq.destroy()
197
+ })
198
+
199
+ proxyReq.end()
200
+ } catch (error) {
201
+ this.handleUpgradeError(error, socket)
202
+ }
203
+ }
204
+ }