Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions packages/core/src/helpers/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import type http from 'http';
import { vol } from 'memfs';
import nock from 'nock';

import { prepareFile, runServer } from './server';

// Use mock files.
jest.mock('fs', () => require('memfs').fs);
jest.mock('fs/promises', () => require('memfs').fs.promises);

const PORT = 3000;

describe('Server', () => {
describe('prepareFile', () => {
beforeAll(() => {
vol.fromJSON(
{
'/system/sensitive.txt': 'sensitive data',
'/root/index.html': '<html>Hello World</html>',
'/root/styles.css': 'body { color: red; }',
},
'/',
);
});

afterAll(() => {
vol.reset();
});

test('Should return the correct file.', async () => {
const file = await prepareFile('/root', '/styles.css');
expect(file.found).toBe(true);
expect(file.ext).toBe('css');
expect(file.content).toBe('body { color: red; }');
});

test('Should handle missing files.', async () => {
const file = await prepareFile('/root', '/nonexistent.txt');
expect(file.found).toBe(false);
expect(file.content).toBe('');
});

test('Should append index.html when path ends with /', async () => {
const file = await prepareFile('/root', '/');
expect(file.found).toBe(true);
expect(file.ext).toBe('html');
expect(file.content).toBe('<html>Hello World</html>');
});

test('Should prevent path traversal attacks', async () => {
const file = await prepareFile('/root', '/../system/sensitive.txt');
expect(file.found).toBe(false);
});
});

describe('runServer', () => {
let server: http.Server;

beforeAll(() => {
// Allow local server.
nock.enableNetConnect('127.0.0.1');

// Add one file.
vol.fromJSON({
'/root/index.html': '<html>Hello World</html>',
});
});

afterAll(() => {
vol.reset();
nock.cleanAll();
nock.disableNetConnect();
});

afterEach(() => {
if (!server) {
return;
}

server.close();
server.closeAllConnections();
server.closeIdleConnections();
});

test('Should start the server', async () => {
server = runServer({
port: PORT,
root: '/root',
});
expect(server).toBeDefined();
expect(server.listening).toBe(true);
});

test('Should handle routes', async () => {
const getHandler = jest.fn((req, res) => {
res.end('Hello World');
});

const routes = {
'/route': {
get: getHandler,
},
};

server = runServer({
port: PORT,
root: '/root',
routes,
});

const response = await fetch(`http://127.0.0.1:${PORT}/route`);
expect(response.ok).toBe(true);
expect(await response.text()).toBe('Hello World');
expect(getHandler).toHaveBeenCalled();
});

test("Should fallback to files when routes doesn't hit", async () => {
const routes = {
'/route': {
get: jest.fn(),
},
};

server = runServer({
port: PORT,
root: '/root',
routes,
});

const response = await fetch(`http://127.0.0.1:${PORT}/`);
expect(response.ok).toBe(true);
expect(await response.text()).toBe('<html>Hello World</html>');
});

test('Should use middleware', async () => {
const middleware = jest.fn((response) => {
return {
statusCode: 201,
headers: {
'Content-Type': 'text/plain',
},
body: `Content was: ${response.body}`,
};
});

server = runServer({
port: PORT,
root: '/root',
middleware,
});

const response = await fetch(`http://127.0.0.1:${PORT}/`);
expect(response.ok).toBe(true);
expect(response.status).toBe(201);
expect(response.headers.get('Content-Type')).toBe('text/plain');
expect(await response.text()).toBe('Content was: <html>Hello World</html>');
expect(middleware).toHaveBeenCalled();
});
});
});
131 changes: 131 additions & 0 deletions packages/core/src/helpers/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

import fs from 'fs';
import http from 'http';
import path from 'path';

const MIME_TYPES = {
default: 'application/octet-stream',
html: 'text/html; charset=UTF-8',
js: 'application/javascript',
css: 'text/css',
png: 'image/png',
jpg: 'image/jpg',
gif: 'image/gif',
ico: 'image/x-icon',
svg: 'image/svg+xml',
} as const;

type File = {
found: boolean;
ext: keyof typeof MIME_TYPES;
content: string;
};
type RouteVerb = 'get' | 'post' | 'put' | 'patch' | 'delete';
type Routes = Record<
string,
Partial<
Record<
RouteVerb,
(req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>
>
>
>;
type Response = {
statusCode: number;
headers: Record<string, string>;
body: string;
error?: Error;
};
type RunServerOptions = {
port: number;
root: string;
routes?: Routes;
middleware?: (
resp: Response,
req: http.IncomingMessage,
res: http.ServerResponse,
) => Partial<Response> | Promise<Partial<Response>>;
};

// Promise to boolean.
const toBool = [() => true, () => false];

export const prepareFile = async (root: string, requestUrl: string): Promise<File> => {
const staticPath = root
? path.isAbsolute(root)
? root
: path.resolve(process.cwd(), root)
: process.cwd();
const url = new URL(requestUrl, 'http://127.0.0.1');
const paths = [staticPath, url.pathname];

if (url.pathname.endsWith('/')) {
paths.push('index.html');
}

const filePath = path.join(...paths);
const pathTraversal = !filePath.startsWith(staticPath);
const exists = await fs.promises.access(filePath).then(...toBool);
const found = !pathTraversal && exists;
const ext = path.extname(filePath).substring(1).toLowerCase() as File['ext'];
const fileContent = found ? await fs.promises.readFile(filePath, { encoding: 'utf-8' }) : '';

return { found, ext, content: fileContent };
};

export const runServer = ({ port, root, middleware, routes }: RunServerOptions) => {
const server = http.createServer(async (req, res) => {
const response: Response = {
statusCode: 200,
headers: {},
body: '',
};

try {
// Handle routes.
const route = routes?.[req.url || '/'];
if (route) {
const verb = req.method?.toLowerCase() as RouteVerb;
const handler = route[verb];
if (handler) {
// Hands off to the route handler.
await handler(req, res);
return;
}
}

// Fallback to files.
const file = await prepareFile(root, req.url || '/');
const statusCode = file.found ? 200 : 404;
const mimeType = MIME_TYPES[file.ext] || MIME_TYPES.default;

response.statusCode = statusCode;
response.headers['Content-Type'] = mimeType;
response.body = file.content;
} catch (e: any) {
response.statusCode = 500;
response.headers['Content-Type'] = MIME_TYPES.html;
response.body = 'Internal Server Error';
response.error = e;
}

if (middleware) {
const middlewareResponse = await middleware(response, req, res);
response.statusCode = middlewareResponse.statusCode ?? response.statusCode;
response.headers = {
...response.headers,
...(middlewareResponse.headers ?? {}),
};
response.body = middlewareResponse.body ?? response.body;
}

res.writeHead(response.statusCode, response.headers);
res.end(response.body);
});

server.listen(port);
return server;
};
Loading