Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better device proxy cache #4792

Merged
merged 10 commits into from
Dec 18, 2024
6 changes: 6 additions & 0 deletions forge/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@ module.exports = {
}
}

if (!config.device) {
config.device = {
cache_path: path.join(config.home, '/var/device/cache')
}
}

// need to check that maxIdleDuration is less than maxDuration
if (config.sessions) {
if (config.sessions.maxIdleDuration && config.sessions.maxDuration) {
Expand Down
3 changes: 3 additions & 0 deletions forge/db/controllers/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ module.exports = {
if (state.agentVersion) {
device.set('agentVersion', state.agentVersion)
}
if (state.nodeRedVersion) {
device.set('nodeRedVersion', state.nodeRedVersion)
}
device.set('editorAffinity', state.affinity || null)
if (!state.snapshot || state.snapshot === '0') {
if (device.activeSnapshotId !== null) {
Expand Down
19 changes: 19 additions & 0 deletions forge/db/migrations/20241218-01-add-nr-version-device.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Add Node-RED version to Device table
*/
const { DataTypes } = require('sequelize')

module.exports = {
/**
* upgrade database
* @param {QueryInterface} context Sequelize.QueryInterface
*/
up: async (context) => {
await context.addColumn('Devices', 'nodeRedVersion', {
type: DataTypes.TEXT,
defaultValue: null
})
},
down: async (context) => {
}
}
1 change: 1 addition & 0 deletions forge/db/models/Device.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ module.exports = {
lastSeenAt: { type: DataTypes.DATE, allowNull: true },
settingsHash: { type: DataTypes.STRING, allowNull: true },
agentVersion: { type: DataTypes.STRING, allowNull: true },
nodeRedVersion: { type: DataTypes.STRING, allowNull: true },
mode: { type: DataTypes.STRING, allowNull: true, defaultValue: 'autonomous' },
editorAffinity: { type: DataTypes.STRING, defaultValue: '' },
editorToken: { type: DataTypes.STRING, defaultValue: '' },
Expand Down
47 changes: 47 additions & 0 deletions forge/ee/lib/deviceEditor/DeviceTunnelManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@
* @typedef {(connection: WebSocket, request: FastifyRequest) => void} wsHandler
*/

const fs = require('node:fs')
const path = require('node:path')
const localCacheFiles = [
{ path: '/vendor/monaco/dist/editor.js', type: 'application/json; charset=UTF-8' }, // ~4.1MB
{ path: '/vendor/monaco/dist/ts.worker.js', type: 'application/json; charset=UTF-8' }, // ~4.7MB
{ path: '/vendor/monaco/dist/css.worker.js', type: 'application/json; charset=UTF-8' }, // ~1.1MB
{ path: '/vendor/vendor.js', type: 'application/json; charset=UTF-8' }, // ~1.1MB
{ path: '/vendor/mermaid/mermaid.min.js', type: 'application/json; charset=UTF-8' }, // ~2.5MB
{ path: '/red/red.min.js', type: 'application/json; charset=UTF-8' },
{ path: '/red/style.min.css', type: 'text/css; charset=UTF-8' }
]

class DeviceTunnelManager {
// private members
/** @type {Map<String, DeviceTunnel>} */ #tunnels
Expand Down Expand Up @@ -59,6 +71,9 @@ class DeviceTunnelManager {
this.closeTunnel(deviceId)
}
})

this.pathPrefix = app.config.device?.cache_path
this.pathPostfix = 'node_modules/@node-red/editor-client/public/'
}

/**
Expand Down Expand Up @@ -159,6 +174,8 @@ class DeviceTunnelManager {
device.editorConnected = true
await device.save()

tunnel.nodeRedVersion = device.nodeRedVersion

// Handle messages sent from the device
tunnel.socket.on('message', msg => {
const response = JSON.parse(msg.toString())
Expand Down Expand Up @@ -236,6 +253,36 @@ class DeviceTunnelManager {

/** @type {httpHandler} */
tunnel._handleHTTPGet = (request, reply) => {
const url = request.url.substring(`/api/v1/devices/${tunnel.deviceId}/editor/proxy`.length)
try {
// check this is a cached item before hitting the file system.
// if the file is foound for this version of node-red, serve it from
// the file system, otherwise, fall through to the device tunnel logic
const cacheEntry = localCacheFiles.find(f => url.startsWith(f.path))
if (tunnel.nodeRedVersion && cacheEntry) {
const cachParentDir = path.join(manager.pathPrefix, tunnel.nodeRedVersion)
const cacheDirExists = fs.existsSync(cachParentDir)
if (cacheDirExists) {
const filePath = path.join(cachParentDir, manager.pathPostfix, cacheEntry.path)
const fileExists = fs.existsSync(filePath)
if (fileExists) {
console.info(`Serving cached file: ${filePath}`) // usefull for debugging
const data = fs.readFileSync(filePath)
reply.headers({
'Content-Type': cacheEntry.type,
'Cache-Control': 'public, max-age=0',
'FF-Proxied': 'true'
})
reply.send(data)
return
}
}
}
} catch (_error) {
console.error('Error serving cached file', _error)
// Ignore errors, drop through to regular logic
}
// non cached requests are forwarded to the device
const id = tunnel.nextRequestId++
tunnel.requests[id] = reply
tunnel.socket.send(JSON.stringify({
Expand Down
Loading