369 lines
9.2 KiB
JavaScript
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(
|
|
', ',
|
|
)}`,
|
|
);
|
|
});
|