Freesia is a library for building Node.js HTTP servers, it provides a way to describe you HTTP handling process in a functional way.
Install freesia into your project by a package manager, like NPM:
npm install freesia
This package contains built-in TypeScript declarations, you don't need any other packages to support it. But in practice, it's recommended to install @types/node
as a developing dependency.
This package is also a dual-module package, you can use it in a CommonJS or a ESM project.
In CommonJS
const { shimHTTP } = require("freesia");
In ESM and TypeScript
import { shimHTTP } from "freesia";
// app.js
import { shimHTTP, response } from "freesia";
import { createServer } from "http";
const main = async (req) => response("hello, world");
createServer(shimHTTP(main)).listen(8080);
Run it with node app.js
, and then visit http://localhost:8080, you'll see the "hello, world"
message.
In this example, we find that shimHTTP
function transform a function into a http request handler, the function we give is the EntryPoint
of a Freesia app. EntryPoint
is the type of function which parameter is the request body from http
/https
/http2
module, and return a Respond<string|Uint8Array|Readable>
tuple.
Http servers are always designed in event-driven architecture, which means the code we write is in fact a "bootstrap code": it will be executed directly, create port listener, prepare resources(for example, connect to a database), define handler functions and bind them to events.
In a bootstrap process, the handler function won't be called, they are used as values which tell the server what to do when requests come in.
After the bootstrap process finished, the server will keep running and waiting requests come in. If a request comes in, it will call the handler function to deal with this request. Freesia hooks and router functions created by Routing functions can be called only in request handling process.
Respond<T>
is a tuple that can define a response, it includes 3 parts: a body of type T
, status and http headers.
The first element of the tuple is the body of the response, it can be any type specified by template type T
, or undefined
if you'd like to response nothing.
The second element of the tuple is the status of the response, it can be a http status code, for example 200
, or a status code with custom status text like [200, "Success"]
.
Elements after the second element are http headers, headers can be defined as Record<string, string | string[]>
. For example, such two patterns have the same result:
const res1: Respond<string> = [
"hello, world",
200,
{ "Content-Type": "text/plain", "Content-Length": "12" },
];
const res2: Respond<string> = [
"hello, world",
200,
{ "Content-Type": "text/plain" },
{ "Content-Length": "12" },
];
Element with higher index will have higher priority when header names duplicated. For example:
[
"hello, world",
200,
{ "Content-Type": "text/html", "Content-Length": "12" },
{ "Content-Type": "text/plain" },
];
This Respond will be response with header Content-Type: text/plain
.
It's not difficult to create a Respond<T>
manually, but function response
provide an another way.
Follow examples can create a Respond<T>
:
response("hello, world"); // [ 'hello, world', 200 ]
response(undefined); // [ undefined, 204 ]
response("hello, world", 200, { "Content-Type": "text/plain" }); // [ 'hello, world', 200, { 'Content-Type': 'text/plain' } ]
response
method can attach http headers to an existed Respond
:
const res = response("hello, world"); // [ 'hello, world', 200 ]
response(res, { "Content-Type": "text/plain", "Content-Length": "12" });
// [
// 'hello, world',
// 200,
// { 'Content-Type': 'text/plain', 'Content-Length': '12' }
// ]
It also provide a way to rebuild another Respond from existed Respond:
const res = response<{ message: string }>({ message: `hello, world` }, 200);
response(res, (body, status, headers) => {
return response(JSON.stringify(body), status, ...headers, {
"Content-Type": "application/json",
});
});
// [
// '{"message":"hello, world"}',
// 200,
// { 'Content-Type': 'application/json' }
// ]
It's useful in EntryPoint
function because the body type must be binary-like (string | Uint8Array | Readable
).
Rebuild with asynchronous rebuilders will give a Promised Respond.
const res = response("./README.md", 200);
const resAsync = response(res, async (body, status, headers) => {
return response(
await fs.promises.readFile(url, { encoding: "utf-8" }),
status,
...headers,
{ "Content-Type": getType(url) }
);
}); // Promise<[string, 200, { "Content-Type": "text/markdown" }]>
Request pathname is one of the most important thing in a request url that tell the server what the client need. The server side application need to binding a handler function to each path pattern. The most simple example of a routing looks like this:
function main() {
const { pathname } = useURL();
let response: Respond<string | Uint8Array | Readable>;
if (pathname.match(/^(\/api)/)) {
response = apiHandler();
} else if (pathname.match(/^(\/(login|logout))/)) {
response = userSessionHandler();
} else if (pathname.match(/^(\/favicon.ico)$/)) {
response = faviconHandler();
} else if (pathname.match(/^(\/public)/)) {
response = staticFileHandler();
} else {
response = renderHandler();
}
return response;
}
In this example, we use if else
block to get right value of response by calling corresponding handler, with freesia routing functions, we can do this more elegant.
createRoute(pattern, handler, flags)
can define a route. For example:
flags
is the RegExp flags, default value is"i"
const helloRt = createRoute(
"/hello/:<lang>/:<username>",
({ lang, username }) =>
response(`${i18n("hello", lang)}, ${username}`, 200, {
"Content-Type": "text/plain",
})
);
const res = helloRt(useURL().pathname) ?? response("No route matched", 404);
In pattern, there are 4 ways to describe a route parameter:
:<paramName>
: match a string between two/
, for example,/hello/:<username>
can match/hello/freesia
and/hello/freesia/
but can't match/hello/freesia/13
:{paramName}
: match a string greedily, for example,/hello/:{username}/
can match/hello/freesia/
and/hello/js/freesia/
(username
is"js/freesia"
).:[paramName]
: like:{paramName}
, but can catch 0 characters string, for example/hello/:[username]
can match/hello/
, whileusername
is""
.:(paramName)
: like:[paramName]
, can match/
before the parameter, for example/hello/:(username)
can match/hello
, whileusername
isundefined
.
createRoute
will return a route function that receive a string as parameter, if this string matched the pattern, the matched parameters will passed to the handler, and return a value, if no matched, the route function won't call the handler but return null
directly. You can hub many route functions together with ??
operator as they will return null if not matched, just like this:
const res =
helloRt(pathname) ??
goodbyeRt(pathname) ??
updateInfoRt(pathname) ??
disableUserRt(pathname) ??
response("No route matched", 404);
With createSwitcher
, you can hub many route functins together:
const switcher = createSwitcher(
helloRt,
goodbyeRt,
updateInfoRt,
disableUserRt
);
const res = switcher(pathname) ?? response("No route matched", 404);
This requires all routes functions has the same return type, or specify a template type for createSwitcher<T>
that capitable with all route functions.
createSwRt
function provides a way to create routes and hub them to a switcher at the same time, like this:
const switcher = createSwRt<Respond<BinaryLike>>()
.route("/hello/:<username>", helloHandler)
.route("/goodbye/:<username>", goodbyeHandler)
.route("/update_info/:<username>/:<operate>/:[restArgs]", updateInfoHandler)
.route("/user/:<usrename>/disable", disableUserHandler)
.build();
const res = switcher(pathname) ?? response("No route matched", 404);
The build
method will return a switcher function, which returns null
(if no routes matched) or the handler return value.
fallback
method can also return a switcher function, it won't return null:
const switcher = createSwRt<Respond<BinaryLike>>()
.route("/hello/:<username>", helloHandler)
.route("/goodbye/:<username>", goodbyeHandler)
.route("/update_info/:<username>/:<operate>/:[restArgs]", updateInfoHandler)
.route("/user/:<usrename>/disable", disableUserHandler)
.fallback((url) => response(`No route matched url ${url}`, 404));
const res = switcher(pathname);
Use an object instead of a function to dispatch requests to different handlers by request methods. For example,
const getUserInfoRt = createRoute("/user/:<username>/info", {
GET: getUserInfoHandler,
PUT: updateUserInfoHandler,
});
const getUserInfoRt = createRoute("/user/:<username>/info", {
GET: userInfoHandler,
});
It also works in createSwRt
:
const switcher = createSwRt<Respond<BinaryLike>>()
.route("/hello/:<username>", { GET: helloHandler })
.route("/goodbye/:<username>", { GET: goodbyeHandler })
.route("/update_info/:<username>/:<operate>/:[restArgs]", {
PUT: updateInfoHandler,
})
.route("/user/:<usrename>/disable", { Put: disableUserHandler })
.fallback((url) => response(`No route matched url ${url}`, 404));
const res = switcher(pathname);
Some values might be used in many functions called during a request handling process, as a result it must be passed through many hierarchies. With Node.js AsyncStorage API, freesia provides some hooks to get values with out pass them in parameters.
useRequest
hook provide a way to access the request object any where in a request handling process.
useURL
provide a way to access request url. See the overloads here
createContext
provide a way to create a context for a request handling process. See the definitions here:
And examples:
const [assignUser, getUser, dropUserCtx] = createContext<Promise<User>>();
// UserValidate will access DB and assign a user model to context
function UserValidate(username: string, token: string): boolean {
if (validate(username, token)) {
assignUser(DB.query({ where: { username } }));
return true;
}
return false;
}
// This is a function only be called in a request handling process
async function getUserName(): Promise<string | undefined> {
return (await getUser())?.username;
}
composeFn
provides a way to compose many functions together:
const { fn } = composeFn((x: number) => x + 1)
.next((x) => Math.pow(x, 2))
.next((x) => x / 2)
.next((x) => `result is ${x}`);
fn(4); // result is 12.5
computeStream
provide a way to compute a value in a stream style process.
const value = computeStream(4)
.map((x) => x + 1)
.map((x) => Math.pow(x, 2))
.map((x) => x / 2)
.map((x) => `the result is ${x}`).value;
Except map
method, there is mapNN
method, which you can only deal with non-null values and let null
and undefined
passed through.
const value = computeStream(token)
.map((tk) => validator(tk)) // validator will return null if not valid
.mapNN((username) => queryUser(username)).value; // queryUser will return null if no such user // Promise<User> | null
computeStreamLazy
has the same API with computeStream
, but functions are called each time accessing value property.
createEffect
can create a wrapper for a function that do some side-effect for the original function. An example looks like this:
const add = (a: number, b: number) => a + b;
const debugWrapper = createEffect<typeof add>((a, b) => {
console.log(`a is ${a}, b is ${b}`);
return (result) => {
console.log(`result is ${result}`);
};
});
const wrappedAdd = debugWrapper(add);
const result = wrappedAdd(1, 2);
/**
* logs:
* a is 1, b is 2
* result is 3
*/
createEffect4Any
is very similar to createEffect
, the wrapper created by it can wrap any type of function, but the side effect should be irrelevant with the parameters and return value of the original function. For example:
const fib = (index: number): number => {
if (index === 0 || index === 1) return 1;
else return fib(index - 1) + fib(index - 2);
};
const timeMeasure = createEffect4Any(() => {
const start = new Date().getTime();
return () => {
console.log(`Use ${new Date().getTime() - start}ms`);
};
});
const fibWithTM = timeMeasure(fib);
fibWithTM(40);
/**
* logs:
* Use 1452ms
*/
createProxy provide a way to proxy a function, which can rewrite the parameters and return values.
For example:
declare function queryUser(userId: string): Promise<User>;
const getUserEmailWrapper = createProxy<
typeof queryUser,
(username: string, token: string) => Promise<string | undefined>
>(async (username: string, token: string) => {
const userId = await validator(username, token);
// first element null told the proxy not to call the original function
// second element will be executed to get a fallback value.
if (userId === undefined) return [null, () => undefined];
// first element pass parameters in an array
// second element will be called to deal with the original return value.
else
return [
[userId],
async (user) => {
return (await user).email;
},
];
});
const getUserEmail = getUserEmailWrapper(queryUser);
const email = getUserEmail(username, token);
Look at docs of createProxy
memoryCache is a wrapper that can cache return value of a function.
Example:
const fib = memoryCache((index: number): number => {
if (index === 1 || index === 2) return 1;
else return fib(index - 1) + fib(index - 2);
});
This wrapper will receive origin parameters as an array, and compare with used parameters by ===
operator and Object.is
function (same-zero-value comparation).
Be careful when using objects as parameters,
{a: 1} !== {a: 1}
,[1,2,3] !== [1,2,3]
.
isVoid
can return if a value is null
or undefined
.
declare let value: string | undefined | null;
if (isVoid(value)) {
value; // => undefined | null
}
if (isVoid(value, [undefined])) {
value; // => undefined
}
if (isVoid(value, [null])) {
value; // => null
}
declare let mayNotDefined: string | undefined;
if (isVoid(mayNotDefined)) {
mayNotDefined; // => undefined
}
declare let nullableValue: string | null;
if (isVoid(nullableValue)) {
nullableValue; // => null
}
isEnum
provides a way to check if a value one of the enum values, for example, a user-input string:
// valid option can be "auto", "manual", "default"
const option: string = await getUserInput();
if (isEnum(option, ["auto", "manual", "default"])) {
option; // => "auto" | "manual" | "default"
}
Support string and number as basic types.
- resJson
- rateLimiter
Find their docs in the GitHub Pages