Skip to content

Commit 68c9fff

Browse files
authored
Merge pull request #152 from DataDog/yoann/move-dev-server
[internal] Move dev server helper
2 parents f4e9a44 + ebdf1f8 commit 68c9fff

File tree

3 files changed

+324
-69
lines changed

3 files changed

+324
-69
lines changed
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import type http from 'http';
6+
import { vol } from 'memfs';
7+
import nock from 'nock';
8+
9+
import { prepareFile, runServer } from './server';
10+
11+
// Use mock files.
12+
jest.mock('fs', () => require('memfs').fs);
13+
jest.mock('fs/promises', () => require('memfs').fs.promises);
14+
15+
const PORT = 3000;
16+
17+
describe('Server', () => {
18+
describe('prepareFile', () => {
19+
beforeAll(() => {
20+
vol.fromJSON(
21+
{
22+
'/system/sensitive.txt': 'sensitive data',
23+
'/root/index.html': '<html>Hello World</html>',
24+
'/root/styles.css': 'body { color: red; }',
25+
},
26+
'/',
27+
);
28+
});
29+
30+
afterAll(() => {
31+
vol.reset();
32+
});
33+
34+
test('Should return the correct file.', async () => {
35+
const file = await prepareFile('/root', '/styles.css');
36+
expect(file.found).toBe(true);
37+
expect(file.ext).toBe('css');
38+
expect(file.content).toBe('body { color: red; }');
39+
});
40+
41+
test('Should handle missing files.', async () => {
42+
const file = await prepareFile('/root', '/nonexistent.txt');
43+
expect(file.found).toBe(false);
44+
expect(file.content).toBe('');
45+
});
46+
47+
test('Should append index.html when path ends with /', async () => {
48+
const file = await prepareFile('/root', '/');
49+
expect(file.found).toBe(true);
50+
expect(file.ext).toBe('html');
51+
expect(file.content).toBe('<html>Hello World</html>');
52+
});
53+
54+
test('Should prevent path traversal attacks', async () => {
55+
const file = await prepareFile('/root', '/../system/sensitive.txt');
56+
expect(file.found).toBe(false);
57+
});
58+
});
59+
60+
describe('runServer', () => {
61+
let server: http.Server;
62+
63+
beforeAll(() => {
64+
// Allow local server.
65+
nock.enableNetConnect('127.0.0.1');
66+
67+
// Add one file.
68+
vol.fromJSON({
69+
'/root/index.html': '<html>Hello World</html>',
70+
});
71+
});
72+
73+
afterAll(() => {
74+
vol.reset();
75+
nock.cleanAll();
76+
nock.disableNetConnect();
77+
});
78+
79+
afterEach(() => {
80+
if (!server) {
81+
return;
82+
}
83+
84+
server.close();
85+
server.closeAllConnections();
86+
server.closeIdleConnections();
87+
});
88+
89+
test('Should start the server', async () => {
90+
server = runServer({
91+
port: PORT,
92+
root: '/root',
93+
});
94+
expect(server).toBeDefined();
95+
expect(server.listening).toBe(true);
96+
});
97+
98+
test('Should handle routes', async () => {
99+
const getHandler = jest.fn((req, res) => {
100+
res.end('Hello World');
101+
});
102+
103+
const routes = {
104+
'/route': {
105+
get: getHandler,
106+
},
107+
};
108+
109+
server = runServer({
110+
port: PORT,
111+
root: '/root',
112+
routes,
113+
});
114+
115+
const response = await fetch(`http://127.0.0.1:${PORT}/route`);
116+
expect(response.ok).toBe(true);
117+
expect(await response.text()).toBe('Hello World');
118+
expect(getHandler).toHaveBeenCalled();
119+
});
120+
121+
test("Should fallback to files when routes doesn't hit", async () => {
122+
const routes = {
123+
'/route': {
124+
get: jest.fn(),
125+
},
126+
};
127+
128+
server = runServer({
129+
port: PORT,
130+
root: '/root',
131+
routes,
132+
});
133+
134+
const response = await fetch(`http://127.0.0.1:${PORT}/`);
135+
expect(response.ok).toBe(true);
136+
expect(await response.text()).toBe('<html>Hello World</html>');
137+
});
138+
139+
test('Should use middleware', async () => {
140+
const middleware = jest.fn((response) => {
141+
return {
142+
statusCode: 201,
143+
headers: {
144+
'Content-Type': 'text/plain',
145+
},
146+
body: `Content was: ${response.body}`,
147+
};
148+
});
149+
150+
server = runServer({
151+
port: PORT,
152+
root: '/root',
153+
middleware,
154+
});
155+
156+
const response = await fetch(`http://127.0.0.1:${PORT}/`);
157+
expect(response.ok).toBe(true);
158+
expect(response.status).toBe(201);
159+
expect(response.headers.get('Content-Type')).toBe('text/plain');
160+
expect(await response.text()).toBe('Content was: <html>Hello World</html>');
161+
expect(middleware).toHaveBeenCalled();
162+
});
163+
});
164+
});
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import fs from 'fs';
6+
import http from 'http';
7+
import path from 'path';
8+
9+
const MIME_TYPES = {
10+
default: 'application/octet-stream',
11+
html: 'text/html; charset=UTF-8',
12+
js: 'application/javascript',
13+
css: 'text/css',
14+
png: 'image/png',
15+
jpg: 'image/jpg',
16+
gif: 'image/gif',
17+
ico: 'image/x-icon',
18+
svg: 'image/svg+xml',
19+
} as const;
20+
21+
type File = {
22+
found: boolean;
23+
ext: keyof typeof MIME_TYPES;
24+
content: string;
25+
};
26+
type RouteVerb = 'get' | 'post' | 'put' | 'patch' | 'delete';
27+
type Routes = Record<
28+
string,
29+
Partial<
30+
Record<
31+
RouteVerb,
32+
(req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>
33+
>
34+
>
35+
>;
36+
type Response = {
37+
statusCode: number;
38+
headers: Record<string, string>;
39+
body: string;
40+
error?: Error;
41+
};
42+
type RunServerOptions = {
43+
port: number;
44+
root: string;
45+
routes?: Routes;
46+
middleware?: (
47+
resp: Response,
48+
req: http.IncomingMessage,
49+
res: http.ServerResponse,
50+
) => Partial<Response> | Promise<Partial<Response>>;
51+
};
52+
53+
// Promise to boolean.
54+
const toBool = [() => true, () => false];
55+
56+
export const prepareFile = async (root: string, requestUrl: string): Promise<File> => {
57+
const staticPath = root
58+
? path.isAbsolute(root)
59+
? root
60+
: path.resolve(process.cwd(), root)
61+
: process.cwd();
62+
const url = new URL(requestUrl, 'http://127.0.0.1');
63+
const paths = [staticPath, url.pathname];
64+
65+
if (url.pathname.endsWith('/')) {
66+
paths.push('index.html');
67+
}
68+
69+
const filePath = path.join(...paths);
70+
const pathTraversal = !filePath.startsWith(staticPath);
71+
const exists = await fs.promises.access(filePath).then(...toBool);
72+
const found = !pathTraversal && exists;
73+
const ext = path.extname(filePath).substring(1).toLowerCase() as File['ext'];
74+
const fileContent = found ? await fs.promises.readFile(filePath, { encoding: 'utf-8' }) : '';
75+
76+
return { found, ext, content: fileContent };
77+
};
78+
79+
export const runServer = ({ port, root, middleware, routes }: RunServerOptions) => {
80+
const server = http.createServer(async (req, res) => {
81+
const response: Response = {
82+
statusCode: 200,
83+
headers: {},
84+
body: '',
85+
};
86+
87+
try {
88+
// Handle routes.
89+
const route = routes?.[req.url || '/'];
90+
if (route) {
91+
const verb = req.method?.toLowerCase() as RouteVerb;
92+
const handler = route[verb];
93+
if (handler) {
94+
// Hands off to the route handler.
95+
await handler(req, res);
96+
return;
97+
}
98+
}
99+
100+
// Fallback to files.
101+
const file = await prepareFile(root, req.url || '/');
102+
const statusCode = file.found ? 200 : 404;
103+
const mimeType = MIME_TYPES[file.ext] || MIME_TYPES.default;
104+
105+
response.statusCode = statusCode;
106+
response.headers['Content-Type'] = mimeType;
107+
response.body = file.content;
108+
} catch (e: any) {
109+
response.statusCode = 500;
110+
response.headers['Content-Type'] = MIME_TYPES.html;
111+
response.body = 'Internal Server Error';
112+
response.error = e;
113+
}
114+
115+
if (middleware) {
116+
const middlewareResponse = await middleware(response, req, res);
117+
response.statusCode = middlewareResponse.statusCode ?? response.statusCode;
118+
response.headers = {
119+
...response.headers,
120+
...(middlewareResponse.headers ?? {}),
121+
};
122+
response.body = middlewareResponse.body ?? response.body;
123+
}
124+
125+
res.writeHead(response.statusCode, response.headers);
126+
res.end(response.body);
127+
});
128+
129+
server.listen(port);
130+
return server;
131+
};

0 commit comments

Comments
 (0)