@logux/server 0.16.3 → 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.
- package/base-server/index.d.ts +65 -39
- package/base-server/index.js +174 -754
- package/context/index.d.ts +13 -1
- package/context/index.js +4 -2
- package/create-reporter/index.js +6 -0
- package/filtered-node/index.js +38 -1
- package/index.d.ts +5 -0
- package/index.js +1 -0
- package/package.json +4 -5
- package/postgres-store/index.d.ts +3 -1
- package/postgres-store/index.js +74 -30
- package/processing/index.js +560 -0
- package/processing-store/index.d.ts +51 -0
- package/processing-store/index.js +44 -0
- package/publishing/index.js +157 -0
- package/queue-manager/index.js +39 -0
- package/server-client/index.d.ts +0 -9
- package/server-client/index.js +25 -134
- package/url-pattern/index.js +9 -3
|
@@ -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
|
+
}
|