UNPKG

10.2 kBJavaScriptView Raw
1// Copyright 2019 Zaiste & contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6// http://www.apache.org/licenses/LICENSE-2.0
7//
8// Unless required by applicable law or agreed to in writing, software
9// distributed under the License is distributed on an "AS IS" BASIS,
10// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11// See the License for the specific language governing permissions and
12// limitations under the License.
13
14const debug = require('debug')('huncwot:index'); // eslint-disable-line no-unused-vars
15
16const http = require('http');
17const Stream = require('stream');
18const querystring = require('querystring');
19const { join } = require('path');
20const { parse } = require('url');
21const Busboy = require('busboy');
22const Router = require('trek-router');
23const httpstatus = require('http-status');
24
25const { serve, security } = require('./middleware');
26const { build, translate } = require('./controller');
27const { NotFound } = require('./response');
28const Logger = require('./logger');
29const HTMLifiedError = require('./error');
30
31const cwd = process.cwd();
32const handlerDir = join(cwd, '.build');
33
34const isObject = _ => !!_ && _.constructor === Object;
35const compose = (...functions) => args =>
36 functions.reduceRight((arg, fn) => fn(arg), args);
37
38class Middleware extends Array {
39 async next(context, last, current, done, called, func) {
40 if ((done = current > this.length)) return;
41
42 func = this[current] || last;
43
44 return (
45 func &&
46 func(context, async () => {
47 if (called) throw new Error('next() already called');
48 called = true;
49 return await this.next(context, last, current + 1);
50 })
51 );
52 }
53
54 async compose(context, last) {
55 return await this.next(context, last, 0);
56 }
57}
58
59class Huncwot {
60 constructor({
61 staticDir = join(cwd, 'static'),
62 securityOptions = {
63 dnsPrefetchControl: false,
64 poweredBy: false
65 },
66 graphql = false,
67 implicitControllers = true,
68 _verbose = false
69 } = {}) {
70 this.middlewareList = new Middleware();
71 this.router = new Router();
72
73 if (graphql) {
74 try {
75 const { typeDefs, resolvers } = require(join(cwd, 'graphql'));
76 const { graphql, graphiql, makeSchema } = require('./graphql');
77
78 const schema = makeSchema({ typeDefs, resolvers });
79
80 this.post('/graphql', graphql({ schema }));
81 this.get('/graphql', graphql({ schema }));
82 this.get('/graphiql', graphiql({ endpointURL: 'graphql' }));
83 } catch (error) {
84 switch (error.code) {
85 case 'MODULE_NOT_FOUND':
86 console.log('GraphQL is not set up.');
87 break;
88 default:
89 console.error(error);
90 break;
91 }
92 }
93 }
94
95 if (implicitControllers) {
96 const handlers = build();
97 for (let { resource, operation, path } of handlers) {
98 try {
99 const handler = require(join(handlerDir, path));
100 let { method, route } = translate(operation, resource);
101
102 console.log('-->', method, route);
103
104 if (Array.isArray(handler)) {
105 this.add(method, route, ...handler);
106 } else {
107 this.add(method, route, handler);
108 }
109 } catch (error) {
110 console.error(error);
111 }
112 }
113 }
114
115 // this.add('GET', '/', [ serve(staticDir) ]);
116 // this.add('GET', '/', [ security(securityOptions) ]);
117 this.add('GET', '/', async _request => 'Hello, Huncwot'); // TODO remove that, properly handle non-existent HTML in public/
118 }
119
120 async setup() {
121 // TODO
122 }
123
124 use(middleware) {
125 if (typeof middleware !== 'function')
126 throw new TypeError('middleware must be a function!');
127 this.middlewareList.push(middleware);
128
129 return this;
130 }
131
132 add(method, path, ...fns) {
133 const action = fns.pop();
134
135 // pipeline is a handler composed over middlewares,
136 // `action` function must be explicitly extracted from the pipeline
137 // as it has different signature, thus cannot be composed
138 const pipeline = fns.length === 0 ? action : compose(...fns)(action);
139
140 this.router.add(method.toUpperCase(), path, pipeline);
141
142 return this;
143 }
144
145 start({ routes = {}, port = 5544, fn = () => {} }) {
146 for (let [method, route] of Object.entries(routes)) {
147 for (let [path, handler] of Object.entries(route)) {
148 if (Array.isArray(handler)) {
149 this.add(method, path, ...handler);
150 } else {
151 this.add(method, path, handler);
152 }
153 }
154 }
155
156 const RouterMiddleware = async (context, next) => {
157 const method = context.request.method;
158 const { pathname, query } = parse(context.request.url, true); // TODO Test perf vs RegEx
159
160 const [handler, dynamicRoutes] = this.router.find(method, pathname);
161
162 const params = {};
163 for (let r of dynamicRoutes) {
164 params[r.name] = r.value;
165 }
166
167 if (handler !== undefined) {
168 await handleRequest(context);
169 context.params = { ...context.params, ...query, ...params };
170 return await handler(context);
171 } else {
172 return await next();
173 }
174 };
175
176 this.middlewareList.push(RouterMiddleware);
177
178 // append 404 middleware handler: it must be put at the end and only once
179 // TODO Move to `catch` for pattern matching ?
180 this.middlewareList.push(() => NotFound());
181
182 const server = http.createServer((request, response) => {
183 const context = { params: {}, headers: {}, request, response };
184
185 this.middlewareList
186 .compose(context)
187 .then(handle(context))
188 .then(() => Logger.printRequestResponse(context))
189 .catch(error => {
190 response.statusCode = 500;
191 error.status = `500 ${httpstatus[500]}`;
192
193 // TODO remove at runtime in `production`, keep only in `development`
194 Logger.printRequestResponse(context);
195 Logger.printError(error, 'HTTP');
196
197 const htmlifiedError = new HTMLifiedError(error, request);
198
199 htmlifiedError.generate().then(html => {
200 response.writeHead(500, { 'Content-Type': 'text/html' }).end(html);
201 });
202 });
203 }).on('error', error => {
204 Logger.printError(error);
205 process.exit(1);
206 });
207
208 return server.listen(port, fn);
209 }
210}
211
212const handle = context => result => {
213 if (null === result || undefined === result)
214 throw new Error('No return statement in the handler');
215
216 let { response } = context;
217
218 let body, headers, type, encoding;
219
220 if (typeof result === 'string' || result instanceof Stream) {
221 body = result;
222 } else {
223 body = result.body;
224 headers = result.headers;
225 type = result.type;
226 encoding = result.encoding;
227 }
228
229 response.statusCode = result.statusCode || 200;
230
231 for (var key in headers) {
232 response.setHeader(key, headers[key]);
233 }
234
235 if (encoding) response.setHeader('Content-Encoding', encoding);
236
237 if (Buffer.isBuffer(body)) {
238 response.setHeader('Content-Type', type || 'application/octet-stream');
239 response.setHeader('Content-Length', body.length);
240 response.end(body);
241 return;
242 }
243
244 if (body instanceof Stream) {
245 if (!response.getHeader('Content-Type'))
246 response.setHeader('Content-Type', type || 'text/html');
247
248 body.pipe(response);
249 return;
250 }
251
252 let str = body;
253
254 if (typeof body === 'object' || typeof body === 'number') {
255 str = JSON.stringify(body);
256 response.setHeader('Content-Type', 'application/json');
257 } else {
258 if (!response.getHeader('Content-Type'))
259 response.setHeader('Content-Type', type || 'text/plain');
260 }
261
262 response.setHeader('Content-Length', Buffer.byteLength(str));
263 response.end(str);
264};
265
266const handleRequest = async context => {
267 const { headers } = context.request;
268
269 context.headers = headers;
270 context.cookies = parseCookies(headers.cookie);
271
272 const buffer = await streamToString(context.request);
273 if (buffer.length > 0) {
274 const contentType = headers['content-type'].split(';')[0];
275
276 switch (contentType) {
277 case 'application/x-www-form-urlencoded':
278 Object.assign(context.params, querystring.parse(buffer));
279 break;
280 case 'application/json': {
281 const result = JSON.parse(buffer);
282 if (isObject(result)) {
283 Object.assign(context.params, result);
284 }
285 break;
286 }
287 case 'multipart/form-data': {
288 context.files = {};
289
290 const busboy = new Busboy({ headers });
291
292 busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
293 file.on('data', data => {
294 context.files = {
295 ...context.files,
296 [fieldname]: {
297 name: filename,
298 length: data.length,
299 data,
300 encoding,
301 mimetype
302 }
303 };
304 });
305 file.on('end', () => {});
306 });
307 busboy.on('field', (fieldname, val) => {
308 context.params = { ...context.params, [fieldname]: val };
309 });
310 busboy.end(buffer);
311
312 await new Promise(resolve => busboy.on('finish', resolve));
313
314 break;
315 }
316 default:
317 }
318 }
319};
320
321const streamToString = async stream => {
322 let chunks = '';
323
324 return new Promise((resolve, reject) => {
325 stream.on('data', chunk => (chunks += chunk));
326 stream.on('error', reject);
327 stream.on('end', () => resolve(chunks));
328 });
329};
330
331const parseCookies = (cookieHeader = '') => {
332 const cookies = cookieHeader.split(/; */);
333 const decode = decodeURIComponent;
334
335 if (cookies[0] === '') return {};
336
337 const result = {};
338 for (let cookie of cookies) {
339 const isKeyValue = cookie.includes('=');
340
341 if (!isKeyValue) {
342 result[cookie.trim()] = true;
343 continue;
344 }
345
346 let [key, value] = cookie.split('=');
347
348 key.trim();
349 value.trim();
350
351 if ('"' === value[0]) value = value.slice(1, -1);
352
353 try {
354 value = decode(value);
355 } catch (error) {
356 // neglect
357 }
358
359 result[key] = value;
360 }
361
362 return result;
363};
364
365module.exports = Huncwot;