Files
blb-throne-of-the-bone-king/socket/server.mjs
T

179 lines
4.0 KiB
JavaScript

import 'dotenv/config';
import { watch } from 'node:fs';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import contentStore from './contentStore.cjs';
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 ?? '5174', 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 httpServer = createServer((request, response) => {
if (request.url === '/health') {
response.writeHead(200, {
'Content-Type': 'application/json',
});
response.end(
JSON.stringify({
ok: true,
service: 'blb-toolkit-socket',
}),
);
return;
}
response.writeHead(404, {
'Content-Type': 'application/json',
});
response.end(
JSON.stringify({
ok: false,
service: 'blb-toolkit-socket',
}),
);
});
const io = new Server(httpServer, {
cors: {
methods: ['GET', 'POST'],
origin: allowedOrigins,
},
});
const contentWatchDebounceMs = 100;
let contentWatchTimer = null;
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 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 creditsWatcher = watch(
contentStore.defaultResourcePaths.credits,
{ persistent: false },
() => {
scheduleContentBroadcast('credits');
},
);
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);
}
});
});
function closeServer(signal) {
console.log(`[toolkit-socket] received ${signal}; shutting down`);
creditsWatcher.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(
', ',
)}`,
);
});