import 'dotenv/config'; import { watch } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { createServer } from 'node:http'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { Server } from 'socket.io'; import contentStore from './contentStore.cjs'; const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); const require = createRequire(import.meta.url); const packageJson = require('../package.json'); const defaultWebOrigin = 'http://127.0.0.1:5173'; const host = process.env.TOOLKIT_SOCKET_HOST ?? '127.0.0.1'; const port = Number.parseInt(process.env.TOOLKIT_SOCKET_PORT ?? '5175', 10); const allowedOrigins = ( process.env.TOOLKIT_SOCKET_CORS_ORIGIN ?? defaultWebOrigin ) .split(',') .map(origin => origin.trim()) .filter(Boolean); if (Number.isNaN(port)) { throw new Error('TOOLKIT_SOCKET_PORT must be a valid number.'); } const socketHealthStartedAt = Date.now(); const socketHealthStartedAtIso = new Date(socketHealthStartedAt).toISOString(); function createSocketHealthPayload() { return { ok: true, service: 'blb-toolkit-socket', version: packageJson.version, startedAt: socketHealthStartedAtIso, timestamp: new Date().toISOString(), uptimeSeconds: Math.max( 0, Math.round((Date.now() - socketHealthStartedAt) / 1000), ), }; } function writeJsonResponse(response, statusCode, body) { response.writeHead(statusCode, { 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', 'Content-Type': 'application/json', }); response.end(`${JSON.stringify(body)}\n`); } const httpServer = createServer((request, response) => { const pathname = request.url?.split('?')[0]; if (pathname === '/health') { writeJsonResponse(response, 200, createSocketHealthPayload()); return; } writeJsonResponse(response, 404, { ok: false, service: 'blb-toolkit-socket', }); }); const io = new Server(httpServer, { cors: { methods: ['GET', 'POST'], origin: allowedOrigins, }, maxHttpBufferSize: 100 * 1024 * 1024, }); const contentWatchDebounceMs = 100; let contentWatchTimer = null; const assetUploadConfig = { audio: { directory: path.join(repositoryRoot, 'src', 'assets', 'music', 'uploads'), publicPrefix: '/src/assets/music/uploads', }, image: { directory: path.join(repositoryRoot, 'src', 'assets', 'images', 'uploads'), publicPrefix: '/src/assets/images/uploads', }, }; function getErrorMessage(error) { return error instanceof Error ? error.message : 'Unknown toolkit content error.'; } function createContentError(resource, error) { return { error: getErrorMessage(error), resource, }; } async function readContent(resource) { return contentStore.readJsonResource(resource); } async function saveContent(resource, content) { return contentStore.writeJsonResource(resource, content); } async function createContentRecord(resource, record) { return contentStore.createJsonRecord(resource, record); } async function updateContentRecord(resource, record) { return contentStore.updateJsonRecord(resource, record); } async function deleteContentRecord(resource, id) { return contentStore.deleteJsonRecord(resource, id); } function sanitizeAssetFileName(fileName) { const baseName = path.basename(String(fileName ?? 'asset')); const sanitizedName = baseName.replace(/[^a-zA-Z0-9._-]+/g, '-'); return sanitizedName.length > 0 ? sanitizedName : 'asset'; } function parseDataUrl(dataUrl) { if (typeof dataUrl !== 'string') { throw new Error('Asset upload requires a data URL.'); } const dataUrlMatch = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl); if (dataUrlMatch == null) { throw new Error('Asset upload data URL was invalid.'); } return { buffer: Buffer.from(dataUrlMatch[2], 'base64'), mimeType: dataUrlMatch[1], }; } async function uploadAsset(payload) { const kind = payload?.kind; const uploadConfig = assetUploadConfig[kind]; if (uploadConfig == null) { throw new Error(`Unsupported asset upload kind: ${String(kind)}`); } const { buffer, mimeType } = parseDataUrl(payload?.dataUrl); if (!mimeType.startsWith(`${kind}/`)) { throw new Error(`Selected file was not a valid ${kind} asset.`); } const fileName = `${Date.now()}-${sanitizeAssetFileName(payload?.fileName)}`; const assetPath = path.join(uploadConfig.directory, fileName); await mkdir(uploadConfig.directory, { recursive: true }); await writeFile(assetPath, buffer); return { assetPath: path.relative(repositoryRoot, assetPath), fileName, kind, mimeType, publicPath: `${uploadConfig.publicPrefix}/${fileName}`, }; } async function broadcastChangedContent(resource) { try { io.emit('toolkit:content:changed', await readContent(resource)); } catch (error) { io.emit('toolkit:content:error', createContentError(resource, error)); } } function scheduleContentBroadcast(resource) { if (contentWatchTimer != null) { clearTimeout(contentWatchTimer); } contentWatchTimer = setTimeout(() => { contentWatchTimer = null; broadcastChangedContent(resource); }, contentWatchDebounceMs); } const contentWatchers = Object.entries(contentStore.defaultResourcePaths).map( ([resource, resourcePath]) => watch(resourcePath, { persistent: false }, () => { scheduleContentBroadcast(resource); }), ); io.on('connection', socket => { socket.emit('toolkit:ready', { connectedAt: new Date().toISOString(), socketId: socket.id, }); socket.on('toolkit:event', payload => { socket.broadcast.emit('toolkit:event', payload); }); socket.on('toolkit:content:read', async (payload, respond) => { const resource = payload?.resource; try { respond?.({ ok: true, ...(await readContent(resource)), }); } catch (error) { const contentError = createContentError(resource, error); respond?.({ ok: false, ...contentError, }); socket.emit('toolkit:content:error', contentError); } }); socket.on('toolkit:content:save', async (payload, respond) => { const resource = payload?.resource; try { const changedContent = await saveContent(resource, payload?.content); respond?.({ ok: true, ...changedContent, }); io.emit('toolkit:content:changed', changedContent); } catch (error) { const contentError = createContentError(resource, error); respond?.({ ok: false, ...contentError, }); socket.emit('toolkit:content:error', contentError); } }); socket.on('toolkit:content:create', async (payload, respond) => { const resource = payload?.resource; try { const changedContent = await createContentRecord( resource, payload?.record, ); respond?.({ ok: true, ...changedContent, }); io.emit('toolkit:content:changed', changedContent); } catch (error) { const contentError = createContentError(resource, error); respond?.({ ok: false, ...contentError, }); socket.emit('toolkit:content:error', contentError); } }); socket.on('toolkit:content:update', async (payload, respond) => { const resource = payload?.resource; try { const changedContent = await updateContentRecord( resource, payload?.record, ); respond?.({ ok: true, ...changedContent, }); io.emit('toolkit:content:changed', changedContent); } catch (error) { const contentError = createContentError(resource, error); respond?.({ ok: false, ...contentError, }); socket.emit('toolkit:content:error', contentError); } }); socket.on('toolkit:content:delete', async (payload, respond) => { const resource = payload?.resource; try { const changedContent = await deleteContentRecord(resource, payload?.id); respond?.({ ok: true, ...changedContent, }); io.emit('toolkit:content:changed', changedContent); } catch (error) { const contentError = createContentError(resource, error); respond?.({ ok: false, ...contentError, }); socket.emit('toolkit:content:error', contentError); } }); socket.on('toolkit:asset:upload', async (payload, respond) => { try { respond?.({ ok: true, ...(await uploadAsset(payload)), }); } catch (error) { respond?.({ error: getErrorMessage(error), ok: false, }); } }); }); function closeServer(signal) { console.log(`[toolkit-socket] received ${signal}; shutting down`); for (const contentWatcher of contentWatchers) { contentWatcher.close(); } io.close(() => { httpServer.close(() => { process.exit(0); }); }); } process.on('SIGINT', () => closeServer('SIGINT')); process.on('SIGTERM', () => closeServer('SIGTERM')); httpServer.listen(port, host, () => { console.log( `[toolkit-socket] listening at http://${host}:${port} with CORS ${allowedOrigins.join( ', ', )}`, ); });