plantera v0.4.1
!WARNING
This is a experimental project in the proof of concept stage. While this warning is here, the code is not ready for production. This project is an attempt to prove that the effector can be used as a base of backend framework.
Table of contents
Introduction
Plantera implements the most convenient way to create routing for your server applications in a server-side JavaScript with event-driven approch. It uses an effector under the hood to achieve maximum reactivity and performance, while also allowing integration of existing codebases written in the effector.
Core concepts:
- All handlers (or middlewares) are effects that can be interconnected and monitored by different parts of the system.
- All routing is built on chains of effects that can be composed and expanded using its API.
- The construction of the middleware system follows the declarative programming principles, that is, the system will work exactly as you described it.
Getting started
Install the package using your favorite package manager. For example: npm, pnpm, yarn or bun.
npm install planteraBasic usage:
import { createApp, controller } from "plantera";
const app = createApp();
app.get("/hello", controller(() => "hello, plantera!"));
app.listen(3000);Documentation
References
Setup
The mechanics of web servers are based on the exchange of requests and responses between the client and the server, so it is necessary to initialize the server as well as the router instance.
To do this, you can use the createApp that creates both of these entities and links them together, or you can initialize everything separately for greater flexibility.
Examples
Using default createApp behaviour:
const app = createApp();
app.listen(port);Using separate instances as parameters for createApp:
const router = createRouter();
const server = createServer(router.callback);
const app = createApp({ router, server });
app.listen(port);Using manual initialization:
const router = createRouter();
const server = createServer(router.callback);
server.listen(port);Routing
Routing can be implemented using custom middleware composition system, but it is recommended to use pre-designed router instance that can be created with createRouter or createApp.
// This is a router:
const router = createRouter();
// This is a router with server attached:
const app = createApp();The router entity is a composed middleware instance with an extended API for defining endpoints. Let's look at how to define endpoints and nest them.
Basic endpoints
To define an endpoint, you can use route, get, post, put and other HTTP method specific methods.
The route method accepts a method, path template and a list of methods. The handlers will only be called when the request matches this route.
router.route("GET", "/path", handlers);Like the route method, HTTP method specific methods imply similar logic, but without having to specify the method with string parameter.
router.get("/path", handlers);The endpoint definition returns an event that can be used as a firing event for external handlers.
import { sample } from "effector";
// ...
sample({
clock: router.post("/action"),
target: actionFx
});Nesting
Route nesting can be implemented by chaining route definitions.
With decomposition:
// GET /users
const getUsers = router.get("/users", handlers);
// POST /users/:id
const updateUser = getUsers.post("/:id");
// GET /items
const getItems = router.get("/items", handlers);
// POST /items/:id
const updateItem = getItems.post("/:id", handlers);With inline chaining:
router
.get("/users", handlers) // GET /users
.get("/:id", handlers); // POST /users/:id
router
.get("/items", handlers) // GET /items
.get("/:id", handlers); // POST /items/:idPrefixes
To specify the base path for a router branch, you can use prefix method. It attaches a string to context.route.path that will be used as a base path for all further routes.
// GET /users
const withPrefix = router.prefix("/users");
// GET /users/:id
withPrefix.get("/:id", handlers);
// GET /items
router.get("/items", handlers);Decomposition
The router instance can be decomposed into multiple routers to achieve modularity. Multiple routers can be connected to each other using use and other forwarding methods.
Examples:
const childRouter = createRouter();
// ...
parentRouter.use(childRouter);const childRouter = createRouter();
// ...
parentRouter.prefix("/base-path").use(childRouter);const childRouter = createRouter().prefix("/base-path");
// ...
parentRouter.use(childRouter);const childRouter = createRouter();
// ...
parentRouter.route(HttpMethods.Unspecified, "/base-path", childRouter);Complex control-flow
To achieve complex control flow with filtering, forking and other useful patterns, try other methods that provided in composed middleware API.
Controllers
When it comes time to process a request, it is necessary to have a function that accepts the request context and can return some value to the client. Such functions and effects can be defined manually, but there is a controller function for such a thing.
This method turns any function into a handler that will work with the request data and send a response to the client. A function can initially work with a context object from parameters, or accept its own parameters, or not accept parameters at all. Let's look at all cases.
With no params and return value:
const empty = () => {};
const controller = controller(empty);
// the controller will send 200 status code with no dataWith no params:
const generator = () => value;
const controller = controller(generator);
// the controller will send a valueWith possible exception:
const throwsError = () => {
throw new Error(...);
};
const controller = controller(throwsError);
// the controller will send 400 status code with error messageWith context as parameter:
const withContext = (context) => value;
const controller = controller(withContext);With own parameters. The adapter converts context object to the expected value:
const withCustomParams = (params) => value;
const controller = controller(withCustomParams, adapter);With context as parameter that used to send response. There's no need to use controller decorator:
const someController = (context) => {
// ...
context.res.send(...);
};Request context
The request context is an object that is passed between effects in the router middleware system. It consists of familiar req and res fields and own API. A new context object is created when a new request is received from the server instance. You also can manually create new context object with createContext.
req
The req field consists of IncomingMessage and an additional data fields.
querycontains URL-encoded parameters from the request URL.
router.get("/search", controller((context) =>
search(context.req.query.q || "")
));
// GET /search?q=cats --> context.req.query == { q: "cats" } paramscontains a slug parameters based on the relevant route path template from the request URL.
router.get("/search/:query", controller((context) =>
search(context.req.params.query || "")
));
// GET /search/cats --> context.req.params == { query: "cats" } bodycontains interpreted request body data passed from the client.Not implemented yet
router.put("/items", controller((context) =>
insertItems(context.req.body.items)
));
// PUT /items { items: [...] } --> context.req.body == { items: [...] }res
The res field consists of ServerResponse and an additional API.
send- the function that transforms passed data and sends it as a response.
router.get("/birds", async (context) =>
context.res.send(await getBirds())
);sent- the flag indicating whether the response has been sent or not.
route
The route field consists of a current route metadata.
method- current method that have been applied to filter requests.path- current path template that have been applied to filter requests or to set base path.
Composition
Plantera uses an event-driven architecture to implement application logic. To simplify the construction of reactive systems, an API has been implemented that allows you to combine effects (further middlewares) and events into extensible chains. This approach is used by default in routing and can be used independently of it, for example in separate modules.
compose function is used to combine middlewares into a callable chain and provide them with an API for expansion and distribution. It returns an entity with sufficient properties and methods to further define any flow declaratively. The passed middleware can be any callable: a function, effect,
or other composed middleware, or a sublist of similar elements.
const increment = (n: number) => n + 1;
const square = (n: number) => n ** 2;
const incrementAndSquare = compose(increment, square);
incrementAndSquare(5); // -> 36Let's look at the API that allows to extend it further.
Basic methods
.use
Composes passed middlewares and forwards the last current middleware to the first passed one. Returns an extension of the current composed middleware.
composed.use(first, second);
// first and second will run afterThis method is often used to include middlewares in routing.
.filter
Composes passed predicates and middlewares respectively and forwards the last current middleware to the first passed one with filter attached. Returns an extension of the current composed middleware.
composed.filter(predicate, next);
// next will run if predicate returns trueThis method can be used to add guards on top of your handlers.
.fork
Composes passed middlewares and forwards the last current middleware to the first passed one without extension. It can be used for high-level concurrency or separation in use middleware system. Returns an untouched instance of the current composed middleware.
composed.use(first); // will run first
composed.fork(second, third); // will run after first, but concurrently
composed.use(fourth); // will run after first.branch
Composes passed predicates and middlewares respectively and creates a new attached middleware that will execute match or mismatch middlewares based on predicate's return value. Returns an extension of the current composed middleware.
composed.branch(predicate, match, mismatch);
// if predicates returns true, match will run, otherwise - mismatchAdvanced methods
.forkFilter
Composes passed predicates and middlewares respectively and forwards
the last current middleware to the first passed one with filter attached
(like filter) without extension.
Returns an untouched instance of the current composed middleware.
// will run first
composed.use(first);
// will run after first as filter, but concurrently
composed.forkFilter(predicate, second);
// will run after first
composed.use(third);.split
Composes passed predicates and middlewares respectively and splits execution result of last middleware of the current composed middleware based on predicate's return value.
This method is similar to branch, but doesn't extend current composed
middleware.
composed.split(predicate, match, mismatch);
// if predicates returns true, match will run, otherwise - mismatch.forEach
Iterates through passed items with a provided relevant instance. Returns a list of values that were returned from callback.
// forks each middleware separately
composed.forEach(
[first, second, third],
(it, instance) => instance.fork(it)
);Interception
.on
Composes passed middlewares and forwards first effect
to the first passed one with filter attached.
Returns composed passed middlewares.
The first event will fire after each execution of the current
composed middleware. It means, that next composed will run each time
this middleware executes.
composed.on(predicate, next);
// next will run if predicate returns true after each execution.when
Composes passed middlewares and forwards step event
to the first passed one with filter attached.
Returns composed passed middlewares.
The step event will fire after the successful execution of each of the
middleware of the current composed middleware system. It means, that
next composed will run each time some middleware executes and predicate
returns true.
composed.when(predicate, next);
// next will run if predicate returns true after each step.intercept
Composes passed middlewares and forwards step event
to the first passed one. Returns composed passed middlewares.
The step event will fire after the successful execution of each of the
middleware of the current composed middleware system. It means, that
next composed will run each time some middleware executes.
composed.intercept(first).use(second);
// first and second will run after each stepError handling
.catch
Composes passed middlewares and forwards fail event
to the first passed one with filter attached.
Returns composed passed middlewares.
The fail event will fire when any of the current system's middleware
throws an exception. It means, that next composed will run each time some
middleware throws an exception.
composed.catch(next);
// next will run after each failPresets
Presets are used to directly modify and update an instance. For example,
use fork or filter on it without the need to create a separate composed.
Presets can be registered in composed middleware with use or apply methods.
Without presets:
const applyCustomFilter = () => {
const separateComposed = compose();
separateComposed.filter(predicate, something);
return separateComposed;
}
composed.use(applyCustomFilter());With presets:
const applyCustomFilter = createPreset(
source => source.filter(predicate, something)
);
composed.use(applyCustomFilter);Built-in units
compose produces events and effects that can be used to externally extend the system.
.first- a first effect of the current composed middleware. It can be used as a firing event because of its targeting properties..last- a last effect of the current composed middleware. It can be used as a terminator event because of its targeting properties..step- an event that fires after the successful execution of each of the middleware of the current composed middleware system..fail- An event that fires when any of the current system's middleware throws an exception..passed- an alias event, derived forlastproperty. It only fires whenlasteffect is fired..ends- an alias event, derived forlast.doneproperty. It only fires whenlast.doneeffect is fired.