Skip to content

Commit

Permalink
Dawn of v1 (#87)
Browse files Browse the repository at this point in the history
  • Loading branch information
keroxp authored Mar 17, 2020
1 parent 4c486a1 commit 24be3ac
Show file tree
Hide file tree
Showing 39 changed files with 843 additions and 495 deletions.
10 changes: 5 additions & 5 deletions agent_test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
import { encode } from "./vendor/https/deno.land/std/strings/encode.ts";
import { createAgent } from "./agent.ts";
import { createRouter } from "./router.ts";
import { createApp } from "./app.ts";
import {
assertEquals,
assertThrows
Expand All @@ -19,21 +19,21 @@ async function readString(r: Reader) {
}

function setupRouter(port: number): ServeListener {
const router = createRouter();
router.handle("/get", async req => {
const app = createApp();
app.route("/get", async req => {
return req.respond({
status: 200,
body: encode("ok")
});
});
router.handle("/post", async req => {
app.route("/post", async req => {
return req.respond({
status: 200,
headers: req.headers,
body: req.body
});
});
return router.listen({
return app.listen({
hostname: "127.0.0.1",
port
});
Expand Down
99 changes: 99 additions & 0 deletions app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
import {
listenAndServe,
listenAndServeTLS,
ServeListener,
ServeOptions,
ServerRequest
} from "./server.ts";
import { createLogger, Logger, Loglevel, namedLogger } from "./logger.ts";
import ListenOptions = Deno.ListenOptions;
import ListenTLSOptions = Deno.ListenTLSOptions;
import {
createRouter,
Router
} from "./router.ts";
import { RoutingError } from "./error.ts";
import { kHttpStatusMessages } from "./serveio.ts";

export interface App extends Router {
/** Start listening with given addr */
listen(addr: string | ListenOptions, opts?: ServeOptions): ServeListener;

/** Start listening for HTTPS server */
listenTLS(tlsOptions: ListenTLSOptions, opts?: ServeOptions): ServeListener;
}

export type AppOptions = {
logger?: Logger;
logLevel?: Loglevel;
};

/** Create App */
export function createApp(
opts: AppOptions = {
logger: createLogger()
}
): App {
const { info, error } = namedLogger("servest:router", opts.logger);
const router = createRouter();
const finalErrorHandler = async (e: any, req: ServerRequest) => {
if (e instanceof RoutingError) {
await req.respond({
status: e.status,
body: e.message
});
} else {
if (e instanceof Error) {
await req.respond({
status: 500,
body: e.stack
});
if (e.stack) {
error(e.stack);
}
} else {
await req.respond({
status: 500,
body: kHttpStatusMessages[500]
});
error(e);
}
}
};
const handleRoute = async (p: string, req: ServerRequest) => {
try {
await router.handleRoute(p, req);
} catch (e) {
if (!req.isResponded()) {
await finalErrorHandler(e, req);
}
} finally {
if (!req.isResponded()) {
await finalErrorHandler(new RoutingError(404), req);
}
info(`${req.respondedStatus()} ${req.method} ${req.url}`);
}
};
function listen(
addr: string | ListenOptions,
opts?: ServeOptions
): ServeListener {
const listener = listenAndServe(addr, req => handleRoute("", req), opts);
info(`listening on ${addr}`);
return listener;
}
function listenTLS(
listenOptions: ListenTLSOptions,
opts?: ServeOptions
): ServeListener {
const listener = listenAndServeTLS(
listenOptions,
req => handleRoute("", req),
opts
);
info(`listening on ${listenOptions.hostname || ""}:${listenOptions.port}`);
return listener;
}
return { ...router, handleRoute, listen, listenTLS };
}
52 changes: 52 additions & 0 deletions app_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
import { createApp, App } from "./app.ts";
import {
assertEquals,
assertMatch
} from "./vendor/https/deno.land/std/testing/asserts.ts";
import { it, makeGet, assertRoutingError } from "./test_util.ts";
import { Loglevel, setLevel } from "./logger.ts";
import { connectWebSocket } from "./vendor/https/deno.land/std/ws/mod.ts";
setLevel(Loglevel.NONE);

it("app/ws", t => {
const app = createApp();
app.handle("/no-response", () => {});
app.handle("/throw", () => {
throw new Error("throw");
});
const get = makeGet(app);
app.ws("/ws", async sock => {
await sock.send("Hello");
await sock.close(1000);
});
t.beforeAfterAll(() => {
const l = app.listen({ port: 8899 });
return () => l.close();
});
t.run("should respond if req.respond wasn't called", async () => {
const res = await get("/no-response");
assertEquals(res.status, 404);
});
t.run("should respond for unknown path", async () => {
const res = await get("/not-found");
assertEquals(res.status, 404);
});
t.run("should handle global error", async () => {
const res = await get("/throw");
const text = await res.body.text();
assertEquals(res.status, 500);
assertMatch(text, /Error: throw/);
});
t.run("should accept ws", async () => {
const sock = await connectWebSocket("ws://127.0.0.1:8899/ws");
const it = sock.receive();
const { value: msg1 } = await it.next();
assertEquals(msg1, "Hello");
const { value: msg2 } = await it.next();
assertEquals(msg2, { code: 1000, reason: "" });
const { done } = await it.next();
assertEquals(done, true);
assertEquals(sock.isClosed, true);
});
});
4 changes: 2 additions & 2 deletions cookie_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
import { it } from "./test_util.ts";
import { cookieToString, parseCookie, parseSetCookie } from "./cookie.ts";
import { toIMF } from "./vendor/https/deno.land/std/datetime/mod.ts";
import { createRouter } from "./router.ts";
import { createApp } from "./app.ts";

it("parseCookie", t => {
t.run("basic", () => {
Expand Down Expand Up @@ -84,7 +84,7 @@ it("cookie integration", t => {
const now = new Date();
now.setMilliseconds(0);
t.beforeAfterAll(() => {
const router = createRouter();
const router = createApp();
router.get("/", req => {
req.setCookie("deno", "land", {
path: "/",
Expand Down
6 changes: 4 additions & 2 deletions error.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { kHttpStatusMessages } from "./serveio.ts";

// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
export class RoutingError extends Error {
constructor(readonly status: number, readonly msg: string) {
super(msg);
constructor(readonly status: number, msg?: string) {
super(msg ?? kHttpStatusMessages[status]);
}
}
42 changes: 24 additions & 18 deletions router_util.ts → matcher.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,57 @@
// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
import * as path from "./vendor/https/deno.land/std/path/mod.ts";

/**
* Find the match that appeared in the nearest position to the beginning of word.
* If positions are same, the longest one will be picked.
* Return -1 and null if no match found.
* */
export function findLongestAndNearestMatch(
export function findLongestAndNearestMatches(
pathname: string,
patterns: (string | RegExp)[]
): { index: number; match: RegExpMatchArray | null } {
): [number, RegExpMatchArray][] {
let lastMatchIndex = pathname.length;
let lastMatchLength = 0;
let match: RegExpMatchArray | null = null;
let index = -1;
let ret: [number, RegExpMatchArray][] = [];
for (let i = 0; i < patterns.length; i++) {
const pattern = patterns[i];
if (pattern instanceof RegExp) {
// Regex pattern always match pathname in ignore case mode
const m = pathname.match(new RegExp(pattern, "i"));
if (!m || m.index === undefined) {
// Regex pattern always matches pathname in ignore case mode
const match = pathname.match(new RegExp(pattern, "i"));
if (!match || match.index == null) {
continue;
}
const { index } = match;
const [tgt] = match;
if (
m.index < lastMatchIndex ||
(m.index === lastMatchIndex && m[0].length > lastMatchLength)
index <= lastMatchIndex ||
(index === lastMatchIndex && tgt.length >= lastMatchLength)
) {
index = i;
match = m;
lastMatchIndex = m.index;
lastMatchLength = m[0].length;
if (tgt.length > lastMatchLength || index < lastMatchIndex) {
ret = [];
}
ret.push([i, match]);
lastMatchIndex = index;
lastMatchLength = tgt.length;
}
} else if (
// req.url is raw requested url string that
// may contain capitalized strings.
// However router compares them by normalized strings
// "/path" matches both "/path" and "/Path".
pathname.toLowerCase() === pattern.toLowerCase() &&
pattern.length > lastMatchLength
pattern.length >= lastMatchLength
) {
index = i;
match = [pattern];
if (pattern.length > lastMatchLength) {
ret = [];
}
const reg = new RegExp(pathname, "i");
const match = pathname.match(reg)!;
ret.push([i, match]);
lastMatchIndex = 0;
lastMatchLength = pattern.length;
}
}
return { index, match };
return ret;
}

export async function resolveIndexPath(
Expand Down
44 changes: 44 additions & 0 deletions matcher_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
import {
findLongestAndNearestMatches,
resolveIndexPath
} from "./matcher.ts";
import { assertEquals } from "./vendor/https/deno.land/std/testing/asserts.ts";
import { it } from "./test_util.ts";

it("matcher", t => {
type Pat = [string, (string | RegExp)[], number[]][];
([
["/foo", ["/foo", "/bar", "/f"], [0]],
["/foo", ["/foo", "/foo/bar"], [0]],
["/foo/bar", ["/", "/foo", "/hoo", "/hoo/foo/bar", "/foo/bar"], [4]],
["/foo/bar/foo", ["/foo", "/foo/bar", "/bar/foo", "/foo/bar/foo"], [3]],
["/foo", ["/", "/hoo", "/hoo/foo"], []],
["/deno/land", [/d(.+?)o/, /d(.+?)d/], [1]],
["/foo", ["/", "/a/foo", "/foo"], [2]],
["/foo", [/\/foo/, /\/bar\/foo/], [0]],
["/foo", [/\/a\/foo/, /\/foo/], [1]]
] as Pat).forEach(([path, pat, idx]) => {
t.run("findLongestAndNearestMatch:" + path, () => {
const matches = findLongestAndNearestMatches(path, pat);
assertEquals(matches.length, idx.length);
for (let i = 0; i < idx.length; i++) {
assertEquals(matches[i][0], idx[i]);
}
});
});

t.run("resolveIndexPath", async () => {
for (
const [dir, fp, exp] of [
[".", "/README.md", "README.md"],
["./fixtures/public", "/", "fixtures/public/index.html"],
["./fixtures/public", "/index", "fixtures/public/index.html"],
["./fixtures/public", "/index.html", "fixtures/public/index.html"],
["./fixtures/public", "/nofile", undefined]
] as [string, string, string | undefined][]
) {
assertEquals(await resolveIndexPath(dir, fp), exp);
}
});
});
18 changes: 5 additions & 13 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,18 @@
// Copyright 2019 Yusuke Sakurai. All rights reserved. MIT license.
import { HttpHandler } from "./router.ts";
import { RoutingError } from "./error.ts";
import { Sha1 } from "./vendor/https/deno.land/std/ws/sha1.ts";
import { assert } from "./vendor/https/deno.land/std/testing/asserts.ts";

/** Deny request with 404 if method doesn't match */
export const methodFilter = (...method: string[]): HttpHandler =>
async req => {
if (!method.includes(req.method)) {
throw new RoutingError(404, `Cannot ${req.method} ${req.path}`);
}
};
import { ServeHandler } from "./server.ts";

/** Deny requests with 400 if content-type doesn't match */
export const contentTypeFilter = (
...types: (string | RegExp)[]
): HttpHandler =>
): ServeHandler =>
async req => {
if (types.some(v => req.headers.get("content-type")?.match(v))) {
return;
}
throw new RoutingError(400, `Invalid content type`);
throw new RoutingError(400);
};

function timeSafeCompare(secret: string, other: string): boolean {
Expand All @@ -36,11 +28,11 @@ export function basicAuth({ username, password, message }: {
username: string;
password: string;
message?: string;
}): HttpHandler {
}): ServeHandler {
assert(username, "username must be defined and not be empty");
assert(password, "password must be defined and not be ampty");
// WWW-Authenticate: Basic realm="SECRET AREA"
return function basicAuth(req) {
return async function basicAuth(req) {
const authorization = req.headers.get("authorization");
if (!authorization) {
return req.respond({
Expand Down
Loading

0 comments on commit 24be3ac

Please sign in to comment.