@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/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
+ }