@superhero/http-server 4.0.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.
- package/LICENCE +21 -0
- package/README.md +451 -0
- package/config.json +18 -0
- package/index.js +424 -0
- package/index.test.js +464 -0
- package/middleware/upstream/header/accept.js +52 -0
- package/middleware/upstream/header/content-type/application/json.js +29 -0
- package/middleware/upstream/header/content-type.js +45 -0
- package/middleware/upstream/method.js +38 -0
- package/package.json +42 -0
- package/view.js +285 -0
package/view.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { Transform } from 'stream'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A view model is a data model specifically designed for the view layer to
|
|
5
|
+
* manage the data that is presented to the client.
|
|
6
|
+
*
|
|
7
|
+
* @memberof @superhero/http-server
|
|
8
|
+
*/
|
|
9
|
+
export default class View
|
|
10
|
+
{
|
|
11
|
+
#stream
|
|
12
|
+
#downstream
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The constructor method is designed to limit access to the view model properties
|
|
16
|
+
* to prevent unpredicted behaviour if the view model is used incorrectly.
|
|
17
|
+
*/
|
|
18
|
+
constructor(session)
|
|
19
|
+
{
|
|
20
|
+
const downstream = session.downstream
|
|
21
|
+
this.#downstream = downstream
|
|
22
|
+
|
|
23
|
+
const headers = new Proxy({},
|
|
24
|
+
{
|
|
25
|
+
get : (target, prop) => target[prop] ?? downstream.getHeader(prop),
|
|
26
|
+
set : (_, prop, val) => downstream.setHeader(prop, val) || val,
|
|
27
|
+
has : (_, prop) => downstream.hasHeader(prop),
|
|
28
|
+
deleteProperty : (_, prop) => downstream.removeHeader(prop),
|
|
29
|
+
ownKeys : () => downstream.getHeaderNames()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
Object.defineProperties(headers,
|
|
33
|
+
{
|
|
34
|
+
addTrailers : { value:downstream.addTrailers .bind(downstream) },
|
|
35
|
+
appendHeader : { value:downstream.appendHeader .bind(downstream) },
|
|
36
|
+
flushHeaders : { value:downstream.flushHeaders .bind(downstream) },
|
|
37
|
+
getHeader : { value:downstream.getHeader .bind(downstream) },
|
|
38
|
+
getHeaderNames : { value:downstream.getHeaderNames .bind(downstream) },
|
|
39
|
+
getHeaders : { value:downstream.getHeaders .bind(downstream) },
|
|
40
|
+
hasHeader : { value:downstream.hasHeader .bind(downstream) },
|
|
41
|
+
removeHeader : { value:downstream.removeHeader .bind(downstream) },
|
|
42
|
+
setHeader : { value:downstream.setHeader .bind(downstream) },
|
|
43
|
+
writeEarlyHints : { value:downstream.writeEarlyHints.bind(downstream) },
|
|
44
|
+
writeHead : { value:downstream.writeHead .bind(downstream) },
|
|
45
|
+
headersSent : { get:() => downstream.headersSent }
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
Object.defineProperties(this,
|
|
49
|
+
{
|
|
50
|
+
// The body property is an object that represents the response body.
|
|
51
|
+
body : { enumerable: true, value: {} },
|
|
52
|
+
// The stream property is a transform stream in object mode that by default encodes objects
|
|
53
|
+
// as stringified JSON data records according to HTML5 standard Server-Sent Events (SSE).
|
|
54
|
+
stream : { enumerable: true, configurable: true, get: () => this.#lazyloadStream },
|
|
55
|
+
// The headers property is an object that represents the response headers.
|
|
56
|
+
headers : { enumerable: true, value: headers },
|
|
57
|
+
// The session property has a reference to this view object, not enumerable because
|
|
58
|
+
// it's a circular reference.
|
|
59
|
+
session : { value: session },
|
|
60
|
+
// The status property is an integer representing the status code of the HTTP response.
|
|
61
|
+
status : { enumerable : true,
|
|
62
|
+
get : () => downstream.statusCode,
|
|
63
|
+
set : (status) => downstream.statusCode = status }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// Prevent the view model from being missused accidentally by providing a proxy that throws
|
|
67
|
+
// an error if a property is accessed that is not already defined.
|
|
68
|
+
return new Proxy(this,
|
|
69
|
+
{
|
|
70
|
+
get: (_, property) =>
|
|
71
|
+
{
|
|
72
|
+
if(this[property] instanceof Function)
|
|
73
|
+
{
|
|
74
|
+
return this[property].bind(this)
|
|
75
|
+
}
|
|
76
|
+
else if(property in this)
|
|
77
|
+
{
|
|
78
|
+
return this[property]
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
{
|
|
82
|
+
const error = new ReferenceError(`Reading an invalid view model property: "${property}"`)
|
|
83
|
+
error.code = 'E_HTTP_SERVER_VIEW_MODEL_PROPERTY_NOT_READABLE'
|
|
84
|
+
error.cause = `Valid properties: ${Object.keys(this).map((prop) => `"${prop}"`).join(', ')}`
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
set: (_, property, value) =>
|
|
89
|
+
{
|
|
90
|
+
const descriptor = Object.getOwnPropertyDescriptor(this, property)
|
|
91
|
+
|
|
92
|
+
if(descriptor?.writable
|
|
93
|
+
|| descriptor?.set)
|
|
94
|
+
{
|
|
95
|
+
return this[property] = value
|
|
96
|
+
}
|
|
97
|
+
else
|
|
98
|
+
{
|
|
99
|
+
const error = new Error(`View model property "${property}" is not writable`)
|
|
100
|
+
error.code = 'E_HTTP_SERVER_VIEW_MODEL_PROPERTY_NOT_WRITABLE'
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The stream property is a transform stream in object mode that by
|
|
109
|
+
* default encodes objects as stringified JSON data records
|
|
110
|
+
* according to HTML5 standard Server-Sent Events (SSE).
|
|
111
|
+
*
|
|
112
|
+
* @returns {node:stream.Transform} @lazyload the channel transform stream.
|
|
113
|
+
* @see https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
114
|
+
*/
|
|
115
|
+
get #lazyloadStream()
|
|
116
|
+
{
|
|
117
|
+
if(this.#stream)
|
|
118
|
+
{
|
|
119
|
+
return this.#stream
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
this.#stream = new Transform(
|
|
123
|
+
{
|
|
124
|
+
objectMode : true,
|
|
125
|
+
transform : (obj, _, callback) =>
|
|
126
|
+
{
|
|
127
|
+
try
|
|
128
|
+
{
|
|
129
|
+
const stringifed = JSON.stringify(obj)
|
|
130
|
+
callback(null, `data: ${stringifed}\n\n`)
|
|
131
|
+
}
|
|
132
|
+
catch(reason)
|
|
133
|
+
{
|
|
134
|
+
const error = new TypeError(`Failed to encode object using JSON.stringify`)
|
|
135
|
+
error.code = 'E_HTTP_SERVER_VIEW_MODEL_CHANNEL_TRANSFORM_FAILED'
|
|
136
|
+
error.cause = reason
|
|
137
|
+
callback(error)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
this.headers['content-type'] = 'text/event-stream'
|
|
143
|
+
this.#stream.pipe(this.#downstream)
|
|
144
|
+
return this.#stream
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The present method is called to present the view model body to the client.
|
|
149
|
+
* This implementation will present the view model as a stringified JSON.
|
|
150
|
+
*
|
|
151
|
+
* - If the request has been aborted, the present method will return early without making any operation.
|
|
152
|
+
* - If the content type is not set, it will be set to "application/json".
|
|
153
|
+
* - If the status code is greater than or equal to 400, the body will be presented as an error.
|
|
154
|
+
*
|
|
155
|
+
* @returns {void}
|
|
156
|
+
*/
|
|
157
|
+
present()
|
|
158
|
+
{
|
|
159
|
+
// can't present if the downstream is not writable
|
|
160
|
+
if(this.#downstream.writableEnded)
|
|
161
|
+
{
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if(false === this.headers.headersSent
|
|
166
|
+
&& false === this.headers.hasHeader('content-type'))
|
|
167
|
+
{
|
|
168
|
+
this.headers['content-type'] = 'application/json'
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// stringify the body and end the downstream
|
|
172
|
+
this.#downstream.end(JSON.stringify(this.body))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The presentError method is called to present an error to the client.
|
|
177
|
+
* This implementation will present the error as a stringified JSON.
|
|
178
|
+
*
|
|
179
|
+
* @param {Error} error The error to present.
|
|
180
|
+
*
|
|
181
|
+
* @returns {void}
|
|
182
|
+
*/
|
|
183
|
+
presentError(error)
|
|
184
|
+
{
|
|
185
|
+
// Can't present if the downstream is not writable.
|
|
186
|
+
if(this.#downstream.writableEnded)
|
|
187
|
+
{
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Set the headers defined by the error if not already sent.
|
|
192
|
+
if(false === this.headers.headersSent)
|
|
193
|
+
{
|
|
194
|
+
if('[object Object]' === Object.prototype.toString.call(error.headers))
|
|
195
|
+
{
|
|
196
|
+
for(const header in error.headers)
|
|
197
|
+
{
|
|
198
|
+
this.headers[header] = error.headers[header]
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if(false === this.headers.hasHeader('content-type'))
|
|
203
|
+
{
|
|
204
|
+
this.headers['content-type'] = 'application/json'
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
this.status = error.statusCode ?? error.status ?? 500
|
|
209
|
+
|
|
210
|
+
const output =
|
|
211
|
+
{
|
|
212
|
+
status : this.status,
|
|
213
|
+
error : error.message,
|
|
214
|
+
code : error.code,
|
|
215
|
+
details : []
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Add the error causes to the details using recursion.
|
|
219
|
+
this.#addDetailToOutput(error.cause, output)
|
|
220
|
+
|
|
221
|
+
// Remove the details property if it's empty for a cleaner output.
|
|
222
|
+
if(output.details.length === 0)
|
|
223
|
+
{
|
|
224
|
+
delete output.details
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Stringify the error and end the response.
|
|
228
|
+
this.#downstream.end(JSON.stringify(output))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
#addDetailToOutput(cause, output, seen = new WeakSet)
|
|
232
|
+
{
|
|
233
|
+
if(cause instanceof Object)
|
|
234
|
+
{
|
|
235
|
+
if(seen.has(cause)
|
|
236
|
+
|| false === !!cause)
|
|
237
|
+
{
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
seen.add(cause)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
switch(Object.prototype.toString.call(cause))
|
|
245
|
+
{
|
|
246
|
+
case '[object Array]':
|
|
247
|
+
{
|
|
248
|
+
for(const detail of cause)
|
|
249
|
+
{
|
|
250
|
+
this.#addDetailToOutput(detail, output, seen)
|
|
251
|
+
}
|
|
252
|
+
break
|
|
253
|
+
}
|
|
254
|
+
case '[object Error]':
|
|
255
|
+
{
|
|
256
|
+
let detail = cause.message
|
|
257
|
+
|
|
258
|
+
if(cause.code)
|
|
259
|
+
{
|
|
260
|
+
detail = `${cause.code} - ${detail}`
|
|
261
|
+
detail = detail.trim()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
output.details.push(detail)
|
|
265
|
+
|
|
266
|
+
if(cause.cause)
|
|
267
|
+
{
|
|
268
|
+
this.#addDetailToOutput(cause.cause, output, seen)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
break
|
|
272
|
+
}
|
|
273
|
+
case '[object Undefined]':
|
|
274
|
+
{
|
|
275
|
+
break
|
|
276
|
+
}
|
|
277
|
+
default:
|
|
278
|
+
{
|
|
279
|
+
cause = String(cause)
|
|
280
|
+
output.details.push(cause)
|
|
281
|
+
break
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|