creevey
Version:
Cross-browser screenshot testing tool for Storybook with fancy UI Runner
249 lines (221 loc) • 7.9 kB
text/typescript
import fs from 'fs';
import path from 'path';
import { IncomingMessage, ServerResponse, createServer } from 'http';
import { WebSocketServer, WebSocket, RawData } from 'ws';
import { parse, fileURLToPath, pathToFileURL } from 'url';
import { shutdownOnException } from '../utils.js';
import { subscribeOn } from '../messages.js';
import { noop } from '../../types.js';
import { logger } from '../logger.js';
import { CreeveyApi } from './api.js';
import { pingHandler, captureHandler, storiesHandler, staticHandler } from './handlers/index.js';
import { testsHandler } from './handlers/tests-handler.js';
function json<T = unknown>(
handler: (data: T) => void,
defaultValue: T,
): (request: IncomingMessage, response: ServerResponse) => void {
return (request: IncomingMessage, response: ServerResponse) => {
const chunks: Buffer[] = [];
request.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
request.on('end', () => {
try {
const body = Buffer.concat(chunks);
const value = body.length === 0 ? defaultValue : (JSON.parse(body.toString('utf-8')) as T);
handler(value);
response.end();
} catch (error) {
logger().error('Failed to parse JSON', error);
const errorMessage = error instanceof Error ? error.message : String(error);
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain');
response.end(`Failed to parse JSON: ${errorMessage}`);
}
});
request.on('error', (error) => {
logger().error('Failed to parse JSON', error);
const errorMessage = error instanceof Error ? error.message : String(error);
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain');
response.end(`Failed to parse JSON: ${errorMessage}`);
});
};
}
function file(handler: (requestedPath: string) => string | undefined) {
return (request: IncomingMessage, response: ServerResponse) => {
const parsedUrl = parse(request.url ?? '/', true);
const requestedPath = decodeURIComponent(parsedUrl.pathname ?? '/');
try {
const filePath = handler(requestedPath);
if (filePath) {
const stat = fs.statSync(filePath);
// Set appropriate MIME type
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: Record<string, string> = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
response.statusCode = 200;
response.setHeader('Content-Type', contentType);
response.setHeader('Content-Length', stat.size);
// Stream the file
const stream = fs.createReadStream(filePath);
stream.pipe(response);
stream.on('error', (error) => {
logger().error('Error streaming file', error);
if (!response.headersSent) {
const errorMessage = error instanceof Error ? error.message : String(error);
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain');
response.end(`Internal server error: ${errorMessage}`);
}
});
} else {
logger().error('File not found', requestedPath);
response.statusCode = 404;
response.setHeader('Content-Type', 'text/plain');
response.end('File not found');
}
} catch (error) {
logger().error('Failed to serve file', error);
const errorMessage = error instanceof Error ? error.message : String(error);
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain');
response.end(`Failed to serve file: ${errorMessage}`);
}
};
}
const importMetaUrl = pathToFileURL(__filename).href;
export function start(reportDir: string, port: number, ui = false, host?: string): (api: CreeveyApi) => void {
let wss: WebSocketServer | null = null;
let creeveyApi: CreeveyApi | null = null;
let resolveApi: (api: CreeveyApi) => void = noop;
const webDir = path.join(path.dirname(fileURLToPath(importMetaUrl)), '../../client/web');
const server = createServer();
const routes = [
{
path: '/ping',
method: 'GET',
handler: pingHandler,
},
...(ui
? [
{
path: '/tests',
method: 'POST',
handler: json(testsHandler, { tests: {} }),
},
]
: []),
{
path: '/stories',
method: 'POST',
handler: json(storiesHandler, { stories: [] }),
},
{
path: '/capture',
method: 'POST',
handler: json(captureHandler, { workerId: 0, options: undefined }),
},
{
path: '/report/',
method: 'GET',
handler: file(staticHandler(reportDir, '/report/')),
},
{
path: '/',
method: 'GET',
handler: file(staticHandler(webDir)),
},
];
const router = (request: IncomingMessage, response: ServerResponse): void => {
const parsedUrl = parse(request.url ?? '/', true);
const path = parsedUrl.pathname ?? '/';
const method = request.method ?? 'GET';
try {
const route = routes.find((route) => path.startsWith(route.path) && route.method === method);
if (route) {
route.handler(request, response);
} else {
response.statusCode = 404;
response.setHeader('Content-Type', 'text/plain');
response.end('Not Found');
}
} catch (error) {
logger().error('Request handling error', error);
response.statusCode = 500;
response.setHeader('Content-Type', 'text/plain');
response.end('Internal Server Error');
}
};
server.on('request', (request: IncomingMessage, response: ServerResponse): void => {
response.setHeader('Access-Control-Allow-Origin', '*');
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
response.setHeader('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
if (request.method === 'OPTIONS') {
response.statusCode = 200;
response.end();
return;
}
router(request, response);
});
if (ui) {
wss = new WebSocketServer({ server });
wss.on('connection', (ws: WebSocket) => {
ws.on('message', (message: RawData, isBinary: boolean) => {
if (creeveyApi) {
// NOTE Text messages are passed as Buffer https://github.com/websockets/ws/releases/tag/8.0.0
// eslint-disable-next-line @typescript-eslint/no-base-to-string
creeveyApi.handleMessage(ws, isBinary ? message : message.toString('utf-8'));
return;
}
});
ws.on('error', (error) => {
logger().error('WebSocket error', error);
});
});
wss.on('error', (error) => {
logger().error('WebSocket error', error);
});
}
subscribeOn('shutdown', () => {
if (wss) {
wss.clients.forEach((ws) => {
ws.close();
});
wss.close(() => {
server.close();
});
} else {
server.close();
}
});
server
.listen(port, host, () => {
logger().info(`Server starting on port ${port}`);
})
.on('error', (error: unknown) => {
logger().error('Failed to start server', error);
process.exit(1);
});
void new Promise<CreeveyApi>((resolve) => (resolveApi = resolve))
.then((api) => {
creeveyApi = api;
if (wss) {
creeveyApi.subscribe(wss);
}
})
.catch(shutdownOnException);
// Return the function to resolve the API
return resolveApi;
}