webcm
Version:
Demonstrative implementation of a web-based manager for utilising Managed Components
294 lines (260 loc) • 9.34 kB
text/typescript
import { MCEventListener } from '@managed-components/types'
import express, { Request, Response, RequestHandler } from 'express'
import { IncomingMessage, ClientRequest } from 'http'
import {
createProxyMiddleware,
responseInterceptor,
} from 'http-proxy-middleware'
import * as path from 'path'
import { Client, ClientGeneric } from './client'
import { ManagerGeneric, MCEvent } from './manager'
import { getConfig } from './config'
import { PERMISSIONS } from './constants'
import { StaticServer } from './static-server'
import _locreq from 'locreq'
const locreq = _locreq(__dirname)
const DEFAULT_TARGET = 'http://localhost:8000'
if (process.env.NODE_ENV === 'production') {
process.on('unhandledRejection', (reason: Error) => {
console.log('Unhandled Rejection at:', reason.stack || reason)
})
}
type BasicServerConfig = {
configPath?: string
componentsFolderPath?: string
url?: string
}
type CustomComponentServerConfig = BasicServerConfig & {
customComponentPath?: string
customComponentSettings?: Record<string, unknown>
}
type ServerConfig = BasicServerConfig | CustomComponentServerConfig
export async function startServerFromConfig({
configPath,
componentsFolderPath,
url,
...args
}: ServerConfig) {
const config = getConfig(configPath)
let componentsPath = ''
if (componentsFolderPath) {
componentsPath = path.resolve(componentsFolderPath)
} else {
console.log('Components folder path not provided')
}
const { hostname, port, trackPath, components } = config
if ('customComponentPath' in args && args.customComponentPath) {
console.log(
`⚠️ Custom component ${args.customComponentPath} will run with all permissions enabled, use webcm.config.ts to change what permissions it gets`
)
components.push({
path: path.resolve(args.customComponentPath),
permissions: Object.values(PERMISSIONS), // use all permissions, it's just for testing
settings: args.customComponentSettings || {},
})
}
if (url) {
if (!(url.startsWith('http://') || url.startsWith('https://'))) {
url = 'http://' + url
}
config.target = url
} else if (!config.target) {
const server = new StaticServer(8000)
server.start()
console.log('Started a demo static server at localhost:8000')
config.target = DEFAULT_TARGET
}
const manager = new ManagerGeneric({
components,
trackPath,
componentsFolderPath: componentsPath,
})
await manager.init()
const getDefaultPayload = () => ({
pageVars: [],
fetch: [],
execute: [],
return: undefined,
})
const handleClientCreated = (
req: Request,
_: Response,
clientGeneric: ClientGeneric
) => {
const cookieName = 'webcm_clientcreated'
const eventName = 'clientcreated'
let clientAlreadyCreated = clientGeneric.cookies.get(cookieName) || ''
if (!manager.listeners[eventName]) return
for (const componentName of Object.keys(manager.listeners[eventName])) {
if (clientAlreadyCreated.split(',')?.includes(componentName)) continue
const event = new MCEvent(eventName, req)
event.client = new Client(componentName as string, clientGeneric)
clientAlreadyCreated = Array.from(
new Set([...clientAlreadyCreated.split(','), componentName])
).join(',')
clientGeneric.set(cookieName, clientAlreadyCreated)
manager.listeners[eventName][componentName]?.forEach(
(fn: MCEventListener) => fn(event)
)
}
}
const handleEvent = async (
eventType: string,
req: Request,
res: Response
) => {
res.payload = getDefaultPayload()
const clientGeneric = new ClientGeneric(req, res, manager, config)
handleClientCreated(req, res, clientGeneric)
if (manager.listeners[eventType]) {
// slightly alter ecommerce payload
if (eventType === 'ecommerce') {
req.body.payload.ecommerce = { ...req.body.payload.data }
delete req.body.payload.data
}
const event = new MCEvent(eventType, req)
for (const componentName of Object.keys(manager.listeners[eventType])) {
event.client = new Client(componentName, clientGeneric)
await Promise.all(
manager.listeners[eventType][componentName].map(
(fn: MCEventListener) => fn(event)
)
)
}
res.payload.execute.push(manager.getInjectedScript(clientGeneric))
}
return res.end(JSON.stringify(res.payload))
}
const handleClientEvent = async (req: Request, res: Response) => {
res.payload = getDefaultPayload()
const event = new MCEvent(req.body.payload.event, req)
const clientGeneric = new ClientGeneric(req, res, manager, config)
const clientComponentNames = Object.entries(
clientGeneric.webcmPrefs.listeners
)
.filter(([, events]) => events.includes(req.body.payload.event))
.map(([componentName]) => componentName)
for (const component of clientComponentNames) {
event.client = new Client(component, clientGeneric)
try {
await manager.clientListeners[
req.body.payload.event + '__' + component
](event)
} catch {
console.error(
`Error dispatching ${req.body.payload.event} to ${component}: it isn't registered`
)
}
}
res.payload.execute.push(manager.getInjectedScript(clientGeneric))
res.end(JSON.stringify(res.payload))
}
// 'event', 'ecommerce' 'pageview', 'client' are the standard types
// 'remarketing', 'identify' or any other event type
const handleTrack: RequestHandler = (req, res) => {
const eventType = req.body.eventType
if (eventType === 'client') {
return handleClientEvent(req, res)
} else {
return handleEvent(eventType, req, res)
}
}
const handleRequest = (req: Request, clientGeneric: ClientGeneric) => {
if (!manager.listeners['request']) return
const requestEvent = new MCEvent('request', req)
for (const componentName of Object.keys(manager.listeners['request'])) {
requestEvent.client = new Client(componentName, clientGeneric)
manager.listeners['request'][componentName]?.forEach(
(fn: MCEventListener) => fn(requestEvent)
)
}
}
const app = express().use(express.json())
app.set('trust proxy', true)
// Mount WebCM endpoint
app.post(trackPath, handleTrack)
// Mount components endpoints
for (const route of Object.keys(manager.mappedEndpoints)) {
app.all(route, async (req, res) => {
const response = await manager.mappedEndpoints[route](req)
for (const [headerName, headerValue] of response.headers.entries()) {
res.set(headerName, headerValue)
}
res.status(response.status)
let isDone = false
const reader = response.body?.getReader()
while (!isDone && reader) {
const { value, done } = await reader.read()
if (value) res.write(Buffer.from(value))
isDone = done
}
res.end()
})
}
// Mount components proxied endpoints
for (const component of Object.keys(manager.proxiedEndpoints)) {
for (const [path, proxyTarget] of Object.entries(
manager.proxiedEndpoints[component]
)) {
const proxyEndpoint = '/webcm/' + component + path
app.all(proxyEndpoint + '*', async (req, res, next) => {
const proxy = createProxyMiddleware({
target: proxyTarget + req.path.replace(proxyEndpoint, ''),
ignorePath: true,
followRedirects: true,
changeOrigin: true,
})
proxy(req, res, next)
})
}
}
// Mount static files
for (const [filePath, fileTarget] of Object.entries(manager.staticFiles)) {
app.use(filePath, express.static(path.join(componentsPath, fileTarget)))
}
// Listen to all normal requests
app.use('**', (req, res, next) => {
res.payload = getDefaultPayload()
const clientGeneric = new ClientGeneric(req, res, manager, config)
const proxySettings = {
target: config.target,
changeOrigin: true,
selfHandleResponse: true,
onProxyReq: (
_proxyReq: ClientRequest,
req: IncomingMessage,
_res: Response
) => {
handleRequest(req as Request, clientGeneric)
},
onProxyRes: responseInterceptor(
async (responseBuffer, _proxyRes, proxyReq, _res) => {
if (proxyReq.headers['accept']?.toLowerCase().includes('text/html')) {
let response = responseBuffer.toString('utf8') as string
response = await manager.processEmbeds(response)
response = await manager.processWidgets(response)
return response.replace(
'<head>',
`<head><script>${manager.getInjectedScript(
clientGeneric
)};webcm._processServerResponse(${JSON.stringify(
res.payload
)})</script>`
)
}
return responseBuffer
}
),
}
const proxy = createProxyMiddleware(proxySettings)
proxy(req, res, next)
})
console.info(
'\nWebCM, version',
process.env.npm_package_version || locreq('package.json').version
)
app.listen(port, hostname)
console.info(
`\n🚀 WebCM is now proxying ${config.target} at http://${hostname}:${port}\n\n`
)
}