@jsenv/server 12.8.0 → 13.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.
Files changed (25) hide show
  1. package/README.md +68 -50
  2. package/package.json +1 -1
  3. package/src/content_negotiation/{applyContentNegotiation.js → pick_accepted_content.js} +1 -3
  4. package/src/content_negotiation/{negotiateContentEncoding.js → pick_content_encoding.js} +3 -3
  5. package/src/content_negotiation/{negotiateContentLanguage.js → pick_content_language.js} +3 -3
  6. package/src/content_negotiation/{negotiateContentType.js → pick_content_type.js} +3 -3
  7. package/src/fetch_filesystem.js +2 -2
  8. package/src/internal/dns_resolution.js +17 -0
  9. package/src/internal/getServerOrigins.js +37 -26
  10. package/src/internal/listen.js +15 -13
  11. package/src/main.js +11 -13
  12. package/src/serve_directory.js +2 -2
  13. package/src/{startServer.js → server.js} +150 -122
  14. package/src/service_controller.js +173 -0
  15. package/src/{cors/plugin_cors.js → services/cors/jsenv_service_cors.js} +29 -45
  16. package/src/services/error_handler/jsenv_service_error_handler.js +98 -0
  17. package/src/{content_negotiation/checkContentNegotiation.js → services/response_acceptance_check/jsenv_service_response_acceptance_check.js} +16 -7
  18. package/src/services/ressource_aliases/jsenv_service_ressource_aliases.js +40 -0
  19. package/src/sse/{createSSERoom.js → sse_room.js} +18 -16
  20. package/src/content_negotiation/plugin_content_negotiation_check.js +0 -15
  21. package/src/error_to_response_default.js +0 -94
  22. package/src/plugin_request_waiting_check.js +0 -32
  23. package/src/ressource_aliases/plugin_ressource_aliases.js +0 -44
  24. package/src/server_timing/plugin_server_timing.js +0 -28
  25. package/src/service_composition/service_composition.js +0 -55
@@ -8,7 +8,7 @@ export const jsenvAccessControlAllowedMethods = [
8
8
  "OPTIONS",
9
9
  ]
10
10
 
11
- export const pluginCORS = ({
11
+ export const jsenvServiceCORS = ({
12
12
  accessControlAllowedOrigins = [],
13
13
  accessControlAllowedMethods = jsenvAccessControlAllowedMethods,
14
14
  accessControlAllowedHeaders = jsenvAccessControlAllowedHeaders,
@@ -19,7 +19,7 @@ export const pluginCORS = ({
19
19
  // by default OPTIONS request can be cache for a long time, it's not going to change soon ?
20
20
  // we could put a lot here, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
21
21
  accessControlMaxAge = 600,
22
- timingAllowOrigin,
22
+ timingAllowOrigin = false,
23
23
  } = {}) => {
24
24
  // TODO: we should check access control params to throw or warn if we find strange values
25
25
 
@@ -27,56 +27,40 @@ export const pluginCORS = ({
27
27
  accessControlAllowRequestOrigin || accessControlAllowedOrigins.length
28
28
 
29
29
  if (!corsEnabled) {
30
- return {
31
- cors: {},
32
- }
30
+ return []
33
31
  }
34
32
 
35
33
  return {
36
- cors: {
37
- onServerParams: ({ plugins }) => {
38
- if (timingAllowOrigin === undefined && plugins.server_timings) {
39
- timingAllowOrigin = true
40
- }
41
- },
42
-
43
- onRequest: (request, { shortcircuitResponse }) => {
44
- if (request.method === "OPTIONS") {
45
- // when request method is "OPTIONS" we must return a 200 without body
46
- // So we bypass "requestToResponse" in that scenario using shortcircuitResponse
47
- shortcircuitResponse({
48
- status: 200,
49
- headers: {
50
- "content-length": 0,
51
- },
52
- })
53
- }
54
-
55
- if (request.parent) {
56
- return null
57
- }
34
+ name: "jsenv:cors",
58
35
 
36
+ handleRequest: (request) => {
37
+ // when request method is "OPTIONS" we must return a 200 without body
38
+ // So we bypass "requestToResponse" in that scenario using shortcircuitResponse
39
+ if (request.method === "OPTIONS") {
59
40
  return {
60
- onResponse: () => {
61
- const accessControlHeaders = generateAccessControlHeaders({
62
- request,
63
- accessControlAllowedOrigins,
64
- accessControlAllowRequestOrigin,
65
- accessControlAllowedMethods,
66
- accessControlAllowRequestMethod,
67
- accessControlAllowedHeaders,
68
- accessControlAllowRequestHeaders,
69
- accessControlAllowCredentials,
70
- accessControlMaxAge,
71
- timingAllowOrigin,
72
- })
73
-
74
- return {
75
- headers: accessControlHeaders,
76
- }
41
+ status: 200,
42
+ headers: {
43
+ "content-length": 0,
77
44
  },
78
45
  }
79
- },
46
+ }
47
+ return null
48
+ },
49
+
50
+ injectResponseHeaders: (response, { request }) => {
51
+ const accessControlHeaders = generateAccessControlHeaders({
52
+ request,
53
+ accessControlAllowedOrigins,
54
+ accessControlAllowRequestOrigin,
55
+ accessControlAllowedMethods,
56
+ accessControlAllowRequestMethod,
57
+ accessControlAllowedHeaders,
58
+ accessControlAllowRequestHeaders,
59
+ accessControlAllowCredentials,
60
+ accessControlMaxAge,
61
+ timingAllowOrigin,
62
+ })
63
+ return accessControlHeaders
80
64
  },
81
65
  }
82
66
  }
@@ -0,0 +1,98 @@
1
+ import { pickContentType } from "../../content_negotiation/pick_content_type.js"
2
+
3
+ export const jsenvServiceErrorHandler = ({ sendErrorDetails = false } = {}) => {
4
+ return {
5
+ name: "jsenv:error_handler",
6
+ handleError: (serverInternalError, { request }) => {
7
+ const serverInternalErrorIsAPrimitive =
8
+ serverInternalError === null ||
9
+ (typeof serverInternalError !== "object" &&
10
+ typeof serverInternalError !== "function")
11
+ if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
12
+ return serverInternalError.asResponse()
13
+ }
14
+ const dataToSend = serverInternalErrorIsAPrimitive
15
+ ? {
16
+ code: "VALUE_THROWED",
17
+ value: serverInternalError,
18
+ }
19
+ : {
20
+ code: serverInternalError.code || "UNKNOWN_ERROR",
21
+ ...(sendErrorDetails
22
+ ? {
23
+ stack: serverInternalError.stack,
24
+ ...serverInternalError,
25
+ }
26
+ : {}),
27
+ }
28
+
29
+ const availableContentTypes = {
30
+ "text/html": () => {
31
+ const renderHtmlForErrorWithoutDetails = () => {
32
+ return `<p>Details not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).</p>`
33
+ }
34
+
35
+ const renderHtmlForErrorWithDetails = () => {
36
+ if (serverInternalErrorIsAPrimitive) {
37
+ return `<pre>${JSON.stringify(
38
+ serverInternalError,
39
+ null,
40
+ " ",
41
+ )}</pre>`
42
+ }
43
+ return `<pre>${serverInternalError.stack}</pre>`
44
+ }
45
+
46
+ const body = `<!DOCTYPE html>
47
+ <html>
48
+ <head>
49
+ <title>Internal server error</title>
50
+ <meta charset="utf-8" />
51
+ <link rel="icon" href="data:," />
52
+ </head>
53
+
54
+ <body>
55
+ <h1>Internal server error</h1>
56
+ <p>${
57
+ serverInternalErrorIsAPrimitive
58
+ ? `Code inside server has thrown a literal.`
59
+ : `Code inside server has thrown an error.`
60
+ }</p>
61
+ <details>
62
+ <summary>See internal error details</summary>
63
+ ${
64
+ sendErrorDetails
65
+ ? renderHtmlForErrorWithDetails()
66
+ : renderHtmlForErrorWithoutDetails()
67
+ }
68
+ </details>
69
+ </body>
70
+ </html>`
71
+
72
+ return {
73
+ headers: {
74
+ "content-type": "text/html",
75
+ "content-length": Buffer.byteLength(body),
76
+ },
77
+ body,
78
+ }
79
+ },
80
+ "application/json": () => {
81
+ const body = JSON.stringify(dataToSend)
82
+ return {
83
+ headers: {
84
+ "content-type": "application/json",
85
+ "content-length": Buffer.byteLength(body),
86
+ },
87
+ body,
88
+ }
89
+ },
90
+ }
91
+ const bestContentType = pickContentType(
92
+ request,
93
+ Object.keys(availableContentTypes),
94
+ )
95
+ return availableContentTypes[bestContentType || "application/json"]()
96
+ },
97
+ }
98
+ }
@@ -1,14 +1,23 @@
1
- import { negotiateContentType } from "./negotiateContentType.js"
2
- import { negotiateContentLanguage } from "./negotiateContentLanguage.js"
3
- import { negotiateContentEncoding } from "./negotiateContentEncoding.js"
1
+ import { pickContentType } from "@jsenv/server/src/content_negotiation/pick_content_type.js"
2
+ import { pickContentLanguage } from "@jsenv/server/src/content_negotiation/pick_content_language.js"
3
+ import { pickContentEncoding } from "@jsenv/server/src/content_negotiation/pick_content_encoding.js"
4
4
 
5
- export const checkContentNegotiation = (request, response, { warn }) => {
5
+ export const jsenvServiceResponseAcceptanceCheck = () => {
6
+ return {
7
+ name: "jsenv:response_acceptance_check",
8
+ inspectResponse: (request, { response, warn }) => {
9
+ checkResponseAcceptance(request, response, { warn })
10
+ },
11
+ }
12
+ }
13
+
14
+ const checkResponseAcceptance = (request, response, { warn }) => {
6
15
  const requestAcceptHeader = request.headers.accept
7
16
  const responseContentTypeHeader = response.headers["content-type"]
8
17
  if (
9
18
  requestAcceptHeader &&
10
19
  responseContentTypeHeader &&
11
- !negotiateContentType(request, [responseContentTypeHeader])
20
+ !pickContentType(request, [responseContentTypeHeader])
12
21
  ) {
13
22
  warn(`response content type is not in the request accepted content types.
14
23
  --- response content-type header ---
@@ -22,7 +31,7 @@ ${requestAcceptHeader}`)
22
31
  if (
23
32
  requestAcceptLanguageHeader &&
24
33
  responseContentLanguageHeader &&
25
- !negotiateContentLanguage(request, [responseContentLanguageHeader])
34
+ !pickContentLanguage(request, [responseContentLanguageHeader])
26
35
  ) {
27
36
  warn(`response language is not in the request accepted language.
28
37
  --- response content-language header ---
@@ -36,7 +45,7 @@ ${requestAcceptLanguageHeader}`)
36
45
  if (
37
46
  requestAcceptLanguageHeader &&
38
47
  responseContentLanguageHeader &&
39
- !negotiateContentEncoding(request, [responseContentLanguageHeader])
48
+ !pickContentEncoding(request, [responseContentLanguageHeader])
40
49
  ) {
41
50
  warn(`response encoding is not in the request accepted encoding.
42
51
  --- response content-encoding header ---
@@ -0,0 +1,40 @@
1
+ import { URL_META } from "@jsenv/url-meta"
2
+
3
+ export const jsenvServiceRessourceAliases = (ressourceAliases) => {
4
+ const aliases = {}
5
+ Object.keys(ressourceAliases).forEach((key) => {
6
+ aliases[asFileUrl(key)] = asFileUrl(ressourceAliases[key])
7
+ })
8
+ return {
9
+ name: "jsenv:ressource_aliases",
10
+ redirectRequest: (request) => {
11
+ const ressourceBeforeAlias = request.ressource
12
+ const urlAfterAliasing = URL_META.applyAliases({
13
+ url: asFileUrl(request.pathname),
14
+ aliases,
15
+ })
16
+ const ressourceAfterAlias = urlAfterAliasing.slice("file://".length)
17
+ if (ressourceBeforeAlias === ressourceAfterAlias) {
18
+ return null
19
+ }
20
+ const ressource = replaceRessource(
21
+ ressourceBeforeAlias,
22
+ ressourceAfterAlias,
23
+ )
24
+ return { ressource }
25
+ },
26
+ }
27
+ }
28
+
29
+ const asFileUrl = (specifier) => new URL(specifier, "file:///").href
30
+
31
+ const replaceRessource = (ressourceBeforeAlias, newValue) => {
32
+ const urlObject = new URL(ressourceBeforeAlias, "file://")
33
+ const searchSeparatorIndex = newValue.indexOf("?")
34
+ if (searchSeparatorIndex > -1) {
35
+ return newValue // let new value override search params
36
+ }
37
+ urlObject.pathname = newValue
38
+ const ressource = `${urlObject.pathname}${urlObject.search}`
39
+ return ressource
40
+ }
@@ -57,6 +57,7 @@ export const createSSERoom = ({
57
57
  const client = {
58
58
  next,
59
59
  complete,
60
+ request,
60
61
  }
61
62
  if (clients.size === 0) {
62
63
  const effectReturnValue = effect()
@@ -94,7 +95,9 @@ export const createSSERoom = ({
94
95
 
95
96
  // send to everyone
96
97
  if (welcomeEventPublic) {
97
- write(stringifySourceEvent(welcomeEvent))
98
+ sendEventToAllClients(welcomeEvent, {
99
+ history: false,
100
+ })
98
101
  }
99
102
  // send only to this client
100
103
  else {
@@ -148,14 +151,17 @@ export const createSSERoom = ({
148
151
  eventHistory.add(event)
149
152
  }
150
153
 
151
- const sendEvent = (event) => {
152
- if (event.type !== "comment") {
154
+ const sendEventToAllClients = (event, { history = true } = {}) => {
155
+ if (history) {
153
156
  addEventToHistory(event)
154
157
  }
155
158
  logger.debug(
156
159
  `send "${event.type}" event to ${clients.size} client in the room`,
157
160
  )
158
- write(stringifySourceEvent(event))
161
+ const eventString = stringifySourceEvent(event)
162
+ clients.forEach((client) => {
163
+ client.next(eventString)
164
+ })
159
165
  }
160
166
 
161
167
  const getAllEventSince = (id) => {
@@ -166,21 +172,18 @@ export const createSSERoom = ({
166
172
  return events
167
173
  }
168
174
 
169
- const write = (data) => {
170
- clients.forEach((client) => {
171
- client.next(data)
172
- })
173
- }
174
-
175
175
  const keepAlive = () => {
176
176
  // maybe that, when an event occurs, we can delay the keep alive event
177
177
  logger.debug(
178
178
  `send keep alive event, number of client listening event source: ${clients.size}`,
179
179
  )
180
- sendEvent({
181
- type: "comment",
182
- data: new Date().toLocaleTimeString(),
183
- })
180
+ sendEventToAllClients(
181
+ {
182
+ type: "comment",
183
+ data: new Date().toLocaleTimeString(),
184
+ },
185
+ { history: false },
186
+ )
184
187
  }
185
188
 
186
189
  const open = () => {
@@ -209,7 +212,7 @@ export const createSSERoom = ({
209
212
  // - ability to sendEvent to clients in the room
210
213
  // - ability to join the room
211
214
  // - ability to leave the room
212
- sendEvent,
215
+ sendEventToAllClients,
213
216
  join,
214
217
  leave,
215
218
 
@@ -256,7 +259,6 @@ const disconnectRequestFromRoom = (request, room) => {
256
259
  if (!requestInfo) {
257
260
  return
258
261
  }
259
-
260
262
  const { sseProducer, roomObservableMap } = requestInfo
261
263
  const roomObservable = roomObservableMap.get(room)
262
264
  roomObservableMap.delete(room)
@@ -1,15 +0,0 @@
1
- import { checkContentNegotiation } from "./checkContentNegotiation.js"
2
-
3
- export const pluginContentNegotiationCheck = () => {
4
- return {
5
- content_negotiation_check: {
6
- onRequest: (request, { warn }) => {
7
- return {
8
- onResponse: (response) => {
9
- checkContentNegotiation(request, response, { warn })
10
- },
11
- }
12
- },
13
- },
14
- }
15
- }
@@ -1,94 +0,0 @@
1
- import { negotiateContentType } from "./content_negotiation/negotiateContentType.js"
2
-
3
- export const applyDefaultErrorToResponse = (
4
- serverInternalError,
5
- { request, sendErrorDetails = false },
6
- ) => {
7
- const serverInternalErrorIsAPrimitive =
8
- serverInternalError === null ||
9
- (typeof serverInternalError !== "object" &&
10
- typeof serverInternalError !== "function")
11
-
12
- if (!serverInternalErrorIsAPrimitive && serverInternalError.asResponse) {
13
- return serverInternalError.asResponse()
14
- }
15
-
16
- const dataToSend = serverInternalErrorIsAPrimitive
17
- ? {
18
- code: "VALUE_THROWED",
19
- value: serverInternalError,
20
- }
21
- : {
22
- code: serverInternalError.code || "UNKNOWN_ERROR",
23
- ...(sendErrorDetails
24
- ? {
25
- stack: serverInternalError.stack,
26
- ...serverInternalError,
27
- }
28
- : {}),
29
- }
30
-
31
- const availableContentTypes = {
32
- "text/html": () => {
33
- const renderHtmlForErrorWithoutDetails = () => {
34
- return `<p>Details not available: to enable them server must be started with sendErrorDetails: true.</p>`
35
- }
36
-
37
- const renderHtmlForErrorWithDetails = () => {
38
- if (serverInternalErrorIsAPrimitive) {
39
- return `<pre>${JSON.stringify(serverInternalError, null, " ")}</pre>`
40
- }
41
- return `<pre>${serverInternalError.stack}</pre>`
42
- }
43
-
44
- const body = `<!DOCTYPE html>
45
- <html>
46
- <head>
47
- <title>Internal server error</title>
48
- <meta charset="utf-8" />
49
- <link rel="icon" href="data:," />
50
- </head>
51
-
52
- <body>
53
- <h1>Internal server error</h1>
54
- <p>${
55
- serverInternalErrorIsAPrimitive
56
- ? `Code inside server has thrown a literal.`
57
- : `Code inside server has thrown an error.`
58
- }</p>
59
- <details>
60
- <summary>See internal error details</summary>
61
- ${
62
- sendErrorDetails
63
- ? renderHtmlForErrorWithDetails()
64
- : renderHtmlForErrorWithoutDetails()
65
- }
66
- </details>
67
- </body>
68
- </html>`
69
-
70
- return {
71
- headers: {
72
- "content-type": "text/html",
73
- "content-length": Buffer.byteLength(body),
74
- },
75
- body,
76
- }
77
- },
78
- "application/json": () => {
79
- const body = JSON.stringify(dataToSend)
80
- return {
81
- headers: {
82
- "content-type": "application/json",
83
- "content-length": Buffer.byteLength(body),
84
- },
85
- body,
86
- }
87
- },
88
- }
89
- const bestContentType = negotiateContentType(
90
- request,
91
- Object.keys(availableContentTypes),
92
- )
93
- return availableContentTypes[bestContentType || "application/json"]()
94
- }
@@ -1,32 +0,0 @@
1
- import { createDetailedMessage } from "@jsenv/log"
2
-
3
- export const pluginRequestWaitingCheck = ({
4
- requestWaitingMs = 20000,
5
- requestWaitingCallback = ({ request, logger, requestWaitingMs }) => {
6
- logger.warn(
7
- createDetailedMessage(
8
- `still no response found for request after ${requestWaitingMs} ms`,
9
- {
10
- "request url": `${request.origin}${request.ressource}`,
11
- "request headers": JSON.stringify(request.headers, null, " "),
12
- },
13
- ),
14
- )
15
- },
16
- } = {}) => {
17
- return {
18
- plugin_request_waiting_check: {
19
- onRequest: (request, { logger }) => {
20
- const timeout = setTimeout(
21
- () => requestWaitingCallback({ request, logger, requestWaitingMs }),
22
- requestWaitingMs,
23
- )
24
- return {
25
- onResponse: () => {
26
- clearTimeout(timeout)
27
- },
28
- }
29
- },
30
- },
31
- }
32
- }
@@ -1,44 +0,0 @@
1
- import { URL_META } from "@jsenv/url-meta"
2
-
3
- export const pluginRessourceAliases = (ressourceAliases) => {
4
- const aliases = {}
5
- Object.keys(ressourceAliases).forEach((key) => {
6
- aliases[asFileUrl(key)] = asFileUrl(ressourceAliases[key])
7
- })
8
- return {
9
- ressource_aliases: {
10
- onRequest: (request, { redirectRequest }) => {
11
- const ressourceBeforeAlias = request.ressource
12
- const urlAfterAliasing = URL_META.applyAliases({
13
- url: asFileUrl(request.pathname),
14
- aliases,
15
- })
16
- const ressourceAfterAlias = urlAfterAliasing.slice("file://".length)
17
- if (ressourceBeforeAlias === ressourceAfterAlias) {
18
- return
19
- }
20
- const ressource = replaceRessource(
21
- ressourceBeforeAlias,
22
- ressourceAfterAlias,
23
- )
24
- redirectRequest({
25
- ressourceBeforeAlias,
26
- ressource,
27
- })
28
- },
29
- },
30
- }
31
- }
32
-
33
- const asFileUrl = (specifier) => new URL(specifier, "file:///").href
34
-
35
- const replaceRessource = (ressourceBeforeAlias, newValue) => {
36
- const urlObject = new URL(ressourceBeforeAlias, "file://")
37
- const searchSeparatorIndex = newValue.indexOf("?")
38
- if (searchSeparatorIndex > -1) {
39
- return newValue // let new value override search params
40
- }
41
- urlObject.pathname = newValue
42
- const ressource = `${urlObject.pathname}${urlObject.search}`
43
- return ressource
44
- }
@@ -1,28 +0,0 @@
1
- // https://www.w3.org/TR/server-timing/
2
-
3
- import { performance } from "node:perf_hooks"
4
-
5
- import { timingToServerTimingResponseHeaders } from "./timing_header.js"
6
-
7
- export const pluginServerTiming = () => {
8
- return {
9
- server_timing: {
10
- onRequest: () => {
11
- const requestStartEvent = performance.now()
12
- return {
13
- onResponse: (response) => {
14
- const responseEndEvent = performance.now()
15
- const timeToCreateResponse = responseEndEvent - requestStartEvent
16
- const serverTiming = {
17
- ...response.timing,
18
- "time to start responding": timeToCreateResponse,
19
- }
20
- return {
21
- headers: timingToServerTimingResponseHeaders(serverTiming),
22
- }
23
- },
24
- }
25
- },
26
- },
27
- }
28
- }
@@ -1,55 +0,0 @@
1
- import { timeFunction } from "../server_timing/timing_measure.js"
2
- import { composeTwoResponses } from "../internal/response_composition.js"
3
- import { applyRedirectionToRequest } from "../internal/request_factory.js"
4
-
5
- export const composeServices = (namedServices) => {
6
- return async (request, { pushResponse }) => {
7
- const redirectRequest = (requestRedirection) => {
8
- request = applyRedirectionToRequest(request, requestRedirection)
9
- }
10
-
11
- const servicesTiming = {}
12
- const response = await firstOperationMatching({
13
- array: Object.keys(namedServices).map((serviceName) => {
14
- return { serviceName, serviceFn: namedServices[serviceName] }
15
- }),
16
- start: async ({ serviceName, serviceFn }) => {
17
- const [serviceTiming, value] = await timeFunction(serviceName, () =>
18
- serviceFn(request, { pushResponse, redirectRequest }),
19
- )
20
- Object.assign(servicesTiming, serviceTiming)
21
- return value
22
- },
23
- predicate: (returnValue) => {
24
- if (returnValue === null || typeof returnValue !== "object") {
25
- return false
26
- }
27
- return true
28
- },
29
- })
30
- if (response) {
31
- return composeTwoResponses({ timing: servicesTiming }, response)
32
- }
33
- return null
34
- }
35
- }
36
-
37
- const firstOperationMatching = ({ array, start, predicate }) => {
38
- return new Promise((resolve, reject) => {
39
- const visit = (index) => {
40
- if (index >= array.length) {
41
- return resolve()
42
- }
43
- const input = array[index]
44
- const returnValue = start(input)
45
- return Promise.resolve(returnValue).then((output) => {
46
- if (predicate(output)) {
47
- return resolve(output)
48
- }
49
- return visit(index + 1)
50
- }, reject)
51
- }
52
-
53
- visit(0)
54
- })
55
- }