@logux/server
Version:
Build own Logux server or make proxy between WebSocket and HTTP backend on any language
326 lines (314 loc) • 8.57 kB
JavaScript
import { LoguxError } from '@logux/core'
import JSONStream from 'JSONStream'
import { nanoid } from 'nanoid'
import http from 'node:http'
import https from 'node:https'
const VERSION = 4
const RESEND_KEYS = [
'channels',
'channel',
'nodes',
'node',
'clients',
'client',
'users',
'user'
]
function send(backend, command, events) {
let body = JSON.stringify({
commands: [command],
secret: backend.secret,
version: VERSION
})
let protocol = backend.protocol === 'https:' ? https : http
let req = protocol.request(
{
headers: {
'Content-Length': Buffer.byteLength(body),
'Content-Type': 'application/json'
},
host: backend.hostname,
method: 'POST',
path: backend.pathname + backend.search,
port: backend.port
},
res => {
if (res.statusCode < 200 || res.statusCode > 299) {
events.error(new Error(`Backend responded with ${res.statusCode} code`))
} else {
let data = false
res
.pipe(JSONStream.parse('*'))
.on('data', answer => {
if (
typeof answer !== 'object' ||
typeof answer.answer !== 'string'
) {
events.error(new Error('Wrong back-end answer'))
} else if (answer.answer === 'error') {
let err = new Error('Error on back-end server')
err.stack = answer.details
events.error(err)
} else if (events.filter(answer)) {
if (!events[answer.answer]) {
events.error(new Error('Unknown back-end answer'))
} else {
data = true
events[answer.answer](answer)
}
}
})
.on('error', err => {
events.error(err)
})
.on('end', () => {
if (!data) {
events.error(new Error('Empty back-end answer'))
} else if (events.end) {
events.end()
}
})
}
}
)
req.on('error', err => {
events.error(err)
})
req.end(body)
}
export function bindBackendProxy(app) {
if (app.options.controlSecret) {
app.controls['POST /'] = {
command({ action, meta }, req) {
if (!app.types[action.type] && !app.getRegexProcessor(action.type)) {
meta.status = 'processed'
}
meta.backend = req.connection.remoteAddress
return app.log.add(action, meta)
},
isValid({ action, command, meta }) {
return (
command === 'action' &&
typeof action === 'object' &&
typeof action.type === 'string' &&
typeof meta === 'object'
)
}
}
}
if (!app.options.backend) return
if (!app.options.controlSecret) {
let e = new Error('`backend` requires `controlSecret` option')
e.code = 'LOGUX_NO_CONTROL_SECRET'
throw e
}
let backend = new URL(app.options.backend)
backend.secret = app.options.controlSecret
let resending = new Map()
let accessing = new Map()
let processing = new Map()
let actions = new Map()
function sendAction(action, meta, headers) {
let resendResolve
if (action.type !== 'logux/subscribe') {
resending.set(
meta.id,
new Promise(resolve => {
resendResolve = resolve
})
)
}
let accessReject, accessResolve
accessing.set(
meta.id,
new Promise((resolve, reject) => {
accessResolve = resolve
accessReject = reject
})
)
let processReject, processResolve
processing.set(
meta.id,
new Promise((resolve, reject) => {
processResolve = resolve
processReject = reject
})
)
let checked = false
let processed = false
let error = false
function currentReject(e) {
if (resendResolve) resendResolve()
if (checked) {
processReject(e)
} else {
accessReject(e)
}
}
let start = Date.now()
app.emitter.emit('backendSent', action, meta)
send(
backend,
{ action, command: 'action', headers, meta },
{
action(data) {
let promise = app.log.add(data.action, {
status: 'processed',
...data.meta
})
if (actions.has(meta.id)) {
actions.get(meta.id).push(promise)
} else {
actions.set(meta.id, [promise])
}
},
approved() {
if (resendResolve) resendResolve()
app.emitter.emit('backendGranted', action, meta, Date.now() - start)
checked = true
accessResolve(true)
},
end() {
if (!error && (!checked || !processed)) {
currentReject(new Error('Back-end do not send required answers'))
}
},
error(e) {
error = true
currentReject(e)
},
filter({ id }) {
return id === meta.id
},
forbidden() {
if (resendResolve) resendResolve()
error = true
accessResolve(false)
},
async processed() {
if (!checked) {
error = true
accessReject(new Error('Processed answer was sent before access'))
} else {
processed = true
await Promise.all(actions.get(meta.id) || [])
app.emitter.emit(
'backendProcessed',
action,
meta,
Date.now() - start
)
processResolve()
}
},
resend(answer) {
if (checked) {
error = true
currentReject(new Error('Resend answer was sent after access'))
} else if (action.type === 'logux/subscribe') {
error = true
accessReject(new Error('Resend can be called on subscription'))
} else {
let resend = {}
for (let key of RESEND_KEYS) {
if (typeof answer[key] !== 'undefined') {
resend[key] = answer[key]
}
}
resendResolve(resend)
}
},
unknownAction() {
resendResolve()
error = true
app.unknownType(action, meta)
accessResolve(false)
},
unknownChannel() {
error = true
app.wrongChannel(action, meta)
accessResolve(false)
}
}
)
}
app.auth(
({ client, cookie, headers, token, userId }) =>
new Promise((resolve, reject) => {
let random = nanoid()
send(
backend,
{
authId: random,
command: 'auth',
cookie,
headers,
subprotocol: client.node.remoteSubprotocol,
token,
userId
},
{
authenticated({ subprotocol }) {
if (subprotocol) {
app.options.subprotocol = subprotocol
client.node.options.subprotocol = subprotocol
}
resolve(true)
},
denied() {
resolve(false)
},
error(e) {
reject(e)
},
filter({ authId }) {
return random === authId
},
wrongSubprotocol({ supported }) {
reject(
new LoguxError('wrong-subprotocol', {
supported,
used: client.node.remoteSubprotocol
})
)
}
}
)
})
)
app.otherType({
access(ctx, action, meta) {
sendAction(action, meta, ctx.headers)
return accessing.get(meta.id)
},
finally(ctx, action, meta) {
actions.delete(meta.id)
resending.delete(meta.id)
accessing.delete(meta.id)
processing.delete(meta.id)
},
process(ctx, action, meta) {
return processing.get(meta.id)
},
resend(ctx, action, meta) {
if (!resending.has(meta.id)) {
sendAction(action, meta, ctx.headers)
}
return resending.get(meta.id)
}
})
app.otherChannel({
access(ctx, action, meta) {
sendAction(action, meta, ctx.headers)
return accessing.get(meta.id)
},
finally(ctx, action, meta) {
actions.delete(meta.id)
accessing.delete(meta.id)
processing.delete(meta.id)
},
load(ctx, action, meta) {
return processing.get(meta.id)
}
})
}