@muze-nl/simplystore 0.4.6 → 0.5.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/src/server.mjs CHANGED
@@ -1,15 +1,18 @@
1
1
  import express from 'express'
2
2
  import fs from 'fs'
3
3
  import JSONTag from '@muze-nl/jsontag'
4
+ import WorkerPool from './workerPool.mjs'
5
+ import { Worker } from 'worker_threads'
4
6
  import { fileURLToPath } from 'url'
5
- import path from 'path'
6
- import commands from './commands.mjs'
7
7
  import {appendFile} from './util.mjs'
8
- import {Piscina} from 'piscina'
8
+ import path from 'path'
9
+ import httpStatusCodes from './statusCodes.mjs'
10
+ import writeFileAtomic from 'write-file-atomic'
9
11
 
12
+ const server = express()
10
13
  const __dirname = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
11
-
12
- const server = express()
14
+ let jsontagBuffer = null
15
+ let meta = {}
13
16
 
14
17
  async function main(options) {
15
18
  if (!options) {
@@ -18,80 +21,59 @@ async function main(options) {
18
21
  const port = options.port || 3000
19
22
  const datafile = options.datafile || './data.jsontag'
20
23
  const wwwroot = options.wwwroot || __dirname+'/www'
21
- const commandLog = options.commandlog || './commandlog.jsontag'
22
- const queryWorker = options.queryWorker || __dirname+'/src/worker-query-init.mjs'
23
- const commandWorker = options.commandWorker || __dirname+'/src/worker-command-init.mjs'
24
-
25
- let jsontag = fs.readFileSync(datafile, 'utf-8')
26
-
27
- function initWorkerPool(workerName, size=null) {
28
- let options = {
29
- filename: workerName,
30
- workerData: jsontag
31
- }
32
- if (size) {
33
- options.maxThreads = size
34
- }
35
- return new Piscina(options)
36
- }
24
+ const maxWorkers = options.maxWorkers || 8
25
+ const queryWorker = options.queryWorker || __dirname+'/src/query-worker.mjs'
26
+ const loadWorker = options.loadWorker || __dirname+'/src/load-worker.mjs'
27
+ const commandWorker = options.commandWorker || __dirname+'/src/command-worker.mjs'
28
+ const commandsFile = options.commandsFile || __dirname+'/src/commands.mjs'
29
+ const commandLog = options.commandLog || './command-log.jsontag'
30
+ const commandStatus = options.commandStatus || './command-status.jsontag'
37
31
 
38
- let queryWorkerpool = initWorkerPool(queryWorker)
32
+ server.use(express.static(wwwroot))
39
33
 
40
- const commandWorkerpool = initWorkerPool(commandWorker,1) // only one update worker so no changes can get lost
41
-
42
- server.get('/', (req,res) => {
43
- res.send('<h1>SimplyStore</h1>') //@TODO: implement something nice
44
- })
45
-
46
- server.use(express.static(wwwroot))
47
-
48
- // allow access to raw body, used to parse a query send as post body
34
+ // allow access to raw body, used to parse a query send as post body
49
35
  server.use(express.raw({
50
36
  type: (req) => true // parse body on all requests
51
37
  }))
52
38
 
53
- function accept(req, res, mimetypes, handler) {
54
- let accept = req.accepts(mimetypes)
55
- if (!accept) {
56
- res.status(406)
57
- res.send("<h1>406 Unacceptable</h1>\n")
58
- return false
59
- }
60
- if (typeof handler === 'function') {
61
- return handler(req, res, accept)
62
- }
63
- return true
39
+ function loadData() {
40
+ return new Promise((resolve,reject) => {
41
+ let worker = new Worker(loadWorker)
42
+ worker.on('message', result => {
43
+ resolve(result)
44
+ worker.terminate()
45
+ })
46
+ worker.on('error', error => {
47
+ reject(error)
48
+ worker.terminate()
49
+ })
50
+ worker.postMessage(datafile)
51
+ })
64
52
  }
53
+ try {
54
+ let data = await loadData()
55
+ jsontagBuffer = data.data
56
+ meta = data.meta
57
+ } catch(err) {
58
+ console.error('ERROR: SimplyStore cannot load '+datafile, err)
59
+ process.exit(1)
60
+ }
65
61
 
66
- function sendResponse(response, res) {
67
- if (response.code) {
68
- res.status(response.code)
62
+ const queryWorkerInitTask = () => {
63
+ return {
64
+ name: 'init',
65
+ req: {
66
+ body: jsontagBuffer,
67
+ meta
68
+ }
69
69
  }
70
- if (response.jsontag) {
71
- res.setHeader('content-type','application/jsontag')
72
- } else {
73
- res.setHeader('content-type','application/json')
74
- }
75
- res.send(response.body)+"\n"
76
70
  }
77
71
 
78
- function sendCommandResponse(result, req, res) {
79
- if (result.code) {
80
- res.status(result.code)
81
- }
82
- if (req.accepts('application/jsontag')) {
83
- res.setHeader('content-type','application/jsontag')
84
- res.send(JSONTag.stringify(result, null, 4)+"\n")
85
- } else {
86
- res.setHeader('content-type','application/json')
87
- res.send(JSON.stringify(result, null, 4)+"\n")
88
- }
89
- }
72
+ let queryWorkerPool = new WorkerPool(maxWorkers, queryWorker, queryWorkerInitTask())
90
73
 
91
- server.get('/query/*', (req, res, next) =>
74
+ server.get('/query/*', async (req, res, next) =>
92
75
  {
93
76
  let start = Date.now()
94
-
95
77
  if ( !accept(req,res,
96
78
  ['application/jsontag','application/json','text/html','text/javascript','image/*'],
97
79
  function(req, res, accept) {
@@ -99,7 +81,7 @@ async function main(options) {
99
81
  case 'text/html':
100
82
  case 'image/*':
101
83
  case 'text/javascript':
102
- handleWebRequest(req,res,options);
84
+ handleWebRequest(req,res,{root:wwwroot});
103
85
  return false
104
86
  break
105
87
  }
@@ -109,199 +91,302 @@ async function main(options) {
109
91
  // done
110
92
  return
111
93
  }
112
- let path = req.path.substr(6); // cut '/query'
94
+ let path = req.path.substr(6) // cut '/query'
95
+ console.log('query',path)
113
96
  let request = {
114
- method: req.method,
115
- url: req.originalUrl,
116
- query: req.query,
117
- jsontag: req.accepts('application/jsontag')
97
+ method: req.method,
98
+ url: req.originalUrl,
99
+ query: req.query,
100
+ path: path
118
101
  }
119
- queryWorkerpool.run({pointer:path, request})
120
- .then(response => {
121
- sendResponse(response, res)
122
- let end = Date.now()
123
- console.log(path, (end-start), process.memoryUsage())
124
- })
102
+ if (accept(req,res,['application/jsontag'])) {
103
+ request.jsontag = true
104
+ }
105
+ try {
106
+ let result = await queryWorkerPool.run('query', request)
107
+ sendResponse(result, res)
108
+ } catch(error) {
109
+ sendError(error, res)
110
+ }
111
+ let end = Date.now()
112
+ console.log(path, (end-start), process.memoryUsage())
125
113
  })
126
114
 
127
- /**
128
- * handle queries, query is the post body
129
- */
130
- server.post('/query/*', (req, res) => {
115
+ server.post('/query/*', async (req,res) => {
131
116
  let start = Date.now()
132
117
  if ( !accept(req,res,
133
118
  ['application/jsontag','application/json'])
134
119
  ) {
120
+ sendError({code:406, message:'Not Acceptable',accept:['application/json','application/jsontag']},res)
135
121
  return
136
122
  }
137
- let query = req.body.toString() // raw body through express.raw()
138
- let path = req.path.substr(6); // cut '/query'
123
+ let path = req.path.substr(6) // cut '/query'
139
124
  let request = {
140
- method: req.method,
141
- url: req.originalUrl,
142
- query: req.query,
143
- jsontag: req.accepts('application/jsontag')
125
+ method: req.method,
126
+ url: req.originalUrl,
127
+ query: req.query,
128
+ path: path,
129
+ body: req.body.toString()
130
+ }
131
+ if (accept(req,res,['application/jsontag'])) {
132
+ request.jsontag = true
144
133
  }
145
- queryWorkerpool.run({pointer:path, request, query})
146
- .then(response => {
147
- sendResponse(response, res)
148
- let end = Date.now()
149
- console.log(path, (end-start), process.memoryUsage())
150
- })
134
+ try {
135
+ let result = await queryWorkerPool.run('query', request)
136
+ sendResponse(result, res)
137
+ } catch(error) {
138
+ sendError(error, res)
139
+ }
140
+ let end = Date.now()
141
+ console.log(path, (end-start), process.memoryUsage())
142
+ // queryWorkerPool.memoryUsage()
151
143
  })
152
144
 
153
- let status = new Map()
154
145
 
155
- server.get('/command/:id', (req, res) => {
156
- //@TODO: find the status of command with :id
157
- //return that
158
- if (status.has(req.params.id)) {
159
- let result = status.get(req.params.id)
160
- sendResponse({
161
- jsontag: false,
162
- body: JSON.stringify(result)
163
- },res)
164
- } else {
165
- sendResponse({
166
- code: 404,
167
- jsontag: false,
168
- body: JSON.stringify({"code":404,"message":"Command not found"})
169
- }, res)
146
+ let status = loadCommandStatus(commandStatus)
147
+
148
+ function loadCommandStatus(commandStatusFile) {
149
+ let status = new Map()
150
+ if (fs.existsSync(commandStatusFile)) {
151
+ let file = fs.readFileSync(commandStatusFile, 'utf-8')
152
+ if (file) {
153
+ let lines = file.split("\n").filter(Boolean) //filter clears empty lines
154
+ for(let line of lines) {
155
+ let command = JSONTag.parse(line)
156
+ status.set(command.id, command.status)
157
+ }
158
+ }
170
159
  }
171
- })
160
+ return status
161
+ }
172
162
 
173
- server.post('/command', async (req, res) => {
174
- try {
175
- let start = Date.now()
176
- if ( !accept(req,res,
177
- ['application/jsontag','application/json'])
178
- ) {
179
- return
163
+ let commandQueue = []
164
+
165
+ function loadCommandLog(commandLog) {
166
+ if (!fs.existsSync(commandLog)) {
167
+ return
168
+ }
169
+ let log = fs.readFileSync(commandLog)
170
+ if (log) {
171
+ let lines = log.split("\n")
172
+ for(let line of lines) {
173
+ let command = JSONTag.parse(line)
174
+ let state = status.get(command.id)
175
+ switch(state) {
176
+ case 'accepted': // enqueue
177
+ commandQueue.push(command)
178
+ break;
179
+ case 'done': // do nothing
180
+ break;
181
+ default: // error, do nothing
182
+ break;
183
+ }
180
184
  }
181
- let error, result
185
+ }
186
+ }
182
187
 
183
- let commandStr = req.body.toString() // raw body through express.raw()
184
- let command = JSONTag.parse(commandStr)
185
- console.log('command',command)
186
- if (!command || !command.id) {
187
- error = {
188
- code: 422,
189
- message: "Command has no id"
190
- }
191
- sendCommandResponse(error, req, res)
192
- return
193
- } else if (status.has(command.id)) {
194
- result = "OK"
195
- sendCommandResponse(result, req, res)
196
- return
197
- } else if (!command.name || !commands[command.name]) {
198
- error = {
199
- code: 422,
200
- message: "Command has no name or is unknown"
188
+ loadCommandLog()
189
+ let commandWorkerInstance
190
+
191
+ async function runNextCommand() {
192
+ let command = commandQueue.shift()
193
+ if (command) {
194
+ let start = (resolve, reject) => {
195
+ if (!commandWorkerInstance) {
196
+ commandWorkerInstance = new Worker(commandWorker)
201
197
  }
202
- sendCommandResponse(error, req, res)
203
- return
198
+ commandWorkerInstance.on('message', result => {
199
+ resolve(result)
200
+ runNextCommand()
201
+ })
202
+ commandWorkerInstance.on('error', error => {
203
+ reject(error)
204
+ runNextCommand()
205
+ })
206
+ commandWorkerInstance.postMessage(command)
204
207
  }
205
- // catch appendFile errors?
206
- await appendFile(commandLog, JSONTag.stringify(command))
207
-
208
- status.set(command.id, 'queued')
209
- console.log('command',command)
208
+ start(
209
+ // resolve()
210
+ (data) => {
211
+ if (!data || data.error) {
212
+ console.error('ERROR: SimplyStore cannot run command ', command.id, err)
213
+ if (!data.error) {
214
+ status.set(command.id, 'failed')
215
+ throw new Error('Unexpected command failure')
216
+ } else {
217
+ status.set(command.id, data.error)
218
+ throw data.error
219
+ }
220
+ }
221
+ jsontagBuffer = data.data
222
+ meta = data.meta
223
+ status.set(command.id, 'done')
224
+ appendFile(commandStatus, JSONTag.stringify({command:command.id, status: 'done'}))
225
+ // restart query workers with new data
226
+ let oldPool = queryWorkerPool
227
+ queryWorkerPool = new WorkerPool(maxWorkers, queryWorker, queryWorkerInitTask())
228
+ setTimeout(() => {
229
+ oldPool.close()
230
+ }, 2000)
231
+ },
232
+ //reject()
233
+ (error) => {
234
+ status.set(command.id, error)
235
+ appendFile(commandStatus, JSONTag.stringify({command:command.id, status: error}))
236
+ }
237
+ )
238
+ } else {
239
+ // this code can never be triggered from the post(/command/) route, since it always adds a command to the queue
240
+ // so you can only get here from commandWorkerInstance.on() route
241
+ // which means that the commandWorkerInstance has finished running the previous command
242
+ await commandWorkerInstance.terminate()
243
+ commandWorkerInstance.unref() // @FIXME is this needed?
244
+ commandWorkerInstance = null // @FIXME or this?
245
+ }
246
+ }
210
247
 
211
- result = "OK"
212
- sendCommandResponse(result, req, res)
248
+ server.post('/command', async (req, res) => {
249
+ let commandId = checkCommand(req, res)
250
+ if (!commandId) {
251
+ return
252
+ }
253
+ let commandStr = req.body.toString()
254
+ try {
213
255
  let request = {
214
256
  method: req.method,
215
257
  url: req.originalUrl,
216
- query: req.query,
217
- jsontag: req.accepts('application/jsontag')
258
+ query: req.query
218
259
  }
219
260
 
220
- commandWorkerpool
221
- .run({request, commandStr})
222
- .then(response => {
223
- //@TODO: add try/catch here as well
224
- //@TODO store response status, if response.code => error
225
- if (!response.code) {
226
- jsontag = response.body // global jsontag
227
- let dataspace = JSONTag.parse(jsontag)
228
- //@TODO: make sure queryWorkerpool is only replaced after
229
- //workers are initialized, to prevent hickups if initialization takes a long time
230
- let newQueryWorkerpool = initWorkerPool(queryWorker)
231
- queryWorkerpool.destroy() // gracefully
232
- queryWorkerpool = newQueryWorkerpool
233
- //@TODO: write dataspace to disk
234
- status.set(command.id, 'done')
235
- let end = Date.now()
236
- console.log(command.name, (end-start), process.memoryUsage())
237
- }
238
- })
239
- .catch(err => {
240
- console.error(err)
241
- //@TODO: set status for this command to error with this err
242
- status.set(command.id, err)
261
+ commandQueue.push({
262
+ id:commandId,
263
+ command:commandStr,
264
+ request,
265
+ meta,
266
+ data:jsontagBuffer,
267
+ commandsFile,
268
+ datafile
243
269
  })
270
+
271
+ runNextCommand()
244
272
  } catch(err) {
245
- console.error(err)
246
- res.status(500)
247
- res.send(err)
273
+ appendFile(commandStatus, JSONTag.stringify({command:commandId, status: 'ERROR: '+err.message}))
274
+ status.set(commandId, 'ERROR: '+err.message)
275
+ console.error('ERROR: SimplyStore cannot run command ', commandId, err)
248
276
  }
249
277
  })
250
278
 
251
- function handleWebRequest(req,res,options)
252
- {
253
- let path = req.path;
254
- path = path.replace(/[^a-z0-9_\.\-\/]*/gi, '') // whitelist acceptable file paths
255
- path = path.replace(/\.+/g, '.') // blacklist '..'
256
- if (!path) {
257
- path = '/'
258
- }
259
- if (path.substring(path.length-1)==='/') {
260
- path += 'index.html'
261
- }
262
- const fileOptions = {
263
- root: options.root || wwwroot
264
- }
265
- if (fs.existsSync(fileOptions.root+path)) {
266
- res.sendFile(path, fileOptions)
267
- } else {
268
- res.sendFile('/index.html', fileOptions)
279
+ function checkCommand(req, res) {
280
+ let commandStr = req.body.toString() // raw body through express.raw()
281
+ let command = JSONTag.parse(commandStr)
282
+ if (!command || !command.id) {
283
+ error = {
284
+ code: 422,
285
+ message: "Command has no id"
286
+ }
287
+ sendResponse({code: 422, body: JSON.stringify(error)}, res)
288
+ return false
289
+ } else if (status.has(command.id)) {
290
+ result = "OK"
291
+ sendResponse({body: JSON.stringify(result)}, res)
292
+ return false
293
+ } else if (!command.name) {
294
+ error = {
295
+ code: 422,
296
+ message: "Command has no name"
297
+ }
298
+ sendResponse({code:422, body: JSON.stringify(error)}, res)
299
+ return false
269
300
  }
301
+ appendFile(commandLog, JSONTag.stringify(command))
302
+ appendFile(commandStatus, JSONTag.stringify({
303
+ command: command.id,
304
+ status: 'accepted'
305
+ }))
306
+ status.set(command.id, 'accepted')
307
+ sendResponse({code: 202, body: '"Accepted"'}, res)
308
+ return command.id
270
309
  }
271
310
 
272
- function linkReplacer(data, baseURL) {
273
- let type = JSONTag.getType(data)
274
- let attributes = JSONTag.getAttributes(data)
275
- if (Array.isArray(data)) {
276
- data = data.map((entry,index) => {
277
- return linkReplacer(data[index], baseURL+index+'/')
278
- })
279
- } else if (type === 'link') {
280
- // do nothing
281
- } else if (data && typeof data === 'object') {
282
- data = JSONTag.clone(data)
283
- Object.keys(data).forEach(key => {
284
- if (Array.isArray(data[key])) {
285
- data[key] = new JSONTag.Link(baseURL+key+'/')
286
- } else if (typeof data[key] === 'object') {
287
- if (JSONTag.getType(data[key])!=='link') {
288
- let id=JSONTag.getAttribute(data[key], 'id')
289
- if (!id) {
290
- id = baseURL+key+'/'
291
- }
292
- data[key] = new JSONTag.Link(id)
293
- }
294
- }
295
- })
311
+ server.get('/command/:id', (req, res) => {
312
+ if (status.has(req.params.id)) {
313
+ let result = status.get(req.params.id)
314
+ sendResponse({
315
+ jsontag: false,
316
+ body: JSON.stringify(result)
317
+ },res)
318
+ } else {
319
+ sendResponse({
320
+ code: 404,
321
+ jsontag: false,
322
+ body: JSON.stringify({code: 404, message: "Command not found"})
323
+ }, res)
296
324
  }
297
- return data
298
- }
325
+ })
299
326
 
300
327
  server.listen(port, () => {
301
328
  console.log('SimplyStore listening on port '+port)
329
+ let used = Math.round(process.memoryUsage().rss / 1024 / 1024);
330
+ console.log(`(${used} MB)`);
302
331
  })
332
+ }
303
333
 
334
+ function sendResponse(response, res) {
335
+ if (response.code && httpStatusCodes[response.code]) {
336
+ res.status(response.code)
337
+ }
338
+ if (response.jsontag) {
339
+ res.setHeader('content-type','application/jsontag')
340
+ } else {
341
+ res.setHeader('content-type','application/json')
342
+ }
343
+ res.send(response.body)+"\n"
344
+ }
345
+
346
+ function sendError(error, res) {
347
+ console.error(error)
348
+ if (error.code && httpStatusCodes[error.code]) {
349
+ res.status(error.code)
350
+ } else {
351
+ res.status(500)
352
+ }
353
+ res.setHeader('content-type','application/json')
354
+ res.send(JSON.stringify(error))
304
355
  }
305
356
 
306
357
  server.run = main
307
- export default server
358
+ export default server
359
+
360
+ function accept(req, res, mimetypes, handler) {
361
+ let accept = req.accepts(mimetypes)
362
+ if (!accept) {
363
+ res.status(406)
364
+ res.send("<h1>406 Unacceptable</h1>\n")
365
+ return false
366
+ }
367
+ if (typeof handler === 'function') {
368
+ return handler(req, res, accept)
369
+ }
370
+ return true
371
+ }
372
+
373
+ function handleWebRequest(req,res,options)
374
+ {
375
+ let path = req.path;
376
+ path = path.replace(/[^a-z0-9_\.\-\/]*/gi, '') // whitelist acceptable file paths
377
+ path = path.replace(/\.+/g, '.') // blacklist '..'
378
+ if (!path) {
379
+ path = '/'
380
+ }
381
+ if (path.substring(path.length-1)==='/') {
382
+ path += 'index.html'
383
+ }
384
+ const fileOptions = {
385
+ root: options.root
386
+ }
387
+ if (fs.existsSync(fileOptions.root+path)) {
388
+ res.sendFile(path, fileOptions)
389
+ } else {
390
+ res.sendFile('/index.html', fileOptions)
391
+ }
392
+ }