huncwot
Version:
A Programming Environment for TypeScript apps built on top of VS Code
461 lines (375 loc) • 13 kB
JavaScript
// Copyright 2020 Zaiste & contributors. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const debug = require('debug')('huncwot:index'); // eslint-disable-line no-unused-vars
const http = require('http');
const Stream = require('stream');
const querystring = require('querystring');
const { join } = require('path');
const { parse } = require('url');
const Busboy = require('busboy');
const Router = require('trek-router');
const httpstatus = require('http-status');
const { cors, security, serve } = require('./middleware');
const { build, translate } = require('./controller');
const { NotFound } = require('./response');
const Logger = require('./logger');
const HTMLifiedError = require('./error');
const cwd = process.cwd();
const handlerDir = join(cwd, 'dist');
const isObject = _ => !!_ && _.constructor === Object;
const compose = (...functions) => args => functions.reduceRight((arg, fn) => fn(arg), args);
const lookupHandler = ({ feature, action }) => {
const path = join(cwd, 'dist', 'features', feature, 'Controller', `${action}.js`);
try {
return require(path);
} catch (error) {
console.error(`'features/${feature}/Controller/${action}.js' could not be loaded.`);
// return a handler that just informs about the missing handler
return _ => `You need to create 'features/${feature}/Controller/${action}.js'`;
}
};
class Middleware extends Array {
async next(context, last, current, done, called, func) {
if ((done = current > this.length)) return;
func = this[current] || last;
return (
func &&
func(context, async () => {
if (called) throw new Error('next() already called');
called = true;
return this.next(context, last, current + 1);
})
);
}
async compose(context, last) {
return this.next(context, last, 0);
}
}
class Huncwot {
constructor({
staticDir = join(cwd, 'public'),
graphql = false,
implicitControllers = true,
WebRPC = true,
_verbose = false
} = {}) {
this.server = null;
this.middlewares = new Middleware();
this.router = new Router();
this.staticDir = staticDir;
if (graphql) {
try {
const { typeDefs, resolvers } = require(join(cwd, 'graphql'));
const { graphql, graphiql, makeSchema } = require('./graphql');
const schema = makeSchema({ typeDefs, resolvers });
this.post('/graphql', graphql({ schema }));
this.get('/graphql', graphql({ schema }));
this.get('/graphiql', graphiql({ endpointURL: 'graphql' }));
} catch (error) {
switch (error.code) {
case 'MODULE_NOT_FOUND':
console.log('GraphQL is not set up.');
break;
default:
console.error(error);
break;
}
}
}
if (implicitControllers) {
const handlers = build();
for (let { resource, operation, dir } of handlers) {
try {
const handler = require(`${join(handlerDir, dir, operation)}.js`);
let { method, route } = translate(operation, resource.toLowerCase());
route = route.replace('_', ':');
if (Array.isArray(handler)) {
this.add(method, route, ...handler);
} else {
this.add(method, route, handler);
}
if (WebRPC)
this.add(
'POST',
`/rpc/${resource}/${operation}`,
...(Array.isArray(handler) ? handler : [handler])
);
} catch (error) {
console.error(error);
}
}
}
}
async setup() {
// TODO
}
use(middleware) {
if (typeof middleware !== 'function') throw new TypeError('middleware must be a function!');
this.middlewares.push(middleware);
return this;
}
add(method, path, ...fns) {
const action = fns.pop();
// pipeline is a handler composed over middlewares,
// `action` function must be explicitly extracted from the pipeline
// as it has different signature, thus cannot be composed
const pipeline = fns.length === 0 ? action : compose(...fns)(action);
this.router.add(method.toUpperCase(), path, pipeline);
return this;
}
buildResourceDependencies(resources, parent = null) {
for (let { feature, alias, children } of resources) {
const path = `${(alias || feature).toLowerCase()}`;
const scopedPath = parent ? `${parent}/:id/${path}` : path;
try {
// add member routes
this.add('GET', `/${path}/:id`, lookupHandler({ feature, action: 'fetch' }));
this.add('PUT', `/${path}/:id`, lookupHandler({ feature, action: 'update' }));
this.add('DELETE', `/${path}/:id`, lookupHandler({ feature, action: 'destroy' }));
// add collection routes (potentially scoped)
this.add('GET', `/${scopedPath}`, lookupHandler({ feature, action: 'browse' }));
this.add('POST', `/${scopedPath}`, lookupHandler({ feature, action: 'create' }));
if (children) {
// recursively go in with `parent` set
this.buildResourceDependencies(children, (alias || feature).toLowerCase());
}
} catch (error) {
console.error(`There is no feature ${feature} -> ${error.message}`);
}
// recursion goes up here
}
}
async start({ routes = {}, port = 0 } = {}) {
for (let [method, route] of Object.entries(routes)) {
if (method !== 'Resources') {
for (let [path, handler] of Object.entries(route)) {
if (Array.isArray(handler)) {
this.add(method, path, ...handler);
} else {
this.add(method, path, handler);
}
}
} else {
const resources = route;
this.buildResourceDependencies(resources);
}
}
const RouterMiddleware = async (context, next) => {
const method = context.request.method;
const { pathname, query } = parse(context.request.url, true); // TODO Test perf vs RegEx
const [handler, dynamicRoutes] = this.router.find(method, pathname);
const params = {};
for (let r of dynamicRoutes) {
params[r.name] = r.value;
}
if (handler !== undefined) {
context.params = { ...query, ...params };
await handleRequest(context);
context.params = { ...context.params };
return handler(context);
} else {
return next();
}
};
this.use(security())
this.use(cors());
this.use(RouterMiddleware);
this.use(serve(this.staticDir));
// append 404 middleware handler: it must be put at the end and only once
// TODO Move to `catch` for pattern matching ?
this.use(() => NotFound());
this.server = http
.createServer((request, response) => {
const context = { params: {}, headers: {}, request, response };
this.middlewares
.compose(context)
.then(handle(context))
.then(() => Logger.printRequestResponse(context))
.catch(error => {
response.statusCode = 500;
error.status = `500 ${httpstatus[500]}`;
// TODO remove at runtime in `production`, keep only in `development`
Logger.printRequestResponse(context);
Logger.printError(error, 'HTTP');
const htmlifiedError = new HTMLifiedError(error, request);
htmlifiedError.generate().then(html => {
response.writeHead(500, { 'Content-Type': 'text/html' }).end(html);
});
});
})
.on('error', error => {
Logger.printError(error);
process.exit(1);
});
return new Promise((resolve, reject) => {
this.server.listen(port, (err) => {
if (err) return reject(err);
resolve(this.server);
});
})
}
async stop() {
return new Promise((resolve, reject) => {
this.server.close((err) => {
if (err) return reject(err);
resolve();
})
})
}
get port () {
return this.server && this.server.address().port
}
}
const handle = context => result => {
if (null === result || undefined === result)
throw new Error('No return statement in the handler');
let { response } = context;
let body, headers, type, encoding;
if (typeof result === 'string' || result instanceof Stream) {
body = result;
} else {
body = result.body;
headers = result.headers;
type = result.type;
encoding = result.encoding;
}
Object.assign(
{
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
},
headers
);
response.statusCode = result.statusCode || 200;
for (var key in headers) {
response.setHeader(key, headers[key]);
}
if (encoding) response.setHeader('Content-Encoding', encoding);
if (Buffer.isBuffer(body)) {
response.setHeader('Content-Type', type || 'application/octet-stream');
response.setHeader('Content-Length', body.length);
response.end(body);
return;
}
if (body instanceof Stream) {
if (!response.getHeader('Content-Type'))
response.setHeader('Content-Type', type || 'text/html');
body.pipe(response);
return;
}
let str = body;
if (typeof body === 'object' || typeof body === 'number') {
str = JSON.stringify(body);
response.setHeader('Content-Type', 'application/json');
} else {
if (!response.getHeader('Content-Type'))
response.setHeader('Content-Type', type || 'text/plain');
}
response.setHeader('Content-Length', Buffer.byteLength(str));
response.end(str);
};
const handleRequest = async context => {
const { headers } = context.request;
const { format } = context.params;
context.headers = headers;
context.cookies = parseCookies(headers.cookie);
context.format = format ? format : parseAcceptHeader(headers);
const buffer = await toBuffer(context.request);
if (buffer.length > 0) {
const contentType = headers['content-type'].split(';')[0];
switch (contentType) {
case 'application/x-www-form-urlencoded':
Object.assign(context.params, querystring.parse(buffer.toString()));
break;
case 'application/json': {
const result = JSON.parse(buffer);
if (isObject(result)) {
Object.assign(context.params, result);
}
break;
}
case 'multipart/form-data': {
context.files = {};
const busboy = new Busboy({ headers });
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
file.on('data', data => {
context.files = {
...context.files,
[fieldname]: {
name: filename,
length: data.length,
data,
encoding,
mimetype
}
};
});
file.on('end', () => {});
});
busboy.on('field', (fieldname, val) => {
context.params = { ...context.params, [fieldname]: val };
});
busboy.end(buffer);
await new Promise(resolve => busboy.on('finish', resolve));
break;
}
default:
}
}
};
const toBuffer = async stream => {
const chunks = [];
for await (let chunk of stream) {
chunks.push(chunk);
}
return Buffer.concat(chunks);
};
const streamToString = async stream => {
let chunks = '';
return new Promise((resolve, reject) => {
stream.on('data', chunk => (chunks += chunk));
stream.on('error', reject);
stream.on('end', () => resolve(chunks));
});
};
const parseCookies = (cookieHeader = '') => {
const cookies = cookieHeader.split(/; */);
const decode = decodeURIComponent;
if (cookies[0] === '') return {};
const result = {};
for (let cookie of cookies) {
const isKeyValue = cookie.includes('=');
if (!isKeyValue) {
result[cookie.trim()] = true;
continue;
}
let [key, value] = cookie.split('=');
key.trim();
value.trim();
if ('"' === value[0]) value = value.slice(1, -1);
try {
value = decode(value);
} catch (error) {
// neglect
}
result[key] = value;
}
return result;
};
const parseAcceptHeader = ({ accept = '*/*' }) => {
const preferredType = accept.split(',').shift();
const format = preferredType.split('/').pop();
return format;
};
module.exports = Huncwot;