@logux/server 0.16.2 → 0.17.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,560 @@
1
+ import { parseId } from '@logux/core'
2
+
3
+ import { Context } from '../context/index.js'
4
+ import { ProcessingStore } from '../processing-store/index.js'
5
+ import { QueueManager } from '../queue-manager/index.js'
6
+
7
+ const RESEND_META = ['channels', 'users', 'clients', 'nodes']
8
+
9
+ function subscriberFilterId(action) {
10
+ return JSON.stringify(action.filter || {})
11
+ }
12
+
13
+ function isResponseError(e, statusCode) {
14
+ return e.name === 'ResponseError' && e.statusCode === statusCode
15
+ }
16
+
17
+ export async function wasNot403(cb) {
18
+ try {
19
+ await cb()
20
+ return true
21
+ } catch (e) {
22
+ if (isResponseError(e, 403)) {
23
+ return false
24
+ }
25
+ throw e
26
+ }
27
+ }
28
+
29
+ function denyOn403(step) {
30
+ return async task => {
31
+ try {
32
+ await step(task)
33
+ } catch (e) {
34
+ if (!isResponseError(e, 403)) throw e
35
+ await task.server.undo(task.action, task.meta, 'denied')
36
+ }
37
+ }
38
+ }
39
+
40
+ function notFoundOn404(step) {
41
+ return async task => {
42
+ try {
43
+ await step(task)
44
+ } catch (e) {
45
+ if (e.name !== 'LoguxNotFoundError' && !isResponseError(e, 404)) throw e
46
+ await task.server.undo(task.action, task.meta, 'notFound')
47
+ }
48
+ }
49
+ }
50
+
51
+ // Trusted server submissions skip the client permission check,
52
+ // but a channel always checks its own access
53
+ function skipTrusted(step) {
54
+ return task => (task.trusted ? undefined : step(task))
55
+ }
56
+
57
+ async function runAccess(task, access) {
58
+ let result = await access(task.ctx, task.action, task.meta)
59
+ if (task.outcome || result) return
60
+ await task.server.undo(task.action, task.meta, 'denied')
61
+ }
62
+
63
+ async function runResend(task, resend) {
64
+ if (!resend) return
65
+ let result = await resend(task.ctx, task.action, task.meta)
66
+ if (task.outcome || !result) return
67
+ if (typeof result === 'string') {
68
+ result = { channels: [result] }
69
+ } else if (Array.isArray(result)) {
70
+ result = { channels: result }
71
+ }
72
+ for (let key of RESEND_META) {
73
+ if (result[key]) task.meta[key] = result[key]
74
+ }
75
+ }
76
+
77
+ function checkClient(task) {
78
+ let client = task.server.clientIds.get(task.ctx.clientId)
79
+ if (client && (!task.client || client === task.client)) return true
80
+ // A disconnected or replaced client must not get a ghost subscription
81
+ task.server.emitter.emit('subscriptionCancelled')
82
+ task.settle({ type: 'cancelled' })
83
+ return false
84
+ }
85
+
86
+ async function installMembership(task) {
87
+ if (!checkClient(task)) return
88
+ let { action, ctx, handler, meta, server } = task
89
+ let channel = action.channel
90
+ let nodeId = ctx.nodeId
91
+ if (channel === '__proto__' || nodeId === '__proto__') return
92
+
93
+ let filter = true
94
+ if (handler.filter) {
95
+ filter = await handler.filter(ctx, action, meta)
96
+ if (task.outcome || !checkClient(task)) return
97
+ }
98
+
99
+ server.emitter.emit('report', 'subscribed', { actionId: meta.id, channel })
100
+ if (!server.subscribers[channel]) {
101
+ server.subscribers[channel] = {}
102
+ server.emitter.emit('subscribing', action, meta)
103
+ }
104
+
105
+ let filterId = subscriberFilterId(action)
106
+ let subscriber = server.subscribers[channel][nodeId]
107
+ // The earlier subscription with the same filter stays as it is
108
+ if (subscriber && filterId in subscriber.filters) return
109
+
110
+ server.subscribers[channel][nodeId] = {
111
+ filters: { ...subscriber?.filters, [filterId]: filter },
112
+ unsubscribe: handler.unsubscribe
113
+ ? (unsubscribeAction, unsubscribeMeta) =>
114
+ handler.unsubscribe(ctx, unsubscribeAction, unsubscribeMeta)
115
+ : undefined
116
+ }
117
+ // The membership is installed before `load()` to keep the live updates
118
+ task.installed = { channel, filterId, nodeId }
119
+ }
120
+
121
+ async function sendLoaded(task, load) {
122
+ let loaded = load && (await load(task.ctx, task.action, task.meta))
123
+ if (task.outcome || !checkClient(task)) return
124
+ if (loaded) await task.ctx.sendBack(loaded)
125
+ if (task.outcome) return
126
+ task.server.emitter.emit(
127
+ 'subscribed',
128
+ task.action,
129
+ task.meta,
130
+ Date.now() - task.start
131
+ )
132
+ }
133
+
134
+ export function normalizeType(name, callbacks, queue) {
135
+ if (!callbacks || (!callbacks.access && !callbacks.accessAndProcess)) {
136
+ throw new Error(`${name} must have access callback`)
137
+ }
138
+
139
+ let steps
140
+ if (callbacks.accessAndProcess) {
141
+ steps = [
142
+ denyOn403(task => {
143
+ return callbacks.accessAndProcess(task.ctx, task.action, task.meta)
144
+ }),
145
+ task => runResend(task, callbacks.resend)
146
+ ]
147
+ } else {
148
+ steps = [
149
+ skipTrusted(task => runAccess(task, callbacks.access)),
150
+ task => runResend(task, callbacks.resend),
151
+ task => callbacks.process?.(task.ctx, task.action, task.meta)
152
+ ]
153
+ }
154
+
155
+ return { duplicate: 'command', finally: callbacks.finally, queue, steps }
156
+ }
157
+
158
+ export function normalizeChannel(name, callbacks, queue) {
159
+ if (!callbacks || (!callbacks.access && !callbacks.accessAndLoad)) {
160
+ throw new Error(`${name} must have access callback`)
161
+ }
162
+
163
+ let steps
164
+ if (callbacks.accessAndLoad) {
165
+ steps = [
166
+ denyOn403(
167
+ notFoundOn404(async task => {
168
+ task.loaded = await callbacks.accessAndLoad(
169
+ task.ctx,
170
+ task.action,
171
+ task.meta
172
+ )
173
+ })
174
+ ),
175
+ installMembership,
176
+ task => sendLoaded(task, () => task.loaded)
177
+ ]
178
+ } else {
179
+ steps = [
180
+ task => runAccess(task, callbacks.access),
181
+ installMembership,
182
+ notFoundOn404(task => sendLoaded(task, callbacks.load))
183
+ ]
184
+ }
185
+
186
+ return {
187
+ duplicate: 'connection',
188
+ filter: callbacks.filter,
189
+ finally: callbacks.finally,
190
+ queue,
191
+ steps,
192
+ unsubscribe: callbacks.unsubscribe
193
+ }
194
+ }
195
+
196
+ export function getRegexType(server, type) {
197
+ for (let regexp of server.regexTypes.keys()) {
198
+ if (type.match(regexp) !== null) {
199
+ return server.regexTypes.get(regexp)
200
+ }
201
+ }
202
+ return undefined
203
+ }
204
+
205
+ function matchChannel(server, name) {
206
+ if (typeof name !== 'string' || name === '__proto__') return undefined
207
+ let channels = server.channels
208
+ if (server.otherSubscriber) {
209
+ channels = channels.concat([server.otherSubscriber])
210
+ }
211
+ for (let channel of channels) {
212
+ let params = channel.pattern
213
+ ? channel.pattern(name)
214
+ : name.match(channel.regexp)
215
+ if (params) return { handler: channel, params }
216
+ }
217
+ return undefined
218
+ }
219
+
220
+ export function removeMembership(server, clientNodeId, action, meta) {
221
+ if (action.channel === '__proto__' || clientNodeId === '__proto__') return
222
+ if (server.subscribers[action.channel]) {
223
+ let subscriber = server.subscribers[action.channel][clientNodeId]
224
+ if (subscriber) {
225
+ if (subscriber.unsubscribe) {
226
+ subscriber.unsubscribe(action, meta)
227
+ }
228
+ let filterId = subscriberFilterId(action)
229
+ delete subscriber.filters[filterId]
230
+ if (Object.keys(subscriber.filters).length === 0) {
231
+ delete server.subscribers[action.channel][clientNodeId]
232
+ }
233
+ if (Object.keys(server.subscribers[action.channel]).length === 0) {
234
+ delete server.subscribers[action.channel]
235
+ }
236
+ }
237
+ }
238
+ server.emitter.emit('unsubscribed', action, meta, clientNodeId)
239
+ server.emitter.emit('report', 'unsubscribed', {
240
+ actionId: meta.id,
241
+ channel: action.channel
242
+ })
243
+ }
244
+
245
+ const UNKNOWN_TYPE = {
246
+ duplicate: 'command',
247
+ queue: 'main',
248
+ steps: [task => task.server.unknownType(task.action, task.meta)]
249
+ }
250
+
251
+ // The server’s own actions are facts: they need no type definition
252
+ const SERVER_FACT = { duplicate: 'command', queue: 'main', steps: [] }
253
+
254
+ const WRONG_CHANNEL = {
255
+ duplicate: 'connection',
256
+ queue: 'main',
257
+ steps: [task => task.server.wrongChannel(task.action, task.meta)]
258
+ }
259
+
260
+ const UNSUBSCRIBE = [
261
+ task => removeMembership(task.server, task.ctx.nodeId, task.action, task.meta)
262
+ ]
263
+
264
+ // The only place which knows about channels: the queue, the runner
265
+ // and the publisher have no subscription branches
266
+ export function resolveHandler(server, action, meta) {
267
+ let type = action.type
268
+ if (type === 'logux/subscribe' || type === 'logux/unsubscribe') {
269
+ let match = matchChannel(server, action.channel)
270
+ if (type === 'logux/unsubscribe' && typeof action.channel === 'string') {
271
+ // Unsubscribe needs no channel callbacks, but keeps the channel’s queue
272
+ return {
273
+ duplicate: 'connection',
274
+ queue: match ? match.handler.queue : 'main',
275
+ steps: UNSUBSCRIBE
276
+ }
277
+ }
278
+ if (!match) return WRONG_CHANNEL
279
+ return { ...match.handler, params: match.params }
280
+ }
281
+
282
+ let handler =
283
+ server.types[type] || getRegexType(server, type) || server.otherProcessor
284
+ if (handler) return handler
285
+ return parseId(meta.id).userId === 'server' ? SERVER_FACT : UNKNOWN_TYPE
286
+ }
287
+
288
+ function ignore() {}
289
+
290
+ function addTo(map, key, value) {
291
+ let values = map.get(key)
292
+ if (!values) {
293
+ values = new Set()
294
+ map.set(key, values)
295
+ }
296
+ values.add(value)
297
+ }
298
+
299
+ function removeFrom(map, key, value) {
300
+ let values = map.get(key)
301
+ if (!values) return
302
+ values.delete(value)
303
+ if (values.size === 0) map.delete(key)
304
+ }
305
+
306
+ function undoError(meta, reason) {
307
+ let error = new Error(`Action "${meta.id}" was undone with ${reason} reason`)
308
+ error.reason = reason
309
+ return error
310
+ }
311
+
312
+ export class Task {
313
+ constructor(runner, { action, client, handler, key, meta, trusted }) {
314
+ this.action = action
315
+ this.client = client
316
+ this.controller = new AbortController()
317
+ this.handler = handler
318
+ this.key = key
319
+ this.meta = meta
320
+ this.runner = runner
321
+ this.server = runner.server
322
+ this.start = Date.now()
323
+ this.trusted = trusted
324
+
325
+ this.promise = new Promise((resolve, reject) => {
326
+ this.resolveTask = resolve
327
+ this.rejectTask = reject
328
+ })
329
+ // The deadline stops the waiting for the application callback
330
+ this.abandoned = new Promise(resolve => {
331
+ this.abandon = resolve
332
+ })
333
+ }
334
+
335
+ cleanup() {
336
+ this.controller.abort()
337
+ if (this.outcome.type !== 'processed' && this.installed) {
338
+ let { nodeId } = this.installed
339
+ removeMembership(this.server, nodeId, this.action, this.meta)
340
+ this.installed = undefined
341
+ }
342
+ if (this.ctx && this.handler.finally) {
343
+ try {
344
+ this.handler.finally(this.ctx, this.action, this.meta)
345
+ } catch (e) {
346
+ this.server.emitter.emit('error', e, this.action, this.meta)
347
+ }
348
+ }
349
+ }
350
+
351
+ async runSteps() {
352
+ this.#startTimer()
353
+ let handler = this.handler
354
+ if (handler.duplicate === 'command' && this.client && !this.trusted) {
355
+ // The log already has the action: it was processed before the answer
356
+ // was lost. An old entry, stored before its processing, is not a receipt
357
+ let [stored, meta] = await this.server.log.byId(this.meta.id)
358
+ if (stored && (!meta.status || meta.status === 'processed')) {
359
+ this.server.emitter.emit('report', 'duplicate', {
360
+ actionId: this.meta.id
361
+ })
362
+ this.settle({ replayed: true, type: 'processed' })
363
+ }
364
+ }
365
+ for (let step of handler.steps) {
366
+ if (this.outcome) break
367
+ await step(this)
368
+ }
369
+ }
370
+
371
+ settle(outcome) {
372
+ if (this.outcome) return false
373
+ this.outcome = outcome
374
+ return true
375
+ }
376
+
377
+ #startTimer() {
378
+ let ms = this.server.options.queueTimeout
379
+ if (!ms) return
380
+ let timer = setTimeout(() => {
381
+ let error = new Error(
382
+ `Action "${this.meta.id}" was not processed in ${ms} ms`
383
+ )
384
+ this.controller.abort(error)
385
+ if (this.settle({ reason: 'error', type: 'undo' })) {
386
+ this.error = error
387
+ this.runner.detached.add(this)
388
+ this.server.emitter.emit('error', error, this.action, this.meta)
389
+ }
390
+ // The callback can not be stopped, but the queue must move on
391
+ this.abandon()
392
+ }, ms)
393
+ if (timer.unref) timer.unref()
394
+ this.controller.signal.addEventListener('abort', () => {
395
+ clearTimeout(timer)
396
+ })
397
+ }
398
+ }
399
+
400
+ export class ActionRunner {
401
+ constructor(server) {
402
+ this.active = new Set()
403
+ this.byId = new Map()
404
+ this.clientTasks = new Map()
405
+ this.detached = new Set()
406
+ this.processing = server.options.processingStore || new ProcessingStore()
407
+ this.queues = new QueueManager(this)
408
+ this.server = server
409
+ }
410
+
411
+ async run(task) {
412
+ // A task, which was answered before its turn, never runs its callbacks
413
+ if (!task.outcome) {
414
+ await Promise.race([this.#runSteps(task), task.abandoned])
415
+ }
416
+ task.settle({ type: 'processed' })
417
+ task.cleanup()
418
+
419
+ let failed
420
+ try {
421
+ await this.#answer(task)
422
+ } catch (e) {
423
+ // The answer was not published: do not acknowledge the completion
424
+ e.transportFailure = true
425
+ failed = e
426
+ }
427
+ this.#forget(task)
428
+
429
+ if (task.outcome.type === 'undo' && task.queueKey) {
430
+ for (let waiting of this.queues.cancelPending(task.queueKey)) {
431
+ void this.server.undo(waiting.action, waiting.meta, 'error')
432
+ void this.run(waiting)
433
+ }
434
+ }
435
+ if (task.outcome.type !== 'cancelled') {
436
+ let latency = Date.now() - task.start
437
+ this.server.emitter.emit('processed', task.action, task.meta, latency)
438
+ }
439
+
440
+ if (failed) {
441
+ task.rejectTask(failed)
442
+ } else if (task.outcome.type === 'undo') {
443
+ task.rejectTask(task.error || undoError(task.meta, task.outcome.reason))
444
+ } else {
445
+ task.resolveTask(task.meta)
446
+ }
447
+ }
448
+
449
+ async #runSteps(task) {
450
+ let { action, handler, meta } = task
451
+ task.ctx = new Context(this.server, meta, task)
452
+ if (handler.params) task.ctx.params = handler.params
453
+ try {
454
+ await task.runSteps()
455
+ } catch (e) {
456
+ task.error = e
457
+ this.server.emitter.emit('error', e, action, meta)
458
+ await this.server.undo(action, meta, 'error')
459
+ }
460
+ // The callback of an abandoned task returned, nothing is left behind
461
+ this.detached.delete(task)
462
+ }
463
+
464
+ // The server’s `batch` listener delivers every written action
465
+ async #answer({ action, client, meta, outcome }) {
466
+ if (outcome.type === 'processed') {
467
+ if (!outcome.replayed) {
468
+ // Custom `Node` classes send the log to their own client by themselves
469
+ if (client && client.node.received) client.node.received[meta.id] = true
470
+ await this.server.log.add([[action, meta]])
471
+ }
472
+ let { clientId, userId } = parseId(meta.id)
473
+ if (userId !== 'server') {
474
+ await this.server.log.add(
475
+ { id: meta.id, type: 'logux/processed' },
476
+ { clients: [clientId] }
477
+ )
478
+ }
479
+ } else if (outcome.type === 'undo') {
480
+ // The outcome was already reported when it was decided
481
+ await this.server.publishUndo(action, meta, outcome.reason, outcome.extra)
482
+ }
483
+ await this.server.publisher.settled()
484
+ }
485
+
486
+ cleanConnection(client) {
487
+ this.processing.removeConnection(client.key)
488
+ this.clientTasks.delete(client)
489
+ }
490
+
491
+ #forget(task) {
492
+ if (this.byId.get(task.meta.id) === task) this.byId.delete(task.meta.id)
493
+ if (task.outcome.type === 'cancelled') {
494
+ this.processing.delete(task.key)
495
+ } else {
496
+ // Only the subscription outcomes belong to the connection
497
+ let connection =
498
+ task.client && task.handler.duplicate === 'connection'
499
+ ? task.client.key
500
+ : undefined
501
+ this.processing.finish(task.key, task.outcome, connection)
502
+ }
503
+ }
504
+
505
+ submit({ action, client, meta = {}, trusted = false }) {
506
+ this.server.prepareMeta(meta, client)
507
+ let handler = resolveHandler(this.server, action, meta)
508
+
509
+ // Reserve the duplicate key before any awaited work
510
+ let key =
511
+ handler.duplicate === 'connection' && client
512
+ ? `${client.key} ${meta.id}`
513
+ : meta.id
514
+
515
+ let found = this.processing.get(key)
516
+ if (found) {
517
+ this.server.emitter.emit('report', 'duplicate', { actionId: meta.id })
518
+ if (found.task) return found.task.promise
519
+ let outcome = { ...found.outcome, replayed: true }
520
+ return this.#answer({ action, client, meta, outcome }).then(() => {
521
+ if (outcome.type === 'undo') throw undoError(meta, outcome.reason)
522
+ return meta
523
+ })
524
+ }
525
+
526
+ let task = new Task(this, { action, client, handler, key, meta, trusted })
527
+ this.processing.start(key, task)
528
+ this.byId.set(meta.id, task)
529
+
530
+ let tracking = task.promise.then(ignore, ignore)
531
+ this.active.add(tracking)
532
+ if (client) addTo(this.clientTasks, client, tracking)
533
+ void tracking.then(() => {
534
+ this.active.delete(tracking)
535
+ if (client) removeFrom(this.clientTasks, client, tracking)
536
+ })
537
+
538
+ if (trusted) {
539
+ // Nested commands must not wait for the task, which awaits them
540
+ void this.run(task)
541
+ } else {
542
+ this.queues.push(`${parseId(meta.id).clientId}/${handler.queue}`, task)
543
+ }
544
+ return task.promise
545
+ }
546
+
547
+ async waitForClient(client) {
548
+ let tasks = this.clientTasks.get(client)
549
+ if (!tasks) return
550
+ while (tasks.size > 0) {
551
+ await Promise.all(tasks)
552
+ }
553
+ }
554
+
555
+ async waitForTasks() {
556
+ while (this.active.size > 0) {
557
+ await Promise.all(this.active)
558
+ }
559
+ }
560
+ }
@@ -0,0 +1,51 @@
1
+ import type { Task } from '../processing/index.js'
2
+
3
+ export interface ProcessingOutcome {
4
+ extra?: object
5
+ reason?: string
6
+ replayed?: boolean
7
+ type: 'cancelled' | 'processed' | 'undo'
8
+ }
9
+
10
+ export interface ProcessingEntry {
11
+ /**
12
+ * Connection key of the client, which owns the subscription outcome.
13
+ */
14
+ connection?: string
15
+
16
+ outcome?: ProcessingOutcome
17
+
18
+ /**
19
+ * The task, which is still running.
20
+ */
21
+ task?: Task
22
+ }
23
+
24
+ /**
25
+ * The running tasks and the recent outcomes of the actions by duplicate key.
26
+ *
27
+ * The server answers a re-sent action from this store instead of running
28
+ * its callbacks again. Logux Server Pro replaces it with a Redis-based
29
+ * store to share the outcomes between the servers.
30
+ */
31
+ export class ProcessingStore {
32
+ entries: Map<string, ProcessingEntry>
33
+
34
+ limit: number
35
+
36
+ constructor(limit?: number)
37
+
38
+ delete(key: string): void
39
+
40
+ finish(
41
+ key: string,
42
+ outcome: ProcessingOutcome,
43
+ connection: string | undefined
44
+ ): void
45
+
46
+ get(key: string): ProcessingEntry | undefined
47
+
48
+ removeConnection(connection: string): void
49
+
50
+ start(key: string, task: Task): void
51
+ }
@@ -0,0 +1,44 @@
1
+ const DEFAULT_LIMIT = 1000
2
+
3
+ export class ProcessingStore {
4
+ constructor(limit = DEFAULT_LIMIT) {
5
+ this.entries = new Map()
6
+ this.limit = limit
7
+ }
8
+
9
+ delete(key) {
10
+ this.entries.delete(key)
11
+ }
12
+
13
+ // A running task must stay: forgetting it allows a second execution
14
+ #evict() {
15
+ for (let [key, entry] of this.entries) {
16
+ if (!entry.task) {
17
+ this.entries.delete(key)
18
+ return
19
+ }
20
+ }
21
+ }
22
+
23
+ finish(key, outcome, connection) {
24
+ this.entries.delete(key)
25
+ if (this.entries.size >= this.limit) this.#evict()
26
+ this.entries.set(key, { connection, outcome })
27
+ }
28
+
29
+ get(key) {
30
+ return this.entries.get(key)
31
+ }
32
+
33
+ // A reconnect must run the handler again instead of replaying
34
+ // the answer of the old connection
35
+ removeConnection(connection) {
36
+ for (let [key, entry] of this.entries) {
37
+ if (entry.connection === connection) this.entries.delete(key)
38
+ }
39
+ }
40
+
41
+ start(key, task) {
42
+ this.entries.set(key, { task })
43
+ }
44
+ }