Skip to content

v0.1.0 release #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 18 commits into
base: master
Choose a base branch
from
Draft
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
249 changes: 185 additions & 64 deletions _app.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import createRequest, { ServaRequest } from "./_request.ts";
import { RouteFactory, HooksFactory } from "./factories.ts";
import createRequest, { ServaRequest } from "./request.ts";
import createRoute, { Route } from "./_route.ts";
import { fs, http, path, flags } from "./deps.ts";

export interface OnRequestCallback {
(request: ServaRequest, next: () => Promise<any>): Promise<any> | any;
}

export interface RouteCallback {
(request: ServaRequest): Promise<any> | any;
}
Expand All @@ -21,7 +26,7 @@ const DEFAULT_CONFIG = Object.freeze({
methods: "get|post|put|delete|patch".split("|"),
});

type RouteEntry = [Route, RouteCallback];
type RouteEntry = [Route, OnRequestCallback[]];
type RoutesStruct = Map<string, RouteEntry[]>;

export default class App {
Expand Down Expand Up @@ -159,7 +164,7 @@ export default class App {
const configFilePath = this.path("serva.config.json");
const lstats = await Deno.lstat(configFilePath);
if (!lstats.isFile) {
throw new Error();
throw new Error("serva.config.json is not a file");
}

// @ts-expect-error
Expand Down Expand Up @@ -210,16 +215,26 @@ export default class App {
}

const routes: [string, RouteEntry][] = [];
const routeHooks: [string, RouteEntry][] = [];

for await (const entry of fs.walk(routesPath)) {
// ignore directories and any non-typescript files
for await (const entry of fs.walk(routesPath, { includeDirs: false })) {
// ignore any non-typescript files
// todo: support for javascript
if (entry.isDirectory || !entry.name.endsWith(this.config.extension)) {
if (!entry.name.endsWith(this.config.extension)) {
continue;
}

const relativeEntryPath = path.relative(routesPath, entry.path);
const method = routeMethod(relativeEntryPath, this.config);
let [filename, method] = nameAndMethodFromPath(
relativeEntryPath,
this.config,
);

// throw if dev is trying to: _hooks.get.ts
if (filename === "_hooks" && method !== "*") {
throw new Error("Hook files should not contain a methods");
}

const urlPath = routePath(relativeEntryPath, this.config); // route information
const route = createRoute(
method,
Expand All @@ -228,7 +243,7 @@ export default class App {
);

// register route
let callback: RouteCallback;
let callback: unknown;
try {
let filePath = `file://${entry.path}`;
if (remount) {
Expand All @@ -238,24 +253,67 @@ export default class App {
({ default: callback } = await import(filePath));
if (typeof callback !== "function") {
throw new TypeError(
`${route.filePath} default export is not a callback.`,
`${route.filePath} default export is not a callback`,
);
}
} catch (err) {
console.log("Error in file:", entry.path, err);
continue;
}

routes.push(
[
route.method,
[route, callback!],
],
);
// check if the export was a factory
// @ts-ignore https://github.com/Microsoft/TypeScript/issues/1863
const factory = callback[Symbol.for("serva_factory")];
let hooks: OnRequestCallback[] = [];
if (factory) {
switch (factory) {
case "hooks":
if (filename !== "_hooks") {
throw new Error("Invalid hooks filename");
}
hooks = (callback as HooksFactory)(route);
routeHooks.push([route.method, [route, hooks]]);
continue;

case "route":
[hooks, callback] = (callback as RouteFactory)(route);
hooks.push(routeToRequestCallback(callback as RouteCallback));
break;

default:
throw new Error(`Factory (${factory}) not implemented`);
}
} else {
hooks = [routeToRequestCallback(callback as RouteCallback)];
}

routes.push([route.method, [route, hooks]]);
}

// sort and set routes
routeHooks.sort(sortRoutes);
routes.sort(sortRoutes).forEach(([method, entry]) => {
// merge routeHooks into route
const [route, hooks] = entry;
const fakeUrl = route.toPath(
route.paramNames.length
? route.paramNames.reduce((previous, name) => ({
...previous,
[name]: "foo",
}), {})
: undefined,
);

const hooksToMerge = routeHooks
.filter(([, [hookRoute]]) => hookRoute.regexp.test(fakeUrl))
.map(([, entry]) => entry[1])
.reduce((previous, hooks_) => [
...previous,
...hooks_,
], []);

hooks.unshift(...hooksToMerge);

const entries = this.routes.get(method) || [];
entries.push(entry);

Expand All @@ -275,21 +333,22 @@ export default class App {
private async handleRequest(req: http.ServerRequest): Promise<void> {
// find a matching route
let route: Route;
let callback: RouteCallback;
let callbacks: OnRequestCallback[] = [];
const { pathname } = new URL(req.url, "https://serva.land");

const possibleMethods = [req.method, "*"];
if (req.method === "HEAD") {
possibleMethods.splice(0, 1, "GET");
// HEAD, GET, *
possibleMethods.splice(1, 0, "GET");
}

possibleMethods.some((m) => {
const entries = this.routes.get(m);
if (entries) {
return entries.some(([r, cb]) => {
return entries.some(([r, cbs]) => {
if (r.regexp.test(pathname)) {
route = r;
callback = cb;
callbacks = cbs;
// route found
return true;
}
Expand All @@ -300,89 +359,88 @@ export default class App {
// not found
// @ts-ignore
if (!route) {
req.respond({
status: 404,
});
return;
return req.respond({ status: 404 });
}

const request = createRequest(req, route);
const body = await callback!(request);

// allow return values to set the body
if (body !== undefined) {
request.response.body = body;
}
await dispatch(callbacks, request);

// if nobody has responded, send the current request's response
if (request.httpRequest.w.usedBufferBytes === 0) {
const { response } = request;

// detect json response
if (!validHttpResponseBody(response.body)) {
response.body = JSON.stringify(response.body);
response.headers.set("content-type", "application/json; charset=utf-8");
}

req.respond(request.response);
}
}
}

/**
* Gets the route path from a file path.
* Returns the filename and extracted method from a file path.
*
* @example
* routePath("index.ts");
* // => "/"
*
* routePath("comments/[comment].get.ts");
* // => "/comments/[comment]"
* nameAndMethodFromPath("index.ts")
* // => ["index", "*"]
*
* nameAndMethodFromPath("comments/[comment].get.ts");
* // => ["[comment]", "GET"]
*
* @param {string} filePath
* @param {string} filePath
* @param {ServaConfig} config
* @returns {string}
* @returns [string, string]
*/
function routePath(filePath: string, config: ServaConfig): string {
function nameAndMethodFromPath(
filePath: string,
config: ServaConfig,
): [string, string] {
let name = path.basename(filePath, config.extension);
let method = "*";

const matched = name.match(
new RegExp(`.*(?=\.(${config.methods.join("|")})$)`, "i"),
);

if (matched) {
[name] = matched;
}

if (name === "index") {
name = "";
}

let base = path.dirname(filePath);
if (base === ".") {
base = "";
[name, method] = matched;
}

return "/" + (base ? path.join(base, name) : name);
return [name, method.toUpperCase()];
}

/**
* Returns the route method from a give route path.
* Gets the route path from a file path.
*
* @example
* routeMethod("index.ts");
* // => "*"
* routePath("index.ts");
* // => "/"
*
* routePath("comments/[comment].get.ts");
* // => "/comments/[comment]"
*
* routeMethod("/comments/[comment].get.ts")
* // => "GET"
*
* @param {string} filePath
* @param {ServaConfig} config
* @returns {string}
*/
function routeMethod(filePath: string, config: ServaConfig): string {
const name = path.basename(filePath, config.extension);
const matched = name.match(
new RegExp(`.*(?=\.(${config.methods.join("|")})$)`, "i"),
);
function routePath(filePath: string, config: ServaConfig): string {
let [name] = nameAndMethodFromPath(filePath, config);

let method = "*";
if (matched) {
[, method] = matched;
if (name === "index") {
name = "";
} else if (name === "_hooks") {
name = "*"; // single glob match for hook files
}

return method.toUpperCase();
let base = path.dirname(filePath);
if (base === ".") {
base = "";
}

return "/" + (base ? path.join(base, name) : name);
}

/**
Expand Down Expand Up @@ -425,3 +483,66 @@ function sortRoutes(a: [string, RouteEntry], b: [string, RouteEntry]): number {

return 0;
}

/**
* The hooks dispatcher.
*
* @param {OnRequestCallback[]} callbacks
* @param {ServaRequest} request
* @returns {Promise<any>}
*/
function dispatch(
callbacks: OnRequestCallback[],
request: ServaRequest,
): Promise<any> {
let i = -1;
const next = (current = 0): Promise<any> => {
if (current <= i) {
throw new Error("next() already called");
}

const cb = callbacks[i = current];

return Promise.resolve(
cb ? cb(request, next.bind(undefined, i + 1)) : undefined,
);
};
return next();
}

/**
* Transforms a route callback to a request hook callback.
*
* @param {RouteCallback} callback
* @returns {OnRequestCallback}
*/
function routeToRequestCallback(callback: RouteCallback): OnRequestCallback {
return async function (request, next) {
const body = await callback(request);
if (body !== undefined) {
request.response.body = body;
}
return next();
};
}

/**
* Chek if a given body is a valid Http response type.
*
* @param {any} body
* @returns {boolean}
*/
function validHttpResponseBody(body: any): boolean {
switch (typeof body) {
case "undefined":
case "string":
return true;

case "object":
return body &&
(body instanceof Uint8Array || typeof body.read === "function");

default:
return false;
}
}
Loading