@logux/server 0.14.0 → 0.15.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.
@@ -1,8 +1,9 @@
1
1
  import os from 'node:os'
2
2
  import { stripVTControlCharacters, styleText } from 'node:util'
3
3
 
4
- import { mulberry32, onceXmur3 } from './utils.js'
4
+ import { isBinary, mulberry32, onceXmur3 } from './utils.js'
5
5
 
6
+ const BINARY_MARK = '[binary]'
6
7
  const INDENT = ' '
7
8
  const PADDING = ' '
8
9
  const SEPARATOR = os.EOL + os.EOL
@@ -119,7 +120,9 @@ function formatNodeId(nodeId) {
119
120
  }
120
121
 
121
122
  function formatValue(value) {
122
- if (typeof value === 'string') {
123
+ if (isBinary(value)) {
124
+ return styleText('bold', BINARY_MARK)
125
+ } else if (typeof value === 'string') {
123
126
  return '"' + styleText('bold', value) + '"'
124
127
  } else if (Array.isArray(value)) {
125
128
  return formatArray(value)
@@ -145,10 +148,7 @@ function formatActionId(id) {
145
148
  if (p.length === 1) {
146
149
  return p
147
150
  }
148
- return (
149
- `${styleText('bold', splitAndColorize(4, p[0]))} ` +
150
- `${formatNodeId(p[1])} ${styleText('bold', p[2])}`
151
- )
151
+ return `${styleText('bold', splitAndColorize(4, p[0]))} ${formatNodeId(p[1])}`
152
152
  }
153
153
 
154
154
  function formatParams(params, parent) {
@@ -164,7 +164,12 @@ function formatParams(params, parent) {
164
164
 
165
165
  let start = PADDING + rightPag(`${name}: `, maxName + 2)
166
166
 
167
- if (name === 'Node ID' || (parent === 'Meta' && name === 'server')) {
167
+ if (isBinary(value)) {
168
+ return start + styleText('bold', BINARY_MARK)
169
+ } else if (
170
+ name === 'Node ID' ||
171
+ (parent === 'Meta' && name === 'server')
172
+ ) {
168
173
  return start + formatNodeId(value)
169
174
  } else if (
170
175
  parent === 'Meta' &&
@@ -1,3 +1,17 @@
1
+ export const BINARY = {
2
+ toJSON() {
3
+ return '[binary]'
4
+ }
5
+ }
6
+
7
+ export function isBinary(value) {
8
+ return (
9
+ value === BINARY ||
10
+ ArrayBuffer.isView(value) ||
11
+ value instanceof ArrayBuffer
12
+ )
13
+ }
14
+
1
15
  export function onceXmur3(str) {
2
16
  let h = 1779033703 ^ str.length
3
17
  for (let i = 0; i < str.length; i++) {
package/index.d.ts CHANGED
@@ -16,7 +16,7 @@ export {
16
16
  ServerMeta,
17
17
  wasNot403
18
18
  } from './base-server/index.js'
19
- export { ChannelContext, Context } from './context/index.js'
19
+ export { Context, ConnectContext, ChannelContext } from './context/index.js'
20
20
  export { filterMeta } from './filter-meta/index.js'
21
21
  export {
22
22
  del,
package/package.json CHANGED
@@ -1,25 +1,22 @@
1
1
  {
2
2
  "name": "@logux/server",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Build own Logux server",
5
5
  "keywords": [
6
- "logux",
7
- "server",
8
- "framework",
9
- "websocket",
10
- "proxy",
6
+ "collaborative",
11
7
  "crdt",
12
- "event sourcing",
13
8
  "distributed systems",
14
- "collaborative"
9
+ "event sourcing",
10
+ "framework",
11
+ "logux",
12
+ "proxy",
13
+ "server",
14
+ "websocket"
15
15
  ],
16
- "author": "Andrey Sitnik <andrey@sitnik.ru>",
17
- "license": "MIT",
18
16
  "homepage": "https://logux.org/",
17
+ "license": "MIT",
18
+ "author": "Andrey Sitnik <andrey@sitnik.es>",
19
19
  "repository": "logux/server",
20
- "engines": {
21
- "node": "^20.0.0 || >=22.0.0"
22
- },
23
20
  "type": "module",
24
21
  "types": "./index.d.ts",
25
22
  "exports": {
@@ -27,14 +24,15 @@
27
24
  "./package.json": "./package.json"
28
25
  },
29
26
  "dependencies": {
30
- "@logux/actions": "^0.5.0",
31
- "@logux/core": "^0.10.0",
32
- "cookie": "^1.0.2",
33
- "fastq": "^1.19.1",
34
- "nanoevents": "^9.1.0",
35
- "nanoid": "^5.1.5",
36
- "tinyglobby": "^0.2.14",
37
- "url-pattern": "^1.0.3",
38
- "ws": "^8.18.3"
27
+ "@logux/actions": "^0.6.0",
28
+ "@logux/core": "^0.11.0",
29
+ "cookie": "^2.0.1",
30
+ "fastq": "^1.20.1",
31
+ "nanoevents": "^10.0.0",
32
+ "nanoid": "^6.0.1",
33
+ "ws": "^8.21.3"
34
+ },
35
+ "engines": {
36
+ "node": "^22.5.0 || ^24.0.0 || >=26.0.0"
39
37
  }
40
38
  }
package/server/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
- import {
1
+ import type { Log } from '@logux/core'
2
+
3
+ import type {
2
4
  BaseServer,
3
- type BaseServerOptions,
4
- type Logger
5
+ BaseServerOptions,
6
+ Logger,
7
+ ServerMeta
5
8
  } from '../base-server/index.js'
6
9
 
7
10
  export interface LogStream {
@@ -62,8 +65,9 @@ export interface ServerOptions extends BaseServerOptions {
62
65
  * ```
63
66
  */
64
67
  export class Server<
65
- Headers extends object = unknown
66
- > extends BaseServer<Headers> {
68
+ Headers extends object = unknown,
69
+ ClientData extends object = unknown
70
+ > extends BaseServer<Headers, Log<ServerMeta>, ClientData> {
67
71
  /**
68
72
  * Server options.
69
73
  *
package/server/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { join, relative } from 'node:path'
1
+ import { glob } from 'node:fs/promises'
2
+ import { join, matchesGlob, relative } from 'node:path'
2
3
  import { styleText } from 'node:util'
3
- import { glob } from 'tinyglobby'
4
4
 
5
5
  import { BaseServer } from '../base-server/index.js'
6
6
  import { createReporter } from '../create-reporter/index.js'
@@ -120,14 +120,23 @@ export class Server extends BaseServer {
120
120
  files = ['modules/*/index.js', 'modules/*.js', '!**/*.{test,spec}.js']
121
121
  ) {
122
122
  if (!Array.isArray(files)) files = [files]
123
- let matches = await glob(files, {
124
- absolute: true,
123
+ let ignore = files.filter(i => i.startsWith('!')).map(i => i.slice(1))
124
+ let patterns = files.filter(i => !i.startsWith('!'))
125
+
126
+ let matches = new Set()
127
+ for await (let entry of glob(patterns, {
125
128
  cwd: this.options.root,
126
- onlyFiles: true
127
- })
129
+ withFileTypes: true
130
+ })) {
131
+ if (!entry.isFile()) continue
132
+ let file = join(entry.parentPath, entry.name)
133
+ let path = relative(this.options.root, file)
134
+ if (ignore.some(i => matchesGlob(path, i))) continue
135
+ matches.add(file)
136
+ }
128
137
 
129
138
  await Promise.all(
130
- matches.map(async file => {
139
+ [...matches].map(async file => {
131
140
  let serverModule = (await import(file)).default
132
141
  if (typeof serverModule === 'function') {
133
142
  await serverModule(this)
@@ -9,7 +9,7 @@ import type { BaseServer } from '../base-server/index.js'
9
9
  * const client = server.connected.get(0)
10
10
  * ```
11
11
  */
12
- export class ServerClient {
12
+ export class ServerClient<ClientData extends object = unknown> {
13
13
  /**
14
14
  * Server, which received client.
15
15
  */
@@ -30,6 +30,23 @@ export class ServerClient {
30
30
  */
31
31
  connection: ServerConnection
32
32
 
33
+ /**
34
+ * Open structure to save data for the whole connection.
35
+ *
36
+ * ```js
37
+ * server.auth(async ({ client, userId, token }) => {
38
+ * let session = await findSession(token)
39
+ * if (!session) return false
40
+ * client.data.sessionId = session.id
41
+ * return true
42
+ * })
43
+ * server.on('disconnected', client => {
44
+ * touchSession(client.data.sessionId)
45
+ * })
46
+ * ```
47
+ */
48
+ data: ClientData
49
+
33
50
  /**
34
51
  * HTTP headers of WS connection.
35
52
  *
@@ -100,4 +117,11 @@ export class ServerClient {
100
117
  * Disconnect client.
101
118
  */
102
119
  destroy(): void
120
+
121
+ /**
122
+ * Wait until the client will confirm all sent actions.
123
+ *
124
+ * @returns Promise with `false` if the client was disconnected.
125
+ */
126
+ drain(): Promise<boolean>
103
127
  }
@@ -1,5 +1,5 @@
1
1
  import { LoguxError, parseId } from '@logux/core'
2
- import cookie from 'cookie'
2
+ import { parseCookie } from 'cookie'
3
3
  import fastq from 'fastq'
4
4
 
5
5
  import { ALLOWED_META } from '../allowed-meta/index.js'
@@ -11,6 +11,8 @@ async function onSend(action, meta) {
11
11
  return [action, filterMeta(meta)]
12
12
  }
13
13
 
14
+ function ignore() {}
15
+
14
16
  function reportDetails(client) {
15
17
  return {
16
18
  connectionId: client.key,
@@ -70,7 +72,9 @@ export class ServerClient {
70
72
  this.userId = undefined
71
73
  this.clientId = undefined
72
74
  this.nodeId = undefined
75
+ this.data = {}
73
76
  this.processing = false
77
+ this.sending = new Set()
74
78
  this.connection = connection
75
79
  this.key = key.toString()
76
80
  if (connection.ws) {
@@ -89,6 +93,7 @@ export class ServerClient {
89
93
  onSend,
90
94
  ping: app.options.ping,
91
95
  subprotocol: app.options.subprotocol,
96
+ syncBatch: app.options.syncBatch,
92
97
  timeout: app.options.timeout
93
98
  })
94
99
  if (this.app.env === 'development') {
@@ -99,11 +104,12 @@ export class ServerClient {
99
104
  this.node.syncSinceQuery = async lastSynced => {
100
105
  let context = new Context(app, this)
101
106
  let entries = await app.connectLoader(context, lastSynced)
102
- let added = entries.reduce((max, entry) => {
103
- let meta = filterMeta(entry[1])
104
- entry[1] = meta
105
- return meta.added > max ? meta.added : max
106
- }, 0)
107
+ let added = 0
108
+ for (let entry of entries) {
109
+ // `added` should be taken before `filterMeta()` removes it
110
+ if (entry[1].added > added) added = entry[1].added
111
+ entry[1] = filterMeta(entry[1])
112
+ }
107
113
  return { added, entries }
108
114
  }
109
115
  }
@@ -157,7 +163,7 @@ export class ServerClient {
157
163
  try {
158
164
  result = await this.app.authenticator({
159
165
  client: this,
160
- cookie: cookie.parse(headers.cookie || ''),
166
+ cookie: parseCookie(headers.cookie || ''),
161
167
  headers: this.node.remoteHeaders,
162
168
  token,
163
169
  userId: this.userId
@@ -239,6 +245,23 @@ export class ServerClient {
239
245
  this.app.connected.delete(this.key)
240
246
  }
241
247
 
248
+ async drain() {
249
+ if (this.sending.size > 0) await Promise.all(this.sending)
250
+ if (this.destroyed || !this.node.connected) return false
251
+ if (this.node.syncing === 0) return true
252
+ return new Promise(resolve => {
253
+ let unbind = this.node.on('state', () => {
254
+ if (this.node.state === 'synchronized') {
255
+ unbind()
256
+ resolve(true)
257
+ } else if (!this.node.connected) {
258
+ unbind()
259
+ resolve(false)
260
+ }
261
+ })
262
+ })
263
+ }
264
+
242
265
  onReceive(action, meta) {
243
266
  if (this.app.actionToQueue.has(meta.id)) {
244
267
  return Promise.resolve(false)
@@ -263,8 +286,10 @@ export class ServerClient {
263
286
 
264
287
  if (isChannel) {
265
288
  for (let channel of this.app.channels) {
266
- let pattern = channel.regexp || channel.pattern.regex
267
- if (action.channel.match(pattern)) {
289
+ let match = channel.regexp
290
+ ? action.channel.match(channel.regexp)
291
+ : channel.pattern(action.channel)
292
+ if (match) {
268
293
  queueName = channel.queue
269
294
  break
270
295
  }
@@ -299,4 +324,12 @@ export class ServerClient {
299
324
  })
300
325
  })
301
326
  }
327
+
328
+ track(sending) {
329
+ let done = Promise.resolve(sending).then(ignore, ignore)
330
+ this.sending.add(done)
331
+ void done.then(() => {
332
+ this.sending.delete(done)
333
+ })
334
+ }
302
335
  }
@@ -141,7 +141,7 @@ export class TestClient {
141
141
  * @param test Function, where do you expect action will be received
142
142
  * @returns Promise with all received actions
143
143
  */
144
- collect(test: () => Promise<any>): Promise<Action[]>
144
+ collect(test: () => Promise<unknown>): Promise<Action[]>
145
145
 
146
146
  /**
147
147
  * Connect to test server.
@@ -180,7 +180,10 @@ export class TestClient {
180
180
  * @param meta Optional action’s meta.
181
181
  * @returns Promise until `logux/processed` answer.
182
182
  */
183
- process(action: AnyAction, meta?: Partial<ServerMeta>): Promise<Action[]>
183
+ process<TypeAction extends Action = AnyAction>(
184
+ action: TypeAction,
185
+ meta?: Partial<ServerMeta>
186
+ ): Promise<Action[]>
184
187
 
185
188
  /**
186
189
  * Collect actions received from server during the `test` call.
@@ -1,5 +1,5 @@
1
1
  import { ClientNode, TestPair } from '@logux/core'
2
- import cookie from 'cookie'
2
+ import { stringifyCookie } from 'cookie'
3
3
  import { setTimeout } from 'node:timers/promises'
4
4
 
5
5
  import { filterMeta } from '../filter-meta/index.js'
@@ -34,9 +34,9 @@ export class TestClient {
34
34
  this.node.setLocalHeaders(opts.headers)
35
35
  }
36
36
  if (opts.cookie) {
37
- this.pair.right.ws.upgradeReq.headers.cookie = Object.keys(opts.cookie)
38
- .map(i => cookie.serialize(i, opts.cookie[i]))
39
- .join('; ')
37
+ this.pair.right.ws.upgradeReq.headers.cookie = stringifyCookie(
38
+ opts.cookie
39
+ )
40
40
  }
41
41
  server.unbind.push(() => {
42
42
  this.node.destroy()
@@ -46,7 +46,7 @@ export class TestClient {
46
46
  async collect(test) {
47
47
  let added = []
48
48
  let unbind = this.node.log.on('add', (action, meta) => {
49
- if (!meta.id.includes(` ${this.nodeId} `)) {
49
+ if (meta.id.split(' ')[1] !== this.nodeId) {
50
50
  added.push(action)
51
51
  }
52
52
  })
@@ -67,8 +67,8 @@ export class TestClient {
67
67
  })
68
68
 
69
69
  this.server.addClient(this.pair.right)
70
- this.node.connection.connect()
71
- this.node.waitFor('synchronized').then(() => {
70
+ void this.node.connection.connect()
71
+ void this.node.waitFor('synchronized').then(() => {
72
72
  this.node.throwsError = true
73
73
  unbind()
74
74
  resolve()
@@ -136,7 +136,7 @@ export class TestClient {
136
136
  async received(test) {
137
137
  let actions = []
138
138
  let unbind = this.log.on('add', (action, meta) => {
139
- if (!meta.id.includes(` ${this.nodeId} `)) {
139
+ if (meta.id.split(' ')[1] !== this.nodeId) {
140
140
  actions.push(action)
141
141
  }
142
142
  })
@@ -1,7 +1,7 @@
1
- import type { TestLog, TestTime } from '@logux/core'
1
+ import type { Log, TestLog, TestTime } from '@logux/core'
2
2
 
3
- import { BaseServer } from '../base-server/index.js'
4
3
  import type {
4
+ BaseServer,
5
5
  BaseServerOptions,
6
6
  Logger,
7
7
  ServerMeta
@@ -9,8 +9,10 @@ import type {
9
9
  import type { LoggerOptions } from '../server/index.js'
10
10
  import type { TestClient, TestClientOptions } from '../test-client/index.js'
11
11
 
12
- export interface TestServerOptions
13
- extends Omit<BaseServerOptions, 'minSubprotocol' | 'subprotocol'> {
12
+ export interface TestServerOptions extends Omit<
13
+ BaseServerOptions,
14
+ 'minSubprotocol' | 'subprotocol'
15
+ > {
14
16
  /**
15
17
  * Disable built-in auth.
16
18
  */
@@ -46,8 +48,9 @@ export interface TestServerOptions
46
48
  * ```
47
49
  */
48
50
  export class TestServer<
49
- Headers extends object = unknown
50
- > extends BaseServer<Headers> {
51
+ Headers extends object = unknown,
52
+ ClientData extends object = unknown
53
+ > extends BaseServer<Headers, Log<ServerMeta>, ClientData> {
51
54
  /**
52
55
  * fetch() compatible API to test HTTP endpoints.
53
56
  *
@@ -34,8 +34,8 @@ export class TestServer extends BaseServer {
34
34
 
35
35
  this.fetch = this.fetch.bind(this)
36
36
 
37
- this.on('fatal', () => {
38
- this.destroy()
37
+ this.on('fatal', async () => {
38
+ await this.destroy()
39
39
  })
40
40
  }
41
41
 
@@ -72,7 +72,8 @@ export class TestServer extends BaseServer {
72
72
  if (e.message === 'Server undid action') {
73
73
  if (e.action.reason !== reason) {
74
74
  throw new Error(
75
- `Undo was with ${e.action.reason} reason, not ${reason}`
75
+ `Undo was with ${e.action.reason} reason, not ${reason}`,
76
+ { cause: e }
76
77
  )
77
78
  }
78
79
  } else {
@@ -0,0 +1,28 @@
1
+ const PARAM = /:(\w+)/g
2
+ const SPECIAL = /[.*+?^${}()|[\]\\]/g
3
+
4
+ function escape(text) {
5
+ return text.replace(SPECIAL, '\\$&')
6
+ }
7
+
8
+ export function createPattern(pattern) {
9
+ let names = []
10
+ let source = ''
11
+ let last = 0
12
+ for (let param of pattern.matchAll(PARAM)) {
13
+ source += escape(pattern.slice(last, param.index)) + '([^/]+)'
14
+ names.push(param[1])
15
+ last = param.index + param[0].length
16
+ }
17
+ let regexp = new RegExp('^' + source + escape(pattern.slice(last)) + '$')
18
+
19
+ return url => {
20
+ let match = regexp.exec(url)
21
+ if (!match) return null
22
+ let params = {}
23
+ for (let i = 0; i < names.length; i++) {
24
+ if (names[i] !== '__proto__') params[names[i]] = match[i + 1]
25
+ }
26
+ return params
27
+ }
28
+ }