Files
blb-throne-of-the-bone-king/socket/server.mjs
T
jacob-mathison 1e9d3f0917
ci/woodpecker/push/woodpecker Pipeline was successful
Lint and Build Checks / Lint, Test, and Build (push) Failing after 345h51m17s
feat(styles): add browser scss builder system
2026-06-25 09:46:09 -05:00

369 lines
9.2 KiB
JavaScript

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(
', ',
)}`,
);
});