@platformatic/runtime 1.23.0 → 1.25.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.
@@ -0,0 +1,32 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v0.20.0/runtime",
3
+ "entrypoint": "serviceApp",
4
+ "allowCycles": true,
5
+ "hotReload": true,
6
+ "autoload": {
7
+ "path": "../monorepo",
8
+ "exclude": [
9
+ "docs",
10
+ "composerApp"
11
+ ],
12
+ "mappings": {
13
+ "serviceAppWithLogger": {
14
+ "id": "with-logger",
15
+ "config": "platformatic.service.json"
16
+ },
17
+ "serviceAppWithMultiplePlugins": {
18
+ "id": "multi-plugin-service",
19
+ "config": "platformatic.service.json"
20
+ },
21
+ "dbApp": {
22
+ "id": "db-app",
23
+ "config": "platformatic.db.json"
24
+ }
25
+ }
26
+ },
27
+ "server": {
28
+ "logger": {
29
+ "level": "trace"
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "test-runtime-package",
3
+ "version": "1.0.42"
4
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v1.22.0/runtime",
3
+ "entrypoint": "service-1",
4
+ "allowCycles": true,
5
+ "hotReload": false,
6
+ "autoload": {
7
+ "path": "./services"
8
+ },
9
+ "server": {
10
+ "hostname": "127.0.0.1",
11
+ "port": 0
12
+ },
13
+ "managementApi": true
14
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v1.22.0/service",
3
+ "service": {
4
+ "openapi": true
5
+ },
6
+ "plugins": {
7
+ "paths": ["plugin.js"]
8
+ },
9
+ "watch": true,
10
+ "metrics": {
11
+ "server": "parent"
12
+ }
13
+ }
@@ -0,0 +1,8 @@
1
+ 'use strict'
2
+
3
+ /** @param {import('fastify').FastifyInstance} app */
4
+ module.exports = async function (app) {
5
+ app.get('/hello', async () => {
6
+ return { service: 'service-2' }
7
+ })
8
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://platformatic.dev/schemas/v1.22.0/service",
3
+ "service": {
4
+ "openapi": true
5
+ },
6
+ "plugins": {
7
+ "paths": ["plugin.js"]
8
+ }
9
+ }
@@ -0,0 +1,8 @@
1
+ 'use strict'
2
+
3
+ /** @param {import('fastify').FastifyInstance} app */
4
+ module.exports = async function (app, options) {
5
+ app.get('/hello', async () => {
6
+ return { service: 'service-2' }
7
+ })
8
+ }
package/lib/api-client.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const { once, EventEmitter } = require('node:events')
4
4
  const { randomUUID } = require('node:crypto')
5
5
  const errors = require('./errors')
6
+ const { setTimeout: sleep } = require('node:timers/promises')
6
7
 
7
8
  const MAX_LISTENERS_COUNT = 100
8
9
 
@@ -27,21 +28,36 @@ class RuntimeApiClient extends EventEmitter {
27
28
  return this.#sendCommand('plt:start-services')
28
29
  }
29
30
 
30
- async stop () {
31
- await this.#sendCommand('plt:stop-services')
32
- }
33
-
34
31
  async close () {
35
32
  await this.#sendCommand('plt:stop-services')
36
33
 
37
34
  this.worker.postMessage({ command: 'plt:close' })
38
- await this.#exitPromise
35
+ const res = await Promise.race([
36
+ this.#exitPromise,
37
+ // We must kill the worker if it doesn't exit in 10 seconds
38
+ // because it may be stuck in an infinite loop.
39
+ // This is a workaround for
40
+ // https://github.com/nodejs/node/issues/47748
41
+ // https://github.com/nodejs/node/issues/49344
42
+ // Remove once https://github.com/nodejs/node/pull/51290 is released
43
+ // on all lines.
44
+ // Likely to be removed when we drop support for Node.js 18.
45
+ sleep(10000, 'timeout', { ref: false })
46
+ ])
47
+
48
+ if (res === 'timeout') {
49
+ this.worker.unref()
50
+ }
39
51
  }
40
52
 
41
53
  async restart () {
42
54
  return this.#sendCommand('plt:restart-services')
43
55
  }
44
56
 
57
+ async getEntrypointDetails () {
58
+ return this.#sendCommand('plt:get-entrypoint-details')
59
+ }
60
+
45
61
  async getServices () {
46
62
  return this.#sendCommand('plt:get-services')
47
63
  }
package/lib/api.js CHANGED
@@ -9,9 +9,11 @@ const { printSchema } = require('graphql')
9
9
  class RuntimeApi {
10
10
  #services
11
11
  #dispatcher
12
+ #logger
12
13
 
13
14
  constructor (config, logger, loaderPort) {
14
15
  this.#services = new Map()
16
+ this.#logger = logger
15
17
  const telemetryConfig = config.telemetry
16
18
 
17
19
  for (let i = 0; i < config.services.length; ++i) {
@@ -48,8 +50,19 @@ class RuntimeApi {
48
50
  const command = message?.command
49
51
  if (command) {
50
52
  if (command === 'plt:close') {
51
- await this.#dispatcher.close()
52
- process.exit() // Exit the worker thread.
53
+ // We close everything because they might be using
54
+ // a FinalizationRegistry and it may stuck us in an infinite loop.
55
+ // This is a workaround for
56
+ // https://github.com/nodejs/node/issues/47748
57
+ // https://github.com/nodejs/node/issues/49344
58
+ // Remove once https://github.com/nodejs/node/pull/51290 is released
59
+ // on all lines.
60
+ // Likely to be removed when we drop support for Node.js 18.
61
+ if (this.#dispatcher) {
62
+ await this.#dispatcher.close()
63
+ }
64
+ setImmediate(process.exit) // Exit the worker thread.
65
+ return
53
66
  }
54
67
 
55
68
  const res = await this.#executeCommand(message)
@@ -97,6 +110,8 @@ class RuntimeApi {
97
110
  return this.stopServices(params)
98
111
  case 'plt:restart-services':
99
112
  return this.#restartServices(params)
113
+ case 'plt:get-entrypoint-details':
114
+ return this.#getEntrypointDetails(params)
100
115
  case 'plt:get-services':
101
116
  return this.#getServices(params)
102
117
  case 'plt:get-service-details':
@@ -162,6 +177,15 @@ class RuntimeApi {
162
177
  return entrypointUrl
163
178
  }
164
179
 
180
+ #getEntrypointDetails () {
181
+ for (const service of this.#services.values()) {
182
+ if (service.appConfig.entrypoint) {
183
+ return this.#getServiceDetails({ id: service.appConfig.id })
184
+ }
185
+ }
186
+ return null
187
+ }
188
+
165
189
  #getServices () {
166
190
  const services = { services: [] }
167
191
 
@@ -191,8 +215,15 @@ class RuntimeApi {
191
215
  const service = this.#getServiceById(id)
192
216
  const status = service.getStatus()
193
217
 
218
+ const type = service.config.configType
194
219
  const { entrypoint, dependencies, localUrl } = service.appConfig
195
- return { id, status, localUrl, entrypoint, dependencies }
220
+ const serviceDetails = { id, type, status, localUrl, entrypoint, dependencies }
221
+
222
+ if (entrypoint) {
223
+ serviceDetails.url = status === 'started' ? service.server.url : null
224
+ }
225
+
226
+ return serviceDetails
196
227
  }
197
228
 
198
229
  #getServiceConfig ({ id }) {
package/lib/app.js CHANGED
@@ -73,7 +73,10 @@ class PlatformaticApp {
73
73
  this.#setuplogger(this.config.configManager)
74
74
  await this.server.restart()
75
75
  } catch (err) {
76
- this.#logAndExit(err)
76
+ // The restart failed. Log the error and
77
+ // wait for another event.
78
+ // The old app is still available
79
+ this.#logger.error({ err })
77
80
  }
78
81
 
79
82
  this.#restarting = false
@@ -245,10 +248,12 @@ class PlatformaticApp {
245
248
  }
246
249
 
247
250
  #setuplogger (configManager) {
248
- // Set the logger if not present
249
251
  configManager.current.server = configManager.current.server || {}
250
- const childLogger = this.#logger.child({}, { level: configManager.current.server.logger?.level || 'info' })
251
- configManager.current.server.logger = childLogger
252
+ const level = configManager.current.server.logger?.level
253
+
254
+ configManager.current.server.logger = level
255
+ ? this.#logger.child({ level })
256
+ : this.#logger
252
257
  }
253
258
 
254
259
  #startFileWatching () {
@@ -1,12 +1,21 @@
1
1
  'use strict'
2
2
 
3
+ const { tmpdir, platform } = require('node:os')
4
+ const { join } = require('node:path')
5
+ const { readFile, mkdir, unlink } = require('node:fs/promises')
3
6
  const fastify = require('fastify')
4
- const { isatty } = require('tty')
7
+ const errors = require('./errors')
5
8
  const platformaticVersion = require('../package.json').version
6
9
 
7
- async function createManagementApi (config, runtimeApiClient) {
8
- addManagementApiLogger(config)
9
- const app = fastify(config)
10
+ const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'pids')
11
+
12
+ async function createManagementApi (configManager, runtimeApiClient, loggingPort) {
13
+ let apiConfig = configManager.current.managementApi
14
+ if (!apiConfig || apiConfig === true) {
15
+ apiConfig = {}
16
+ }
17
+
18
+ const app = fastify(apiConfig)
10
19
  app.log.warn(
11
20
  'Runtime Management API is in the experimental stage. ' +
12
21
  'The feature is not subject to semantic versioning rules. ' +
@@ -14,36 +23,57 @@ async function createManagementApi (config, runtimeApiClient) {
14
23
  'Use of the feature is not recommended in production environments.'
15
24
  )
16
25
 
26
+ async function getRuntimePackageJson (cwd) {
27
+ const packageJsonPath = join(cwd, 'package.json')
28
+ const packageJsonFile = await readFile(packageJsonPath, 'utf8')
29
+ const packageJson = JSON.parse(packageJsonFile)
30
+ return packageJson
31
+ }
32
+
33
+ app.register(require('@fastify/websocket'))
34
+
17
35
  app.register(async (app) => {
18
36
  app.get('/metadata', async () => {
37
+ const packageJson = await getRuntimePackageJson(configManager.dirname).catch(() => ({}))
38
+ const entrypointDetails = await runtimeApiClient.getEntrypointDetails().catch(() => null)
39
+
19
40
  return {
20
41
  pid: process.pid,
21
42
  cwd: process.cwd(),
43
+ argv: process.argv,
44
+ uptimeSeconds: Math.floor(process.uptime()),
22
45
  execPath: process.execPath,
23
46
  nodeVersion: process.version,
47
+ projectDir: configManager.dirname,
48
+ packageName: packageJson.name ?? null,
49
+ packageVersion: packageJson.version ?? null,
50
+ url: entrypointDetails?.url ?? null,
24
51
  platformaticVersion
25
52
  }
26
53
  })
27
54
 
28
- app.get('/services', async () => {
29
- return runtimeApiClient.getServices()
55
+ app.get('/config', async () => {
56
+ return configManager.current
30
57
  })
31
58
 
32
- app.post('/services/start', async () => {
33
- app.log.debug('start services')
34
- await runtimeApiClient.start()
59
+ app.get('/env', async () => {
60
+ return process.env
35
61
  })
36
62
 
37
- app.post('/services/stop', async () => {
63
+ app.post('/stop', async () => {
38
64
  app.log.debug('stop services')
39
- await runtimeApiClient.stop()
65
+ await runtimeApiClient.close()
40
66
  })
41
67
 
42
- app.post('/services/restart', async () => {
43
- app.log.debug('restart services')
68
+ app.post('/reload', async () => {
69
+ app.log.debug('reload services')
44
70
  await runtimeApiClient.restart()
45
71
  })
46
72
 
73
+ app.get('/services', async () => {
74
+ return runtimeApiClient.getServices()
75
+ })
76
+
47
77
  app.get('/services/:id', async (request) => {
48
78
  const { id } = request.params
49
79
  app.log.debug('get service details', { id })
@@ -87,27 +117,68 @@ async function createManagementApi (config, runtimeApiClient) {
87
117
  .headers(res.headers)
88
118
  .send(res.body)
89
119
  })
90
- }, { prefix: '/api' })
120
+
121
+ app.get('/logs', { websocket: true }, async (connection, req) => {
122
+ const handler = (message) => {
123
+ for (const log of message.logs) {
124
+ connection.socket.send(log)
125
+ }
126
+ }
127
+
128
+ loggingPort.on('message', handler)
129
+ connection.socket.on('close', () => {
130
+ loggingPort.off('message', handler)
131
+ })
132
+ connection.socket.on('error', () => {
133
+ loggingPort.off('message', handler)
134
+ })
135
+ connection.socket.on('end', () => {
136
+ loggingPort.off('message', handler)
137
+ })
138
+ })
139
+ }, { prefix: '/api/v1' })
91
140
 
92
141
  return app
93
142
  }
94
143
 
95
- function addManagementApiLogger (config) {
96
- let logger = config.logger
97
- if (!logger) {
98
- config.logger = {
99
- level: 'info',
100
- name: 'management-api'
101
- }
102
- logger = config.logger
144
+ async function startManagementApi (configManager, runtimeApiClient, loggingPort) {
145
+ const runtimePID = process.pid
146
+
147
+ let socketPath = null
148
+ if (platform() === 'win32') {
149
+ socketPath = '\\\\.\\pipe\\platformatic-' + runtimePID
150
+ } else {
151
+ await mkdir(PLATFORMATIC_TMP_DIR, { recursive: true })
152
+ socketPath = join(PLATFORMATIC_TMP_DIR, `${runtimePID}.sock`)
103
153
  }
104
154
 
105
- /* c8 ignore next 5 */
106
- if (isatty(1) && !logger.transport) {
107
- logger.transport = {
108
- target: 'pino-pretty'
155
+ try {
156
+ await mkdir(PLATFORMATIC_TMP_DIR, { recursive: true })
157
+ await unlink(socketPath).catch((err) => {
158
+ if (err.code !== 'ENOENT') {
159
+ throw new errors.FailedToUnlinkManagementApiSocket(err.message)
160
+ }
161
+ })
162
+
163
+ const managementApi = await createManagementApi(
164
+ configManager,
165
+ runtimeApiClient,
166
+ loggingPort
167
+ )
168
+
169
+ if (platform() !== 'win32') {
170
+ managementApi.addHook('onClose', async () => {
171
+ await unlink(socketPath).catch(() => {})
172
+ })
109
173
  }
174
+
175
+ await managementApi.listen({ path: socketPath })
176
+ return managementApi
177
+ /* c8 ignore next 4 */
178
+ } catch (err) {
179
+ console.error(err)
180
+ process.exit(1)
110
181
  }
111
182
  }
112
183
 
113
- module.exports = { createManagementApi }
184
+ module.exports = { startManagementApi, createManagementApi }
package/lib/start.js CHANGED
@@ -1,17 +1,16 @@
1
1
  'use strict'
2
2
 
3
- const { tmpdir, platform } = require('node:os')
4
3
  const { once } = require('node:events')
5
4
  const inspector = require('node:inspector')
6
5
  const { join, resolve, dirname } = require('node:path')
7
- const { writeFile, unlink, mkdir } = require('node:fs/promises')
6
+ const { writeFile } = require('node:fs/promises')
8
7
  const { pathToFileURL } = require('node:url')
9
8
  const { Worker } = require('node:worker_threads')
10
9
  const { start: serviceStart } = require('@platformatic/service')
11
10
  const { printConfigValidationErrors } = require('@platformatic/config')
12
11
  const closeWithGrace = require('close-with-grace')
13
12
  const { loadConfig } = require('./load-config')
14
- const { createManagementApi } = require('./management-api')
13
+ const { startManagementApi } = require('./management-api')
15
14
  const { parseInspectorOptions, wrapConfigInRuntimeConfig } = require('./config')
16
15
  const RuntimeApiClient = require('./api-client.js')
17
16
  const errors = require('./errors')
@@ -25,8 +24,6 @@ const kWorkerExecArgv = [
25
24
  kLoaderFile
26
25
  ]
27
26
 
28
- const PLATFORMATIC_TMP_DIR = join(tmpdir(), 'platformatic', 'pids')
29
-
30
27
  async function startWithConfig (configManager, env = process.env) {
31
28
  const config = configManager.current
32
29
 
@@ -47,10 +44,21 @@ async function startWithConfig (configManager, env = process.env) {
47
44
  // The configManager cannot be transferred to the worker, so remove it.
48
45
  delete config.configManager
49
46
 
47
+ let mainLoggingPort = null
48
+ let childLoggingPort = config.loggingPort
49
+
50
+ if (!childLoggingPort && config.managementApi) {
51
+ const { port1, port2 } = new MessageChannel()
52
+ mainLoggingPort = port1
53
+ childLoggingPort = port2
54
+
55
+ config.loggingPort = childLoggingPort
56
+ }
57
+
50
58
  const worker = new Worker(kWorkerFile, {
51
59
  /* c8 ignore next */
52
60
  execArgv: config.hotReload ? kWorkerExecArgv : [],
53
- transferList: config.loggingPort ? [config.loggingPort] : [],
61
+ transferList: childLoggingPort ? [childLoggingPort] : [],
54
62
  workerData: { config, dirname },
55
63
  env
56
64
  })
@@ -106,7 +114,11 @@ async function startWithConfig (configManager, env = process.env) {
106
114
  const runtimeApiClient = new RuntimeApiClient(worker)
107
115
 
108
116
  if (config.managementApi) {
109
- managementApi = await startManagementApi(config.managementApi, runtimeApiClient)
117
+ managementApi = await startManagementApi(
118
+ configManager,
119
+ runtimeApiClient,
120
+ mainLoggingPort
121
+ )
110
122
  runtimeApiClient.managementApi = managementApi
111
123
  }
112
124
 
@@ -126,38 +138,6 @@ async function start (args) {
126
138
  return serviceStart(config.app, args)
127
139
  }
128
140
 
129
- async function startManagementApi (managementApiConfig, runtimeApiClient) {
130
- const runtimePID = process.pid
131
-
132
- let socketPath = null
133
- if (platform() === 'win32') {
134
- socketPath = '\\\\.\\pipe\\' + join(PLATFORMATIC_TMP_DIR, `${runtimePID}.sock`)
135
- } else {
136
- await mkdir(PLATFORMATIC_TMP_DIR, { recursive: true })
137
- socketPath = join(PLATFORMATIC_TMP_DIR, `${runtimePID}.sock`)
138
- }
139
-
140
- try {
141
- await mkdir(PLATFORMATIC_TMP_DIR, { recursive: true })
142
- await unlink(socketPath).catch((err) => {
143
- if (err.code !== 'ENOENT') {
144
- throw new errors.FailedToUnlinkManagementApiSocket(err.message)
145
- }
146
- })
147
-
148
- const managementApi = await createManagementApi(
149
- managementApiConfig,
150
- runtimeApiClient
151
- )
152
- await managementApi.listen({ path: socketPath })
153
- return managementApi
154
- /* c8 ignore next 4 */
155
- } catch (err) {
156
- console.error(err)
157
- process.exit(1)
158
- }
159
- }
160
-
161
141
  async function startCommand (args) {
162
142
  try {
163
143
  const config = await loadConfig({}, args)
package/lib/worker.js CHANGED
@@ -12,6 +12,7 @@ const {
12
12
  } = require('node:worker_threads')
13
13
  const undici = require('undici')
14
14
  const pino = require('pino')
15
+ const pretty = require('pino-pretty')
15
16
  const { setGlobalDispatcher, Agent } = require('undici')
16
17
  const RuntimeApi = require('./api')
17
18
  const { MessagePortWritable } = require('./message-port-writable')
@@ -33,30 +34,28 @@ globalThis.fetch = undici.fetch
33
34
 
34
35
  const config = workerData.config
35
36
 
36
- let loggerConfig = config.server?.logger
37
- let destination
37
+ const loggerConfig = { ...config.server?.logger }
38
+ const cliStream = isatty(1) ? pretty() : pino.destination(1)
38
39
 
39
- if (loggerConfig) {
40
- loggerConfig = { ...loggerConfig }
41
- } else {
42
- loggerConfig = {}
43
- }
44
-
45
- /* c8 ignore next 10 */
40
+ let logger = null
46
41
  if (config.loggingPort) {
47
- destination = new MessagePortWritable({
42
+ const portStream = new MessagePortWritable({
48
43
  metadata: config.loggingMetadata,
49
44
  port: config.loggingPort
50
45
  })
51
- delete loggerConfig.transport
52
- } else if (!loggerConfig.transport && isatty(1)) {
53
- loggerConfig.transport = {
54
- target: 'pino-pretty'
46
+ const multiStream = pino.multistream([
47
+ { stream: portStream, level: 'trace' },
48
+ { stream: cliStream, level: loggerConfig.level || 'info' }
49
+ ])
50
+ if (loggerConfig.transport) {
51
+ const transport = pino.transport(loggerConfig.transport)
52
+ multiStream.add({ level: loggerConfig.level || 'info', stream: transport })
55
53
  }
54
+ logger = pino({ level: 'trace' }, multiStream)
55
+ } else {
56
+ logger = pino(loggerConfig, cliStream)
56
57
  }
57
58
 
58
- const logger = pino(loggerConfig, destination)
59
-
60
59
  if (config.server) {
61
60
  config.server.logger = logger
62
61
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/runtime",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "homepage": "https://github.com/platformatic/platformatic#readme",
19
19
  "devDependencies": {
20
20
  "@fastify/express": "^2.3.0",
21
+ "@matteo.collina/tspl": "^0.1.1",
21
22
  "borp": "^0.9.0",
22
23
  "c8": "^9.1.0",
23
24
  "execa": "^8.0.1",
@@ -30,11 +31,13 @@
30
31
  "tsd": "^0.30.4",
31
32
  "typescript": "^5.3.3",
32
33
  "undici-oauth-interceptor": "^0.4.2",
33
- "@platformatic/sql-graphql": "1.23.0",
34
- "@platformatic/sql-mapper": "1.23.0"
34
+ "ws": "^8.16.0",
35
+ "@platformatic/sql-mapper": "1.25.0",
36
+ "@platformatic/sql-graphql": "1.25.0"
35
37
  },
36
38
  "dependencies": {
37
39
  "@fastify/error": "^3.4.1",
40
+ "@fastify/websocket": "^9.0.0",
38
41
  "@hapi/topo": "^6.0.2",
39
42
  "boring-name-generator": "^1.0.3",
40
43
  "close-with-grace": "^1.2.0",
@@ -52,13 +55,13 @@
52
55
  "pino-pretty": "^10.3.1",
53
56
  "undici": "^6.6.0",
54
57
  "why-is-node-running": "^2.2.2",
55
- "@platformatic/composer": "1.23.0",
56
- "@platformatic/config": "1.23.0",
57
- "@platformatic/db": "1.23.0",
58
- "@platformatic/service": "1.23.0",
59
- "@platformatic/generators": "1.23.0",
60
- "@platformatic/telemetry": "1.23.0",
61
- "@platformatic/utils": "1.23.0"
58
+ "@platformatic/composer": "1.25.0",
59
+ "@platformatic/config": "1.25.0",
60
+ "@platformatic/generators": "1.25.0",
61
+ "@platformatic/service": "1.25.0",
62
+ "@platformatic/telemetry": "1.25.0",
63
+ "@platformatic/db": "1.25.0",
64
+ "@platformatic/utils": "1.25.0"
62
65
  },
63
66
  "standard": {
64
67
  "ignore": [
package/runtime.mjs CHANGED
@@ -45,5 +45,5 @@ export async function run (argv) {
45
45
  }
46
46
 
47
47
  if (isMain(import.meta)) {
48
- await run(process.argv.splice(2))
48
+ await run(process.argv.slice(2))
49
49
  }