console-web-node 0.0.12 → 0.1.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/README.md +41 -1
- package/package.json +14 -7
- package/src/auth.js +21 -0
- package/src/client.js +73 -0
- package/src/index.js +35 -161
- package/src/log-buffer.js +52 -0
- package/src/logs-handler.js +21 -0
- package/src/utils/circular-buffer.js +0 -39
- package/src/utils/circular-buffer.test.js +0 -102
package/README.md
CHANGED
|
@@ -1 +1,41 @@
|
|
|
1
|
-
# console-web-node
|
|
1
|
+
# console-web-node
|
|
2
|
+
|
|
3
|
+
Guarda em memória as últimas linhas escritas no console (stdout/stderr) de um servidor Node com Express (ou NestJS sobre Express), e permite acompanhá-las de outra máquina: primeiro a janela guardada, depois as linhas novas em tempo real.
|
|
4
|
+
|
|
5
|
+
## Servidor
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import consoleWebNode from 'console-web-node'
|
|
9
|
+
import moment from 'moment'
|
|
10
|
+
|
|
11
|
+
consoleWebNode({ express: app, moment })
|
|
12
|
+
|
|
13
|
+
// NestJS: o logger já coloca data e hora
|
|
14
|
+
consoleWebNode({ express: app.getHttpAdapter().getInstance(), maxLines: 100000 })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
| opção | padrão | |
|
|
18
|
+
|---|---|---|
|
|
19
|
+
| `express` | — | app ou router express onde é registrada a rota dos logs |
|
|
20
|
+
| `moment` | — | instância do moment para colocar data e hora nas linhas (sem ela as linhas ficam como foram escritas, para loggers que já colocam data e hora) |
|
|
21
|
+
| `logsPath` | `/api/x/logs` | caminho da rota (relativo ao `express` informado) |
|
|
22
|
+
| `maxLines` | `10000` | quantidade de linhas guardadas em memória |
|
|
23
|
+
| `consoleTimestamp` | `true` | escreve também no console as linhas com data e hora |
|
|
24
|
+
|
|
25
|
+
## Acompanhar os logs
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
export CONSOLE_WEB_NODE_KEY=<chave>
|
|
29
|
+
npx console-web-node meusistema.com.br # https://meusistema.com.br/api/x/logs
|
|
30
|
+
npx console-web-node https://meusistema.com.br/x/logs # outro caminho
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Rodando na pasta do projeto que usa a biblioteca, o `npx` usa a versão instalada nele (a mesma do servidor).
|
|
34
|
+
|
|
35
|
+
## Chave de acesso
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npx console-web-node --nova-chave
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Gera uma chave aleatória (para a variável `CONSOLE_WEB_NODE_KEY`) e o hash dela, que vai em `KEY_HASH` em `src/auth.js`. Sem hash configurado nenhuma chave é aceita.
|
package/package.json
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "console-web-node",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Guarda em memória as últimas linhas do console de um servidor Node/Express e permite acompanhá-las de outra máquina (npx console-web-node <servidor>)",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"console-web-node": "src/client.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"!src/**/*.test.js"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=14"
|
|
16
|
+
},
|
|
7
17
|
"scripts": {
|
|
8
18
|
"start": "nodemon",
|
|
9
19
|
"test:watch": "nodemon -x mocha -p 'src/**/*.test.js'",
|
|
@@ -21,10 +31,7 @@
|
|
|
21
31
|
"homepage": "https://github.com/LeonardoRomeiro/console-web-node#readme",
|
|
22
32
|
"devDependencies": {
|
|
23
33
|
"chai": "^4.2.0",
|
|
24
|
-
"mocha": "^8.
|
|
25
|
-
"nodemon": "^
|
|
26
|
-
},
|
|
27
|
-
"dependencies": {
|
|
28
|
-
"minio": "^7.0.16"
|
|
34
|
+
"mocha": "^10.8.2",
|
|
35
|
+
"nodemon": "^3.1.14"
|
|
29
36
|
}
|
|
30
37
|
}
|
package/src/auth.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
|
|
3
|
+
// sha256 (base64) da chave de acesso aos logs, gerado com `npx console-web-node --nova-chave`
|
|
4
|
+
// a chave é aleatória (256 bits): mesmo com o hash público não dá para descobri-la por força bruta
|
|
5
|
+
// vazio: nenhuma chave é aceita
|
|
6
|
+
export const KEY_HASH = 'PgYhxkg8Kk8BXArYKM8a6Y/ME7/cYn3COpBoAf9lm58='
|
|
7
|
+
|
|
8
|
+
const sha256 = (value) => crypto.createHash('sha256').update(value).digest()
|
|
9
|
+
|
|
10
|
+
// confere o cabeçalho Authorization (Bearer <chave>) com o hash da chave
|
|
11
|
+
export function isAuthorized(authorization, keyHash = KEY_HASH) {
|
|
12
|
+
const match = /^Bearer (\S+)$/.exec(authorization || '')
|
|
13
|
+
const expected = Buffer.from(keyHash, 'base64')
|
|
14
|
+
if (!match || expected.length !== 32) return false
|
|
15
|
+
return crypto.timingSafeEqual(sha256(match[1]), expected)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function newKey() {
|
|
19
|
+
const key = crypto.randomBytes(32).toString('hex')
|
|
20
|
+
return { key, hash: sha256(key).toString('base64') }
|
|
21
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// acompanha os logs de um servidor com console-web-node: mostra a janela de linhas guardada e segue as novas
|
|
3
|
+
// uso: npx console-web-node <servidor ou url> (chave na variável de ambiente CONSOLE_WEB_NODE_KEY)
|
|
4
|
+
// npx console-web-node --nova-chave
|
|
5
|
+
import http from 'http'
|
|
6
|
+
import https from 'https'
|
|
7
|
+
import { newKey } from './auth.js'
|
|
8
|
+
|
|
9
|
+
const KEY_ENV = 'CONSOLE_WEB_NODE_KEY'
|
|
10
|
+
const RETRY_MS = 5000
|
|
11
|
+
const RESPONSE_TIMEOUT_MS = 60000 // o servidor responde em até 30s mesmo sem linhas novas
|
|
12
|
+
|
|
13
|
+
const [target] = process.argv.slice(2)
|
|
14
|
+
|
|
15
|
+
if (target === '--nova-chave') {
|
|
16
|
+
const { key, hash } = newKey()
|
|
17
|
+
console.log(`Chave (guarde na variável de ambiente ${KEY_ENV} de quem vai acompanhar os logs):\n ${key}\n`)
|
|
18
|
+
console.log(`Hash (substitua KEY_HASH em src/auth.js e publique uma nova versão):\n ${hash}`)
|
|
19
|
+
} else if (!target || target.startsWith('-')) {
|
|
20
|
+
console.error('uso: console-web-node <servidor ou url> (ex.: meusistema.com.br, https://meusistema.com.br/api/x/logs)')
|
|
21
|
+
console.error(' console-web-node --nova-chave')
|
|
22
|
+
process.exitCode = 1
|
|
23
|
+
} else if (!process.env[KEY_ENV]) {
|
|
24
|
+
console.error(`informe a chave de acesso aos logs na variável de ambiente ${KEY_ENV}`)
|
|
25
|
+
process.exitCode = 1
|
|
26
|
+
} else {
|
|
27
|
+
const url = new URL(/^https?:\/\//.test(target) ? target : `https://${target}`)
|
|
28
|
+
if (url.pathname === '/') url.pathname = '/api/x/logs'
|
|
29
|
+
follow(url, process.env[KEY_ENV])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function follow(url, key, cursor = '') {
|
|
33
|
+
const started = Date.now()
|
|
34
|
+
let received = 0
|
|
35
|
+
let finished = false // erros da requisição e da resposta podem chegar juntos: segue uma vez só
|
|
36
|
+
if (cursor) url.searchParams.set('cursor', cursor)
|
|
37
|
+
const continueAfter = (wait, nextCursor) => {
|
|
38
|
+
if (finished) return
|
|
39
|
+
finished = true
|
|
40
|
+
setTimeout(() => follow(url, key, nextCursor), wait)
|
|
41
|
+
}
|
|
42
|
+
const retry = (error) => {
|
|
43
|
+
const reason = error.message || error.code || String(error) // falha de conexão pode vir só com o código (ex.: ECONNREFUSED)
|
|
44
|
+
if (!finished) console.error(`\n[console-web-node] ${reason}, tentando novamente em ${RETRY_MS / 1000}s...`)
|
|
45
|
+
continueAfter(RETRY_MS, cursor)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const request = (url.protocol === 'http:' ? http : https).get(url, { headers: { Authorization: `Bearer ${key}` } }, (res) => {
|
|
49
|
+
if (res.statusCode === 401) {
|
|
50
|
+
console.error(`[console-web-node] chave recusada pelo servidor, confira a variável de ambiente ${KEY_ENV}`)
|
|
51
|
+
process.exit(1)
|
|
52
|
+
}
|
|
53
|
+
const next = res.headers['x-cursor']
|
|
54
|
+
if (res.statusCode !== 200 || !next) {
|
|
55
|
+
res.resume()
|
|
56
|
+
return retry(new Error(`resposta inesperada do servidor (HTTP ${res.statusCode})`))
|
|
57
|
+
}
|
|
58
|
+
if (cursor && next.split('.')[0] !== cursor.split('.')[0]) console.log('\n[console-web-node] servidor reiniciado, logs desde o início:\n')
|
|
59
|
+
|
|
60
|
+
res.setEncoding('utf8')
|
|
61
|
+
res.on('data', (data) => {
|
|
62
|
+
received += data.length
|
|
63
|
+
process.stdout.write(data)
|
|
64
|
+
})
|
|
65
|
+
res.on('error', retry)
|
|
66
|
+
res.on('end', () => {
|
|
67
|
+
// resposta vazia imediata não deveria acontecer: espera um pouco para não ficar em loop
|
|
68
|
+
continueAfter(!received && Date.now() - started < 1000 ? 1000 : 0, next)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
request.setTimeout(RESPONSE_TIMEOUT_MS, () => request.destroy(new Error('sem resposta do servidor')))
|
|
72
|
+
request.on('error', retry)
|
|
73
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,165 +1,39 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
.then((stream) => new Promise((resolve) => {
|
|
29
|
-
const data = []
|
|
30
|
-
stream.on('data', data.push.bind(data))
|
|
31
|
-
stream.on('end', resolve.bind(null, data))
|
|
32
|
-
}))
|
|
33
|
-
.then((file) => Buffer.concat(file).toString().split('\n').forEach(line => circularBuffer.push(line + '\n')))
|
|
34
|
-
.catch(() => { })
|
|
35
|
-
|
|
36
|
-
writeToFile = () => {
|
|
37
|
-
writeToFile._timeout && clearTimeout(writeToFile._timeout)
|
|
38
|
-
writeToFile._timeout = setTimeout(() => minioClient.putObject(LOGS_BUCKET, name, circularBuffer.toArray().join('')), 200)
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
express.get(logsPath, async (req, res) => {
|
|
43
|
-
if (!hasListner(req.query.u)) {
|
|
44
|
-
if (!checkPass(req.query.p)) res.end()
|
|
45
|
-
setListner(req.query.u)
|
|
46
|
-
return res.send(circularBuffer.toArray().join(''))
|
|
47
|
-
}
|
|
48
|
-
setListner(req.query.u)
|
|
49
|
-
const ls = await readListner(req.query.u)
|
|
50
|
-
res.send(ls.join(''))
|
|
51
|
-
})
|
|
52
|
-
express.post(logsPath, async (req, res) => {
|
|
53
|
-
if (hasListner(req.query.u)) clearLastLogs(req.query.u)
|
|
54
|
-
res.end()
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
const StdoutWrite = global.process.stdout.write.bind(global.process.stdout)
|
|
58
|
-
const StderrWrite = global.process.stderr.write.bind(global.process.stderr)
|
|
59
|
-
|
|
60
|
-
global.process.stdout.write = function($str) {
|
|
61
|
-
if (typeof $str !== 'string') return StdoutWrite($str)
|
|
62
|
-
|
|
63
|
-
const strTime = _setTimestamp(moment, $str)
|
|
64
|
-
|
|
65
|
-
if ($str[$str.length - 1] === '\n') {
|
|
66
|
-
const strFormat = _formatLogString(strTime)
|
|
67
|
-
consoleTimestamp ? StdoutWrite(strFormat) : StdoutWrite($str)
|
|
68
|
-
broadcast(strFormat)
|
|
69
|
-
circularBuffer.push(strFormat)
|
|
70
|
-
writeToFile()
|
|
71
|
-
} else {
|
|
72
|
-
consoleTimestamp ? StdoutWrite(strTime) : StdoutWrite($str)
|
|
73
|
-
broadcast(strTime)
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
global.process.stderr.write = function($str) {
|
|
78
|
-
if (typeof $str !== 'string') return StderrWrite($str)
|
|
79
|
-
|
|
80
|
-
const strTime = _setTimestamp(moment, $str)
|
|
81
|
-
|
|
82
|
-
if ($str[$str.length - 1] === '\n') {
|
|
83
|
-
const strFormat = _formatLogErrString(strTime)
|
|
84
|
-
consoleTimestamp ? StderrWrite(strFormat) : StderrWrite($str)
|
|
85
|
-
broadcast(strFormat)
|
|
86
|
-
circularBuffer.push(strFormat)
|
|
87
|
-
writeToFile()
|
|
88
|
-
} else {
|
|
89
|
-
consoleTimestamp ? StderrWrite(strTime) : StderrWrite($str)
|
|
90
|
-
broadcast(strTime)
|
|
91
|
-
}
|
|
1
|
+
import { createLogBuffer } from './log-buffer.js'
|
|
2
|
+
import { createLogsHandler } from './logs-handler.js'
|
|
3
|
+
|
|
4
|
+
// guarda em memória as últimas maxLines linhas escritas no stdout/stderr e as disponibiliza
|
|
5
|
+
// na rota logsPath do app express para o cliente (npx console-web-node <servidor>)
|
|
6
|
+
// moment: coloca data e hora em cada linha (sem ele as linhas ficam como foram escritas, ex.: logger que já tem data e hora)
|
|
7
|
+
// consoleTimestamp: escreve também no console as linhas com data e hora (false: o console recebe o texto original)
|
|
8
|
+
export default function ConsoleWebNode({ express, moment, logsPath = '/api/x/logs', maxLines = 10000, consoleTimestamp = true }) {
|
|
9
|
+
if (!express) throw new Error('console-web-node: informe em express o app onde registrar a rota dos logs')
|
|
10
|
+
|
|
11
|
+
const buffer = createLogBuffer(maxLines)
|
|
12
|
+
express.get(logsPath, createLogsHandler(buffer))
|
|
13
|
+
|
|
14
|
+
const format = createFormatter(moment)
|
|
15
|
+
intercept(process.stdout, buffer, format, consoleTimestamp, (line) => line)
|
|
16
|
+
intercept(process.stderr, buffer, format, consoleTimestamp, (line) => `\x1b[31m${line}\x1b[0m`)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// só linhas completas (terminadas em \n) vão para os logs; escritas parciais (ex.: progresso com \r) só para o console
|
|
20
|
+
function intercept(stream, buffer, format, consoleTimestamp, color) {
|
|
21
|
+
const write = stream.write.bind(stream)
|
|
22
|
+
stream.write = function(chunk, ...args) {
|
|
23
|
+
if (typeof chunk !== 'string') return write(chunk, ...args)
|
|
24
|
+
if (!chunk.endsWith('\n')) return write(consoleTimestamp ? format.partial(chunk) : chunk, ...args)
|
|
25
|
+
const line = color(format.line(chunk))
|
|
26
|
+
buffer.push(line)
|
|
27
|
+
return write(consoleTimestamp ? line : chunk, ...args)
|
|
92
28
|
}
|
|
93
29
|
}
|
|
94
30
|
|
|
95
|
-
|
|
96
|
-
function
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function setListner(listner) {
|
|
106
|
-
listners[listner] = listners[listner] || { logs: [], lastLogs: [] }
|
|
107
|
-
|
|
108
|
-
listners[listner]._setTimeout && clearTimeout(listners[listner]._setTimeout)
|
|
109
|
-
listners[listner]._setTimeout = setTimeout(() => {
|
|
110
|
-
listners[listner]._readTimeout && clearTimeout(listners[listner]._readTimeout)
|
|
111
|
-
listners[listner]._readClear && events.off(BROADCAST_TOKEN, listners[listner]._readClear)
|
|
112
|
-
delete listners[listner]
|
|
113
|
-
}, LISTNER_TIMEOUT)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function clearLastLogs(listner) {
|
|
117
|
-
listners[listner].lastLogs = []
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function readListner(listner) {
|
|
121
|
-
return new Promise((resolve) => {
|
|
122
|
-
if (listners[listner].lastLogs[0] || listners[listner].logs[0]) return resolve(read(listner))
|
|
123
|
-
|
|
124
|
-
listners[listner]._readTimeout && clearTimeout(listners[listner]._readTimeout)
|
|
125
|
-
listners[listner]._readClear && events.off(BROADCAST_TOKEN, listners[listner]._readClear)
|
|
126
|
-
|
|
127
|
-
listners[listner]._readClear = () => {
|
|
128
|
-
clearTimeout(listners[listner]._readTimeout)
|
|
129
|
-
resolve(read(listner))
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
listners[listner]._readTimeout = setTimeout(() => {
|
|
133
|
-
events.off(BROADCAST_TOKEN, listners[listner]._readClear)
|
|
134
|
-
resolve([])
|
|
135
|
-
}, READ_TIMEOUT)
|
|
136
|
-
|
|
137
|
-
events.once(BROADCAST_TOKEN, listners[listner]._readClear)
|
|
138
|
-
})
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function read(listner) {
|
|
142
|
-
listners[listner].lastLogs = listners[listner].lastLogs.concat(listners[listner].logs)
|
|
143
|
-
listners[listner].logs = []
|
|
144
|
-
return listners[listner].lastLogs
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function hasListner(listner) {
|
|
148
|
-
return !!listners[listner]
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function checkPass(pass) {
|
|
152
|
-
return crypto.createHash('sha1').update(pass).digest('base64') === 'rJzrVISFf5EOCkdA3DkVpId9yZo='
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function _setTimestamp(moment, $str) {
|
|
156
|
-
return `${moment().format('DD MMM YYYY HH:mm:ss')} ${$str}`
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function _formatLogString($str) {
|
|
160
|
-
return `${$str.slice(0, -1).replace(/\n/g, '\n ') + '\n'}`
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function _formatLogErrString($str) {
|
|
164
|
-
return `\x1b[31m${_formatLogString($str)}\x1b[0m`
|
|
31
|
+
// data e hora no início, com as linhas seguintes de uma mensagem com várias linhas alinhadas depois dela
|
|
32
|
+
export function createFormatter(moment) {
|
|
33
|
+
if (!moment) return { line: (text) => text, partial: (text) => text }
|
|
34
|
+
const timestamp = (text) => `${moment().format('DD MMM YYYY HH:mm:ss')} ${text}`
|
|
35
|
+
return {
|
|
36
|
+
line: (text) => timestamp(text).slice(0, -1).replace(/\n/g, '\n ') + '\n',
|
|
37
|
+
partial: timestamp,
|
|
38
|
+
}
|
|
165
39
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import crypto from 'crypto'
|
|
2
|
+
|
|
3
|
+
// janela com as últimas linhas do log, numeradas em sequência desde o início do processo
|
|
4
|
+
// o cursor (instância.sequência) indica ao cliente de onde continuar a leitura;
|
|
5
|
+
// a instância muda quando o processo reinicia, e aí o cliente recebe a janela inteira do processo novo
|
|
6
|
+
export function createLogBuffer(maxLines) {
|
|
7
|
+
const instance = crypto.randomBytes(4).toString('hex')
|
|
8
|
+
const waiting = new Set()
|
|
9
|
+
let notifying = false
|
|
10
|
+
let lines = []
|
|
11
|
+
let total = 0
|
|
12
|
+
|
|
13
|
+
const notify = () => {
|
|
14
|
+
notifying = false
|
|
15
|
+
const waiters = Array.from(waiting)
|
|
16
|
+
waiting.clear()
|
|
17
|
+
waiters.forEach((done) => done())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
push(line) {
|
|
22
|
+
lines.push(line)
|
|
23
|
+
total++
|
|
24
|
+
if (lines.length >= maxLines * 2) lines = lines.slice(-maxLines) // descarta as antigas de uma vez, não a cada linha
|
|
25
|
+
if (waiting.size && !notifying) { // várias linhas seguidas vão na mesma resposta
|
|
26
|
+
notifying = true
|
|
27
|
+
setImmediate(notify)
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// linhas depois do cursor (a janela inteira se o cursor for de outra instância, inválido ou ausente) e o próximo cursor
|
|
32
|
+
since(cursor) {
|
|
33
|
+
const [cursorInstance, sequence] = String(cursor || '').split('.')
|
|
34
|
+
const seen = cursorInstance === instance && /^\d+$/.test(sequence) ? Number(sequence) : -Infinity
|
|
35
|
+
const count = Math.min(total - seen, lines.length, maxLines)
|
|
36
|
+
return { lines: count > 0 ? lines.slice(-count) : [], cursor: `${instance}.${total}` }
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
// resolve quando chegar uma linha nova ou depois de timeoutMs
|
|
40
|
+
waitForLines(timeoutMs) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const done = () => {
|
|
43
|
+
clearTimeout(timer)
|
|
44
|
+
waiting.delete(done)
|
|
45
|
+
resolve()
|
|
46
|
+
}
|
|
47
|
+
const timer = setTimeout(done, timeoutMs)
|
|
48
|
+
waiting.add(done)
|
|
49
|
+
})
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { isAuthorized as defaultIsAuthorized } from './auth.js'
|
|
2
|
+
|
|
3
|
+
// GET dos logs: devolve as linhas depois do cursor (?cursor=) e o próximo cursor no cabeçalho X-Cursor
|
|
4
|
+
// sem linhas novas, espera até waitMs por elas (long polling) antes de responder vazio
|
|
5
|
+
export function createLogsHandler(buffer, { waitMs = 30000, isAuthorized = defaultIsAuthorized } = {}) {
|
|
6
|
+
return async (req, res) => {
|
|
7
|
+
if (!isAuthorized(req.headers.authorization)) {
|
|
8
|
+
res.writeHead(401)
|
|
9
|
+
return res.end()
|
|
10
|
+
}
|
|
11
|
+
const cursor = new URL(req.url, 'http://localhost').searchParams.get('cursor')
|
|
12
|
+
let result = buffer.since(cursor)
|
|
13
|
+
if (!result.lines.length) {
|
|
14
|
+
await buffer.waitForLines(waitMs)
|
|
15
|
+
result = buffer.since(cursor)
|
|
16
|
+
}
|
|
17
|
+
if (res.destroyed) return // cliente desconectou enquanto esperava
|
|
18
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Cursor': result.cursor })
|
|
19
|
+
res.end(result.lines.join(''))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
export function CircularBuffer(size) {
|
|
2
|
-
let currentIndex = size - 1
|
|
3
|
-
const bufferArray = Array(size)
|
|
4
|
-
const indexTable = _newBufferNextIndexTable(size)
|
|
5
|
-
return {
|
|
6
|
-
push(value) {
|
|
7
|
-
currentIndex = _push(currentIndex, indexTable, bufferArray, value)
|
|
8
|
-
return this
|
|
9
|
-
},
|
|
10
|
-
toArray() { return _toArray(currentIndex, indexTable, bufferArray) }
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export function _newBufferNextIndexTable(size) {
|
|
15
|
-
const result = {}
|
|
16
|
-
for (let index = 0; index < size; index++) {
|
|
17
|
-
result[index] = index + 1
|
|
18
|
-
}
|
|
19
|
-
result[size - 1] = 0
|
|
20
|
-
return result
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function _push(currentIndex, indexTable, bufferArray, value) {
|
|
24
|
-
const nextIndex = indexTable[currentIndex]
|
|
25
|
-
bufferArray[nextIndex] = value
|
|
26
|
-
return nextIndex
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function _toArray(currentIndex, indexTable, bufferArray) {
|
|
30
|
-
currentIndex = indexTable[currentIndex]
|
|
31
|
-
if (typeof bufferArray[currentIndex] === 'undefined') return bufferArray.slice(0, currentIndex)
|
|
32
|
-
|
|
33
|
-
const result = []
|
|
34
|
-
for (const value of bufferArray) {
|
|
35
|
-
result.push(bufferArray[currentIndex])
|
|
36
|
-
currentIndex = indexTable[currentIndex]
|
|
37
|
-
}
|
|
38
|
-
return result
|
|
39
|
-
}
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
import chai from 'chai'
|
|
2
|
-
import { _newBufferNextIndexTable, _push, _toArray, CircularBuffer } from './circular-buffer.js'
|
|
3
|
-
|
|
4
|
-
describe(import.meta.url, function() {
|
|
5
|
-
|
|
6
|
-
describe(_newBufferNextIndexTable.name, function() {
|
|
7
|
-
it('deve retornar uma tabela de próximos índices', function() {
|
|
8
|
-
chai.expect(_newBufferNextIndexTable(4)).to.deep.equal({ 0: 1, 1: 2, 2: 3, 3: 0 })
|
|
9
|
-
})
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
describe(_push.name, function() {
|
|
13
|
-
it('deve retornar o array com o valor inserido na posicão 5', function() {
|
|
14
|
-
const value = 'teste'
|
|
15
|
-
const index = 3
|
|
16
|
-
const indexTable = _newBufferNextIndexTable(6)
|
|
17
|
-
const bufferArray = Array(6)
|
|
18
|
-
const nextIndex = _push(index, indexTable, bufferArray, value)
|
|
19
|
-
chai.expect(nextIndex).to.equal(4)
|
|
20
|
-
chai.expect(bufferArray[4]).to.equal(value)
|
|
21
|
-
})
|
|
22
|
-
it('deve retornar o array com o valor inserido na última posição', function() {
|
|
23
|
-
const value = 'teste'
|
|
24
|
-
const index = 4
|
|
25
|
-
const indexTable = _newBufferNextIndexTable(6)
|
|
26
|
-
const bufferArray = Array(6)
|
|
27
|
-
const nextIndex = _push(index, indexTable, bufferArray, value)
|
|
28
|
-
chai.expect(nextIndex).to.equal(5)
|
|
29
|
-
chai.expect(bufferArray[bufferArray.length - 1]).to.equal(value)
|
|
30
|
-
})
|
|
31
|
-
it('deve retornar o array com o valor inserido na primeira posicão', function() {
|
|
32
|
-
const value = 'teste'
|
|
33
|
-
const index = 5
|
|
34
|
-
const indexTable = _newBufferNextIndexTable(6)
|
|
35
|
-
const bufferArray = Array(6)
|
|
36
|
-
const nextIndex = _push(index, indexTable, bufferArray, value)
|
|
37
|
-
chai.expect(nextIndex).to.equal(0)
|
|
38
|
-
chai.expect(bufferArray[0]).to.equal(value)
|
|
39
|
-
})
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
describe(_toArray.name, function() {
|
|
43
|
-
it('deve retornar um array com a órdem dos itens do buffer circular', function() {
|
|
44
|
-
const bufferArray = [4, 5, 0, 1, 2, 3]
|
|
45
|
-
const indexTable = _newBufferNextIndexTable(6)
|
|
46
|
-
const currentIndex = 1
|
|
47
|
-
const expectedArray = [0, 1, 2, 3, 4, 5]
|
|
48
|
-
const resultArray = _toArray(currentIndex, indexTable, bufferArray)
|
|
49
|
-
chai.expect(resultArray).to.deep.equal(expectedArray)
|
|
50
|
-
})
|
|
51
|
-
it('deve retornar um array incompleto', function() {
|
|
52
|
-
const bufferArray = [4, 5, , , , ]
|
|
53
|
-
const indexTable = _newBufferNextIndexTable(6)
|
|
54
|
-
const currentIndex = 1
|
|
55
|
-
const expectedArray = [4, 5]
|
|
56
|
-
const resultArray = _toArray(currentIndex, indexTable, bufferArray)
|
|
57
|
-
chai.expect(resultArray).to.deep.equal(expectedArray)
|
|
58
|
-
})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
describe(CircularBuffer.name, function() {
|
|
62
|
-
it('deve retornar um array preenchido 1', function() {
|
|
63
|
-
const circularBuffer = CircularBuffer(6)
|
|
64
|
-
const expectedArray = [0, 1, 2, 3, 4, 5]
|
|
65
|
-
circularBuffer.push(0)
|
|
66
|
-
circularBuffer.push(1)
|
|
67
|
-
circularBuffer.push(2)
|
|
68
|
-
circularBuffer.push(3)
|
|
69
|
-
circularBuffer.push(4)
|
|
70
|
-
circularBuffer.push(5)
|
|
71
|
-
chai.expect(circularBuffer.toArray()).to.deep.equal(expectedArray)
|
|
72
|
-
})
|
|
73
|
-
it('deve retornar um array preenchido 2', function() {
|
|
74
|
-
const circularBuffer = CircularBuffer(6)
|
|
75
|
-
const expectedArray = [3, 4, 5, 6, 7, 8]
|
|
76
|
-
circularBuffer.push(0)
|
|
77
|
-
circularBuffer.push(1)
|
|
78
|
-
circularBuffer.push(2)
|
|
79
|
-
circularBuffer.push(3)
|
|
80
|
-
circularBuffer.push(4)
|
|
81
|
-
circularBuffer.push(5)
|
|
82
|
-
circularBuffer.push(6)
|
|
83
|
-
circularBuffer.push(7)
|
|
84
|
-
circularBuffer.push(8)
|
|
85
|
-
chai.expect(circularBuffer.toArray()).to.deep.equal(expectedArray)
|
|
86
|
-
})
|
|
87
|
-
it('deve retornar um array preenchido 3', function() {
|
|
88
|
-
const circularBuffer = CircularBuffer(6)
|
|
89
|
-
const expectedArray = [6, 7, 8]
|
|
90
|
-
circularBuffer.push(6)
|
|
91
|
-
circularBuffer.push(7)
|
|
92
|
-
circularBuffer.push(8)
|
|
93
|
-
chai.expect(circularBuffer.toArray()).to.deep.equal(expectedArray)
|
|
94
|
-
})
|
|
95
|
-
it('deve retornar um array vazio', function() {
|
|
96
|
-
const circularBuffer = CircularBuffer(6)
|
|
97
|
-
const expectedArray = []
|
|
98
|
-
chai.expect(circularBuffer.toArray()).to.deep.equal(expectedArray)
|
|
99
|
-
})
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
})
|