Freesia for Deno: a TypeScript library for building HTTP servers.
This is the Freesia library for Deno, which provides an experience similar to
Node.js version. You can load this library directly from GitHub by url
https://deno.land/x/freesia
.
This project is still under developing, specify a commit id to access it or clone its deno branch as a submodule if you want to use in production.
Here is a simple example:
import {
createRes,
createSwRtX,
EntryPoint,
Get,
parseURL,
shimHTTP,
} from "https://deno.land/x/freesia";
import { serve } from "https://deno.land/std@0.127.0/http/server.ts";
const { switcher } = createSwRtX
.route(
"/hello/<username>/",
Get(async ({ username }) => createRes(`hello, ${username}`)),
)
.fallback(async (url, req) => createRes(404, `Can't ${req.method} ${url}`));
const main: EntryPoint = async (req) => {
const { pathname } = parseURL(req);
return switcher(pathname, req);
};
serve(shimHTTP(main), { port: 8000 });
Be aware of that Freesia for Deno has no magical functions.
Basic
When using Freesia, there is nothing like app
object in express or koa. You
need to write the entry function to handle request by your self. It should
receive a parameter in type HttpReq
(it’s the union type of http and http2
request type), and return an object of ResponseProps
type in asynchorous way.
After that, you can use shimHTTP
to transform it to a handler for
createServer
in http
or createSecureServer
in http2
.
EntryPoint: main function
EntryPoint
is a type that describe a main function in Freesia
, it is defined
as:
type EntryPoint = (req: HttpReq) => Promise<ResponseProps>;
You need to implement a function of this type, to describe how you deal with a request.
Process of this function and all function called by it (directly and indirectly) is a request handling process. Some special functions provided by freesia can be called only inside of this process.
ResponseProps and Respond
ResponseProps
is a interface of response object, it includes 4 properties:
status
valid http status codestatusText
status message, can be set toundefined
, and it’s not work with HTTP/2body
response body, can be astring
,Buffer
or aReadable
stream, orundefined
headers
response headers, type of it isRecord<string, string | string[] | number>
It’s difficult to create and operate such an object manually, and Freesia
provides a interface with methods named Respond
It’s difficult to create and operate such an object manually, and Freesia
provides a interface Respond
which has some methods to modify the object.
const response = createRes()
.setStatus(200)
.setStatusText("Ok")
.setBody("hello, world.")
.setHeaders("Content-Type", "text/plain; charset=UTF-8")
.setHeaders("Content-Length", 13);
The interface of Respond
is extended from ResponseProps
interface, and you
can use setXXX
methods to operate it.
Except setHeaders
, all other setXXX
functions will replace existed value in
the object and setHeaders
will merge headers set newly into the existed
headers. setHeaders
provides many format of input, you can learn from
API document
createRes
function provides many overloads to help developers create such an
object easily:
export function createRes(): Respond; // This will give you an empty response with 404
export function createRes(code: number): Respond; // This will give you a response with specified status
export function createRes(body: ResponseBody): Respond; // This will give a response with specify body and status 200
export function createRes(code: number, body: ResponseBody): Respond; // specify status and body
export function createRes(code: number, headers: Headers): Respond; // specify status and headers
export function createRes(body: ResponseBody, headers: Headers): Respond; // specify body and headers, default status is 200
export function createRes(
code: number,
body: ResponseBody,
headers: Headers,
): Respond; // specify status, body and headers
Status
is a enum of HTTP status codes, for example, Status.NotFound
is
404
, Status.Ok
is 200
. If you think this is more Readable, you can use it
to set status code instead of numbers.
Status
is a enum of HTTP status codes, for example, Status.NotFound
is
404
, Status.Ok
is 200
. If you think this is more Readable, you can use it
to set status code instead of numbers.
shimHTTP
With upon introduction, it’s not difficult to create your first main function:
// main.ts
export const main: EntryPoint = async (req) => createRes("hello, world");
And then we transfrom it to a handler that can be used by serve
function:
// server.ts
import main from "./main.ts";
import { shimHTTP } from "https://deno.land/x/freesia";
import { serve } from "https://deno.land/std@0.127.0/http/server.ts";
serve(shimHTTP(main), { port: 8000 });
Now it works! You created your first Freesia app.
But we still have lots of things to do, please follow this introduction.
Routes
createRoute
A route can be described with a path matching pattern and a handler, for example, you may need a route that can say hello to the user, like this:
Browser: GET /hello/mary
Server: hello, mary
With createRoute
function, you can create such a route like this:
export const helloRoute = createRoute(
"/hello/<username>",
({ username }) => createRes(`hello, ${username}`),
);
The handler parameters will be inferred automatically by the path patter you give. Route params can be described in such ways:
<ParamName>
non-greedily match at least one characters:/user/<username>
can match/user/hexiang
(username
is"hexiang"
) and/user/hexiang/avatar
(username
is"hexiang/avatar"
)/user/<username>/
can match/user/hexiang/
(username
is"hexiang"
) but can’t match/user/hexiang/avatar
(un-matched after the third slash).
<[ParamName]>
greedily match at least one characters:/user/<username>/<[rest]>/
can match/user/hexiang/avatar/
(rest
is"avatar"
) and/user/hexiang/info/gender/
(rest
is"info/gender"
)
[ParamName]
greedily match at least zero characters:/files/[filepath]
can match/files/README.md
and/files/
(filepath
is""
, and you may like to give a list of files under root directory under this situation).
createRoute
returns a function that receive a string as paramters, and can
return what the given handler returns (anything, not only objects created by
createRes
) if the string matched given pattern. If not matched, the function
will return null
. You can define many routes in practice:
const helloRoute = createRoute(
"/hello/<username>",
({ username }) => `hello, ${username}`,
);
const goodByeRoute = createRoute(
"/goodbye/<username>",
({ username }) => `goodbye, ${username}`,
);
const main = async (req: HttpReq) => {
const { pathname } = parseURL(req);
const message = helloRoute(pathname) ??
goodbyeRoute(pathname) ??
/**
* each route function can return null,
* you must handle it.
*/
"No route matched";
return createRes(message);
};
You’ll mention that req.url
in Deno is different from Node.js, it is a full
url, which includes protocol, hostname, pathname and search params, so we need
to use new URL(req.url)
to get pathname
before routing, or use parseURL
function to generate a URL
object from req
.
createSwitcher
As you see, we need to give pathname
to each route function, that’s not good.
Freesia provides a function called createSwitcher
to solve this problem. It
can connect many routes together:
const helloRoute = createRoute(
"/hello/<username>",
({ username }) => `hello, ${username}`,
);
const goodByeRoute = createRoute(
"/goodbye/<username>",
({ username }) => `goodbye, ${username}`,
);
const switcher = createSwitcher(helloRoute, goodByeRoute);
const main = async (req: HttpReq) => {
const { pathname } = parseURL(req);
const message = switcher(pathname) ??
/**
* each route function can return null,
* you must handle it.
*/
"No route matched";
return createRes(message);
};
createSwitcher
returns another route function that connected to all given
route functions. All given route functions should have same return type, or you
can specify the Union-Type in createSwitcher<T>
.
createSwRt
Another way to create switchers is using createSwRt
, you can create routes and
connect them to switcher at the same time.
createSwRt
is both a function and a namespace, createSwRt()
is equals to
createSwRt
.
const { switcher } = createSwRt
.route("/hello/<username>", ({ username }) => `hello, ${username}`)
.route("/goodbye/<username>", ({ username }) => `goodbye, ${username}`)
/**
* fallback method will return a route function that will not return null,
* if no patter matched, it will execute the fallback handler. If you don't
* use fallback method, the switcher can still return null.
*/
.fallback((url) => `No pattern matched ${url}`);
const main = async (req: HttpReq) => {
const { pathname } = parseURL(req);
const message = switcher(pathname);
return createRes(message);
};
The return type is inferred by the first called route
method, if you’d like to
specify a union-type as generic type, use route<T>
.
X series route creators
In practice, we need always pass req
object as parameters, but either route
handler or generated route function don’t have arguments to receive it. A simple
way to resolve this problem is define route in the scope where req
object
exists:
import helloHandler from "./handler/hello";
import goodByeRoute from "./route/goodBye";
const main = async (req: HttpReq) => {
const helloRoute = createRoute(
"/hello/<username>",
({ username }) => helloHandler(username, req),
);
const switcher = createSwitcher(helloRoute, goodByeRoute);
return createRes(switcher(parseURL(req).pathname) ?? "No route matched");
};
But that means every time a request comes in, a new router function need to be create, it’s awful. On the other hand, if there are many routes need such an extra parameter, code will be difficult to organize.
Freesia provides X-series route creators to solve this problem. They are
createRouteX
, createSwitcherX
and createSwRtX
. Handlers use be routes
created by X-series creators can receive an extra parameter (can be anything,
not only req
object), and generated route functions will have an extra
argument and pass it to handlers on routing.
/**
* here, helloRoute can receive an extra parameter of HttpReq type.
*/
const helloRoute = createRouteX(
"/hello/<username>",
async ({ username }, req: HttpReq) =>
`hello, ${username} from, you send content: ${await req.text()}`,
);
const goodByeRoute = createRouteX(
"/goodbye/<username>",
/**
* It's no problem ignore the extra parameter, it just
* declare the same type with helloRoute.
*/
async ({ username }, req: HttpReq) => `goodbye, ${username}`,
);
const switcher = createSwitcherX(helloRoute, goodByeRoute);
const main = async (req: HttpReq) => {
/**
* Here, switcher can receive the extra parameter
*/
const message = switcher(parseURL(req).pathname) ?? "No route matched";
return createRes(message);
};
Example for createSwRtX
:
const { switcher } = createSwRtX
.route(
"/hello/<username>",
({ username }, req: HttpReq) =>
`hello, ${username} from, you send content: ${await req.text()}`,
)
// No problem ignore extra parameter
.route("/goodbye/<username>", ({ username }) => `goodbye, ${username}`)
// fallback handler will also receive the extra parameter
.fallback(
(url, req) =>
`No pattern matched ${url}, your ip is ${req.socket.address().address}`,
);
const main = async (req: HttpReq) => {
const message = switcher(parseURL(req).pathname, req);
return createRes(message);
};
Methods limit
As there is no magical functions, allowMethods
and allowMethodsX
dosen’t
work in deno version. allowMethods
function for deno is in fact the
non-magical function allowMethodsN
from v2
branch. As a result, methods
limit in routing is supported with X series routes only, and the extra parameter
must be HttpReq
.
const { switcher } = createSwRtX
.route(
"/hello/<username>",
// As you use Get function to wrap the handler, the second parameter of handler is inferred as HttpReq
Get(
({ username }, req) =>
`hello, ${username} from, you send content: ${await req
.text()}`,
),
)
.route(
"/goodbye/<username>",
Get(({ username }) => `goodbye, ${username}`),
)
.fallback((url) => `No pattern matched ${url}`);
const main: EntryPoint = async (req) => {
const message = switcher(parseURL(req).pathname, req) ?? "No route matched";
return createRes(message);
};
Utils
composeFn
composeFn
provides a way to connect functions together.
const { fn } = composeFn((x: number) => x + 1)
.next((x) => Math.pow(x, 2))
.next((x) => x * 4)
.next((x) => `final result is ${x}`);
fn(1); // -> "final result is 16"
Computation
compute
can create Computation<T>
object.
const credentialInfo = compute(tokenBuf) // Computation<Buffer>
.map((buf) => buf.toString("utf-8")) // Computation<string>
.map(parseToken) // Computation<Promise<Token | null>>
.aMapSkipNull((token) => (outDated(token) ? null : token)) // Computation<Promise<Token | null>>
.aMapSkipNull((token) => token.username).value; // Promise<string | null>
It has 4 methods:
- map:use a function to deal with internal value
- mapSkipNull:use a function to deal with internal value, but left origin value if it’s undefined and null
- aMap: unpack the Promise and use function to deal with the value, and use Promise to wrap the result.
- aMapSkipNull:unpack the Promise and use function to deal with the value, and
use Promise to wrap the result. if origin value is
undefined | null | Promise<undefined | null>
, keep and wrap toPromise<undefined | null>
.
Each methods will return a new Computation<T>
, if you pass it two different
chains, they will not influence each other.
Using computeLazy
can create Computation<T>
object too.
The difference between compute
and computeLazy
is that compute
will
compute the value every time called method in stream, but computeLazy
just
compose functions。when you finally access value
of the object,the composed
functions will be executed. Be careful especailly when functions has
side-effection! Every time you access value
, composed function will be called.
Just like Computation
, methods of Lazy
object will return a new Lazy
object, but if you pass a Lazy
to different chains, the results will not
influence each other only if all functions are pure.
Effect and Wrapper
Some times we would like to do something before or after a function, but these logics are not a part of target function, we will find a way to define them in other places. In other languages, we may use decorators to implement this, but in TypeScript, decorators can’t apply to a simple function. So Freesia provides two ways to create outside logic of a function.
createEffect
createEffect
can create a wrapper for target function, the wrapper will not
influence the arugment and return value of original function, what the wrapper
do is side-effect.
// visitLogger.ts
export const visitLogger = createEffect<EntryPoint>((req) => {
const path = useURL("path");
const reqComeIn = new Date();
return async (res) => {
const { statusCode } = await res;
const resGoOut = new Date().getTime();
console.log(
`${reqComeIn.toLocaleString()} ${path} ${statusCode} ${
resGoOut - reqComeIn.getTime()
}ms`,
);
};
});
// main.ts
const mainWithVisitLogger = visitLogger(main);
In this example, createEffect
create a wrapper for functions of type
EntryPoint
. It can access the parameter and return value of origin function,
but unable to change them.
The parameter hook
is a function (we can call it beforeHook
) that can get
the paramters of original function, and called before original function. It will
return another function (we can call it afterHook
) which use original
function’s return value as parameter and called after original function, this
function should return void. You can ignore arguments of beforeHook
and
afterHook
.
Some times, what we need to do is completely irrelevant to original function, we
can use createEffect4Any
instead of createEffect
, the beforeHook
and
afterHook
both has no arguments. The function type will not change after
applying the wrapper created by createEffect4Any
.
beforeHook
must be an synchorous function, butafterHook
can be a asynchorous function.
createWrapper
Some times we would like to do some pretreatment to function arguments or post
processing to return values, then we use createWrapper
to create such a
wrapper.
createWrapper<O, T>
has 2 generic types, O
is the original function type and
T
is the wrapped function type. If you don’t like to change the type, you can
specify O
only, default T
is O
.
export const useAutoPlainText = createWrapper<
(req: HttpReq) => Promise<Respond>
>((req) => [
[req],
async (res) => {
const response = await res;
if (
typeof response.body === "string" &&
!("Content-Type" in response.headers)
) {
response.setHeaders(["Content-Type", "text/plain; charset=UTF-8"]);
}
return response;
},
]);
The beforeHook
should receive parameters of type T
, and return a
BeforeHookTuple<O, T>
, It has 3 pattern:
- Full pattern:
[[Parameters<O>], (res: ReturnType<O>) => ReturnType<T>]
, the first element is parameters need to pass to original function, the second element is theafterHook
called after original function, it returns the value of wrapped function. - Block pattern:
[null, () => ReturnType<T>]
, if first element isnull
, the original function won’t be called, andafterHook
should has no arguments. - Pretreatment only pattern:
[[Parameters<O>]]
if you don’t want to change the return value, you can ignore theafterHook
.
If at least one of
beforeHook
,afterHook
and original function is asynchorous, the wrapped function (T
) will return aPromise
, which meansO
andT
might be unable to be equal.