@logux/server 0.13.0 → 0.14.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.
@@ -2,14 +2,13 @@ import { LoguxNotFoundError } from '@logux/actions'
2
2
  import { Log, MemoryStore, parseId, ServerConnection } from '@logux/core'
3
3
  import { createNanoEvents } from 'nanoevents'
4
4
  import { nanoid } from 'nanoid'
5
- import { promises as fs } from 'node:fs'
5
+ import { readFile } from 'node:fs/promises'
6
6
  import { dirname, join } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import UrlPattern from 'url-pattern'
9
9
  import { WebSocketServer } from 'ws'
10
10
 
11
- import { bindBackendProxy } from '../bind-backend-proxy/index.js'
12
- import { bindControlServer } from '../bind-control-server/index.js'
11
+ import { addHttpPages } from '../add-http-pages/index.js'
13
12
  import { Context } from '../context/index.js'
14
13
  import { createHttpServer } from '../create-http-server/index.js'
15
14
  import { ServerClient } from '../server-client/index.js'
@@ -24,17 +23,6 @@ function optionError(msg) {
24
23
  throw error
25
24
  }
26
25
 
27
- let helloCache
28
- async function readHello() {
29
- if (!helloCache) {
30
- let hello = await fs.readFile(
31
- join(fileURLToPath(import.meta.url), '..', 'hello.html')
32
- )
33
- helloCache = hello.toString()
34
- }
35
- return helloCache
36
- }
37
-
38
26
  export async function wasNot403(cb) {
39
27
  try {
40
28
  await cb()
@@ -103,17 +91,11 @@ export class BaseServer {
103
91
  this.options = opts
104
92
  this.env = this.options.env || process.env.NODE_ENV || 'development'
105
93
 
106
- if (
107
- typeof this.options.subprotocol === 'undefined' &&
108
- typeof this.options.backend === 'undefined'
109
- ) {
94
+ if (typeof this.options.subprotocol === 'undefined') {
110
95
  throw optionError('Missed `subprotocol` option in server constructor')
111
96
  }
112
- if (
113
- typeof this.options.supports === 'undefined' &&
114
- typeof this.options.backend === 'undefined'
115
- ) {
116
- throw optionError('Missed `supports` option in server constructor')
97
+ if (typeof this.options.minSubprotocol === 'undefined') {
98
+ throw optionError('Missed `minSubprotocol` option in server constructor')
117
99
  }
118
100
 
119
101
  if (this.options.key && !this.options.cert) {
@@ -135,7 +117,9 @@ export class BaseServer {
135
117
  }
136
118
 
137
119
  this.options.root = this.options.root || process.cwd()
138
- this.options.controlMask = this.options.controlMask || '127.0.0.1/8'
120
+ if (typeof this.options.port === 'string') {
121
+ this.options.port = parseInt(this.options.port, 10)
122
+ }
139
123
 
140
124
  let store = this.options.store || new MemoryStore()
141
125
 
@@ -167,7 +151,6 @@ export class BaseServer {
167
151
  }
168
152
  if (
169
153
  !isLogux &&
170
- !this.options.backend &&
171
154
  !this.types[action.type] &&
172
155
  !this.getRegexProcessor(action.type)
173
156
  ) {
@@ -339,27 +322,11 @@ export class BaseServer {
339
322
  this.queues = new Map()
340
323
  this.actionToQueue = new Map()
341
324
 
342
- this.controls = {
343
- 'GET /': {
344
- async request() {
345
- return {
346
- body: await readHello(),
347
- headers: { 'Content-Type': 'text/html' }
348
- }
349
- },
350
- safe: true
351
- },
352
- 'GET /health': {
353
- request: () => ({
354
- body: 'Logux Server: OK\n',
355
- headers: { 'Content-Type': 'text/plain' }
356
- }),
357
- safe: true
358
- }
359
- }
325
+ this.httpListeners = {}
326
+ this.httpAllListeners = []
327
+ addHttpPages(this)
360
328
 
361
329
  this.listenNotes = {}
362
- bindBackendProxy(this)
363
330
 
364
331
  let end = (actionId, queue, queueKey, ...args) => {
365
332
  this.actionToQueue.delete(actionId)
@@ -555,13 +522,17 @@ export class BaseServer {
555
522
  return undefined
556
523
  }
557
524
 
558
- http(listener) {
525
+ http(method, url, listener) {
559
526
  if (this.options.disableHttpServer) {
560
527
  throw new Error(
561
528
  '`server.http()` can not be called when `disableHttpServer` enabled'
562
529
  )
563
530
  }
564
- this.httpListener = listener
531
+ if (!url) {
532
+ this.httpAllListeners.push(method)
533
+ } else {
534
+ this.httpListeners[`${method} ${url}`] = listener
535
+ }
565
536
  }
566
537
 
567
538
  internalUnknownType(action, meta) {
@@ -618,29 +589,39 @@ export class BaseServer {
618
589
  this.httpServer.listen(this.options.port, this.options.host, resolve)
619
590
  })
620
591
  }
621
- bindControlServer(this, this.httpListener)
622
592
 
623
- this.unbind.push(
624
- () =>
625
- new Promise(resolve => {
626
- this.ws.on('close', resolve)
627
- this.ws.close()
628
- })
629
- )
630
- if (this.httpServer) {
631
- this.unbind.push(
632
- () =>
633
- new Promise(resolve => {
634
- this.httpServer.on('close', resolve)
635
- this.httpServer.close()
636
- })
637
- )
593
+ let processing = 0
594
+ let waiting
595
+ this.unbind.push(() => {
596
+ return new Promise(resolve => {
597
+ let end = () => {
598
+ this.ws.close(resolve)
599
+ this.httpServer.close()
600
+ }
601
+ if (processing === 0) {
602
+ end()
603
+ } else {
604
+ waiting = end
605
+ }
606
+ })
607
+ })
608
+
609
+ if (!this.options.disableHttpServer) {
610
+ this.httpServer.on('request', async (req, res) => {
611
+ if (this.destroying) {
612
+ res.writeHead(503, { 'Content-Type': 'text/plain' })
613
+ res.end('The server is shutting down\n')
614
+ return
615
+ }
616
+ processing += 1
617
+ await this.processHttp(req, res)
618
+ processing -= 1
619
+ if (processing === 0 && waiting) waiting()
620
+ })
638
621
  }
639
622
 
640
623
  let pkg = JSON.parse(
641
- await fs.readFile(
642
- join(fileURLToPath(import.meta.url), '..', '..', 'package.json')
643
- )
624
+ await readFile(join(import.meta.dirname, '..', 'package.json'))
644
625
  )
645
626
 
646
627
  this.ws.on('connection', (ws, req) => {
@@ -648,20 +629,17 @@ export class BaseServer {
648
629
  this.addClient(new ServerConnection(ws))
649
630
  })
650
631
  this.emitter.emit('report', 'listen', {
651
- backend: this.options.backend,
652
632
  cert: !!this.options.cert,
653
- controlMask: this.options.controlMask,
654
- controlSecret: this.options.controlSecret,
655
633
  environment: this.env,
656
634
  host: this.options.host,
657
635
  loguxServer: pkg.version,
636
+ minSubprotocol: this.options.minSubprotocol,
658
637
  nodeId: this.nodeId,
659
638
  notes: this.listenNotes,
660
639
  port: this.options.port,
661
640
  redis: this.options.redis,
662
641
  server: !!this.options.server,
663
- subprotocol: this.options.subprotocol,
664
- supports: this.options.supports
642
+ subprotocol: this.options.subprotocol
665
643
  })
666
644
  }
667
645
 
@@ -772,6 +750,32 @@ export class BaseServer {
772
750
  this.emitter.emit('processed', action, meta, latency)
773
751
  }
774
752
 
753
+ async processHttp(req, res) {
754
+ let urlString = req.url
755
+ if (/^\/\w+%3F/.test(urlString)) {
756
+ urlString = decodeURIComponent(urlString)
757
+ }
758
+ let reqUrl = new URL(urlString, 'http://localhost')
759
+ let rule = this.httpListeners[req.method + ' ' + reqUrl.pathname]
760
+
761
+ if (!rule) {
762
+ let processed = false
763
+ for (let listener of this.httpAllListeners) {
764
+ let result = await listener(req, res)
765
+ if (result === true) {
766
+ processed = true
767
+ break
768
+ }
769
+ }
770
+ if (!processed) {
771
+ res.writeHead(404, { 'Content-Type': 'text/plain' })
772
+ res.end('Not found\n')
773
+ }
774
+ } else {
775
+ await rule(req, res)
776
+ }
777
+ }
778
+
775
779
  rememberBadAuth(ip) {
776
780
  this.authAttempts[ip] = (this.authAttempts[ip] || 0) + 1
777
781
  this.setTimeout(() => {
@@ -869,6 +873,10 @@ export class BaseServer {
869
873
  }
870
874
  }
871
875
 
876
+ sendOnConnect(loader) {
877
+ this.connectLoader = loader
878
+ }
879
+
872
880
  setTimeout(callback, ms) {
873
881
  this.lastTimeout += 1
874
882
  let id = this.lastTimeout
@@ -1,20 +1,10 @@
1
1
  import type { AnyAction } from '@logux/core'
2
2
 
3
3
  import type { ServerMeta } from '../base-server/index.js'
4
+ import type { ServerClient } from '../server-client/index.js'
4
5
  import type { Server } from '../server/index.js'
5
6
 
6
- /**
7
- * Action context.
8
- *
9
- * ```js
10
- * server.type('FOO', {
11
- * access (ctx, action, meta) {
12
- * return ctx.isSubprotocol('3.x') ? check3(action) : check4(action)
13
- * }
14
- * })
15
- * ```
16
- */
17
- export class Context<Data extends object = {}, Headers extends object = {}> {
7
+ export class ConnectContext<Headers extends object = unknown> {
18
8
  /**
19
9
  * Unique persistence client ID.
20
10
  *
@@ -24,23 +14,6 @@ export class Context<Data extends object = {}, Headers extends object = {}> {
24
14
  */
25
15
  clientId: string
26
16
 
27
- /**
28
- * Open structure to save some data between different steps of processing.
29
- *
30
- * ```js
31
- * server.type('RENAME', {
32
- * access (ctx, action, meta) {
33
- * ctx.data.user = findUser(ctx.userId)
34
- * return ctx.data.user.hasAccess(action.projectId)
35
- * }
36
- * process (ctx, action, meta) {
37
- * return ctx.data.user.rename(action.projectId, action.name)
38
- * }
39
- * })
40
- * ```
41
- */
42
- data: Data
43
-
44
17
  /**
45
18
  * Client’s headers.
46
19
  *
@@ -53,15 +26,6 @@ export class Context<Data extends object = {}, Headers extends object = {}> {
53
26
  */
54
27
  headers: Headers
55
28
 
56
- /**
57
- * Was action created by Logux server.
58
- *
59
- * ```js
60
- * access: (ctx, action, meta) => ctx.isServer
61
- * ```
62
- */
63
- isServer: boolean
64
-
65
29
  /**
66
30
  * Unique node ID.
67
31
  *
@@ -77,10 +41,9 @@ export class Context<Data extends object = {}, Headers extends object = {}> {
77
41
  server: Server
78
42
 
79
43
  /**
80
- * Action creator application subprotocol version in SemVer format.
81
- * Use {@link Context#isSubprotocol} to check it.
44
+ * Action creator application subprotocol version.
82
45
  */
83
- subprotocol: string
46
+ subprotocol: number
84
47
 
85
48
  /**
86
49
  * User ID taken node ID.
@@ -92,32 +55,9 @@ export class Context<Data extends object = {}, Headers extends object = {}> {
92
55
  * }
93
56
  * ```
94
57
  */
95
- userId: 'server' | string
58
+ userId: string
96
59
 
97
- /**
98
- * @param nodeId Unique node ID.
99
- * @param clientId Unique persistence client ID.
100
- * @param userId User ID taken node ID.
101
- * @param subprotocol Action creator application subprotocol version
102
- * in SemVer format.
103
- * @param server Logux server
104
- */
105
- constructor(server: Server, meta: ServerMeta)
106
-
107
- /**
108
- * Check creator subprotocol version. It uses `semver` npm package
109
- * to parse requirements.
110
- *
111
- * ```js
112
- * if (ctx.isSubprotocol('2.x')) {
113
- * useOldAPI()
114
- * }
115
- * ```
116
- *
117
- * @param range npm’s version requirements.
118
- * @returns Is version satisfies requirements.
119
- */
120
- isSubprotocol(range: string): boolean
60
+ constructor(server: Server, client: ServerClient)
121
61
 
122
62
  /**
123
63
  * Send action back to the client.
@@ -135,6 +75,43 @@ export class Context<Data extends object = {}, Headers extends object = {}> {
135
75
  sendBack(action: AnyAction, meta?: Partial<ServerMeta>): Promise<void>
136
76
  }
137
77
 
78
+ /**
79
+ * Action context.
80
+ * ```
81
+ */
82
+ export class Context<
83
+ Data extends object = unknown,
84
+ Headers extends object = unknown
85
+ > extends ConnectContext<Headers> {
86
+ /**
87
+ * Open structure to save some data between different steps of processing.
88
+ *
89
+ * ```js
90
+ * server.type('RENAME', {
91
+ * access (ctx, action, meta) {
92
+ * ctx.data.user = findUser(ctx.userId)
93
+ * return ctx.data.user.hasAccess(action.projectId)
94
+ * }
95
+ * process (ctx, action, meta) {
96
+ * return ctx.data.user.rename(action.projectId, action.name)
97
+ * }
98
+ * })
99
+ * ```
100
+ */
101
+ data: Data
102
+
103
+ /**
104
+ * Was action created by Logux server.
105
+ *
106
+ * ```js
107
+ * access: (ctx, action, meta) => ctx.isServer
108
+ * ```
109
+ */
110
+ isServer: boolean
111
+
112
+ constructor(server: Server, meta: ServerMeta)
113
+ }
114
+
138
115
  /**
139
116
  * Subscription context.
140
117
  *
package/context/index.js CHANGED
@@ -1,23 +1,29 @@
1
1
  import { parseId } from '@logux/core'
2
- import semver from 'semver'
3
2
 
4
3
  export class Context {
5
4
  constructor(server, meta) {
6
5
  this.server = server
7
6
  this.data = {}
8
7
 
9
- let parsed = parseId(meta.id)
10
- this.nodeId = parsed.nodeId
11
- this.userId = parsed.userId
12
- this.clientId = parsed.clientId
13
- this.isServer = this.userId === 'server'
14
-
15
- let client = server.clientIds.get(this.clientId)
16
-
17
- if (meta.subprotocol) {
18
- this.subprotocol = meta.subprotocol
19
- } else if (client) {
8
+ let client
9
+ if (meta.node) {
10
+ client = meta
11
+ this.nodeId = client.nodeId
12
+ this.userId = client.userId
13
+ this.clientId = client.clientId
20
14
  this.subprotocol = client.node.remoteSubprotocol
15
+ } else {
16
+ let parsed = parseId(meta.id)
17
+ this.nodeId = parsed.nodeId
18
+ this.userId = parsed.userId
19
+ this.clientId = parsed.clientId
20
+ this.isServer = this.userId === 'server'
21
+ client = server.clientIds.get(this.clientId)
22
+ if (meta.subprotocol) {
23
+ this.subprotocol = meta.subprotocol
24
+ } else if (client) {
25
+ this.subprotocol = client.node.remoteSubprotocol
26
+ }
21
27
  }
22
28
 
23
29
  if (client) {
@@ -27,10 +33,6 @@ export class Context {
27
33
  }
28
34
  }
29
35
 
30
- isSubprotocol(range) {
31
- return semver.satisfies(this.subprotocol, range)
32
- }
33
-
34
36
  sendBack(action, meta = {}) {
35
37
  return this.server.log.add(action, {
36
38
  clients: [this.clientId],
@@ -1,14 +1,7 @@
1
1
  import os from 'node:os'
2
- import { dirname, join, sep } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import pico from 'picocolors'
5
- import pino from 'pino'
2
+ import { sep } from 'node:path'
6
3
 
7
- const __dirname = dirname(fileURLToPath(import.meta.url))
8
- export const PATH_TO_PRETTIFYING_PINO_TRANSPORT = join(
9
- __dirname,
10
- '../human-formatter/index.js'
11
- )
4
+ import humanFormatter from '../human-formatter/index.js'
12
5
 
13
6
  const ERROR_CODES = {
14
7
  EACCES: (e, environment) => {
@@ -27,13 +20,14 @@ const ERROR_CODES = {
27
20
  }
28
21
  },
29
22
  EADDRINUSE: e => {
23
+ let port = String(e.port)
30
24
  let wayToFix = {
31
25
  darwin: `$ sudo lsof -i ${e.port}\n` + '$ sudo kill -9 `<processid>`',
32
26
  linux:
33
27
  '$ su - root\n' +
34
28
  `# netstat -nlp | grep ${e.port}\n` +
35
29
  'Proto Local Address State PID/Program name\n' +
36
- `tcp 0.0.0.0:${e.port} LISTEN \`777\`/node\n` +
30
+ `tcp 0.0.0.0:${port.padEnd(8)}LISTEN \`777\`/node\n` +
37
31
  '# sudo kill -9 `777`',
38
32
  win32:
39
33
  'Run `cmd.exe` as an administrator\n' +
@@ -49,13 +43,7 @@ const ERROR_CODES = {
49
43
  'or previous version of this server was not killed.\n\n' +
50
44
  (wayToFix[os.platform()] || '')
51
45
  }
52
- },
53
- LOGUX_NO_CONTROL_SECRET: e => ({
54
- msg: e.message,
55
- note:
56
- 'Call `npx nanoid` and set result as `controlSecret` ' +
57
- 'or `LOGUX_CONTROL_SECRET` environment variable'
58
- })
46
+ }
59
47
  }
60
48
 
61
49
  const REPORTERS = {
@@ -134,9 +122,9 @@ const REPORTERS = {
134
122
  let details = {
135
123
  environment: r.environment,
136
124
  loguxServer: r.loguxServer,
125
+ minSubprotocol: r.minSubprotocol,
137
126
  nodeId: r.nodeId,
138
- subprotocol: r.subprotocol,
139
- supports: r.supports
127
+ subprotocol: r.subprotocol
140
128
  }
141
129
 
142
130
  if (r.environment === 'development') {
@@ -155,15 +143,6 @@ const REPORTERS = {
155
143
  details.healthCheck = `${httpProtocol}${r.host}:${r.port}/health`
156
144
  }
157
145
 
158
- if (r.controlSecret) {
159
- details.controlListen = `http://${r.host}:${r.port}/`
160
- details.controlMask = r.controlMask
161
- }
162
-
163
- if (r.backend) {
164
- details.backendSend = r.backend
165
- }
166
-
167
146
  if (r.redis) {
168
147
  details.redis = r.redis
169
148
  }
@@ -191,53 +170,9 @@ const REPORTERS = {
191
170
  msg: 'Wrong channel name'
192
171
  }),
193
172
 
194
- wrongControlIp: () => ({
195
- level: 'warn',
196
- msg: 'IP address of control request do not pass the mask'
197
- }),
198
-
199
- wrongControlSecret: () => ({
200
- level: 'warn',
201
- msg: 'Wrong secret in control request'
202
- }),
203
-
204
173
  zombie: () => ({ level: 'warn', msg: 'Zombie client was disconnected' })
205
174
  }
206
175
 
207
- function createLogger(options) {
208
- if (options.logger === 'human' || options.logger.type === 'human') {
209
- let env = options.env || process.env.NODE_ENV || 'development'
210
- let color =
211
- env !== 'development' ? false : pico.createColors().isColorSupported
212
- let basepath = options.root || process.cwd()
213
- if (basepath.slice(-1) !== sep) basepath += sep
214
-
215
- let logger = pino(
216
- pino.transport({
217
- options: {
218
- basepath,
219
- color,
220
- destination: options.logger.destination
221
- },
222
- target: PATH_TO_PRETTIFYING_PINO_TRANSPORT
223
- })
224
- )
225
-
226
- // NOTE: needed only for tests
227
- logger._basepath = basepath
228
- logger._color = color
229
-
230
- return logger
231
- }
232
- return pino(
233
- {
234
- name: 'logux-server',
235
- timestamp: pino.stdTimeFunctions.isoTime
236
- },
237
- options.logger.stream || pino.destination()
238
- )
239
- }
240
-
241
176
  function cleanFromKeys(obj, regexp, seen) {
242
177
  let result = {}
243
178
  for (let key in obj) {
@@ -270,12 +205,56 @@ export function createReporter(options) {
270
205
  )
271
206
  }
272
207
 
273
- let customLoggerProvided =
274
- typeof options.logger !== 'string' && 'info' in options.logger
275
- if (customLoggerProvided) {
208
+ if (typeof options.logger !== 'string' && 'info' in options.logger) {
276
209
  reporter.logger = options.logger
277
210
  } else {
278
- reporter.logger = createLogger(options)
211
+ let format
212
+ if (options.logger === 'human' || options.logger.type === 'human') {
213
+ let basepath = options.root || process.cwd()
214
+ if (basepath.slice(-1) !== sep) basepath += sep
215
+ format = humanFormatter({ basepath })
216
+ } else {
217
+ format = record => JSON.stringify(record) + '\n'
218
+ }
219
+ let stream = options.logger?.stream ?? process.stderr
220
+
221
+ function createRecord(level, details, msg) {
222
+ /* c8 ignore next 4 */
223
+ if (typeof details === 'string') {
224
+ msg = details
225
+ details = {}
226
+ }
227
+ return {
228
+ level,
229
+ time: new Date().toISOString(),
230
+ pid: process.pid,
231
+ ...details,
232
+ msg
233
+ }
234
+ }
235
+
236
+ reporter.logger = {
237
+ /* c8 ignore next 3 */
238
+ debug(details, msg) {
239
+ stream.write(format(createRecord(20, details, msg)))
240
+ },
241
+ info(details, msg) {
242
+ stream.write(format(createRecord(30, details, msg)))
243
+ },
244
+ warn(details, msg) {
245
+ stream.write(format(createRecord(40, details, msg)))
246
+ },
247
+ error(details, msg) {
248
+ stream.write(format(createRecord(50, details, msg)))
249
+ },
250
+ fatal(details, msg) {
251
+ if (stream.flushSync) {
252
+ stream.flushSync(format(createRecord(60, details, msg)))
253
+ } else {
254
+ stream.write(format(createRecord(60, details, msg)))
255
+ }
256
+ }
257
+ }
279
258
  }
280
259
  return reporter
281
260
  }