Skip to content

planterajs/plantera

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

87 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Plantera

Server routing with ease.

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 approach. 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 plantera

Basic 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/:id

Prefixes

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 data

With no params:

const generator = () => value;
const controller = controller(generator);
// the controller will send a value

With possible exception:

const throwsError = () => {
    throw new Error(...);
};
const controller = controller(throwsError);
// the controller will send 400 status code with error message

With 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.

  • query contains 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" } 
  • params contains 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" } 
  • body contains 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); // -> 36

Let'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 after

This 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 true

This 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 - mismatch

Advanced 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 step

Error 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 fail

Presets

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 from last property. It only fires when last effect is fired.
  • .ends - an alias event, derived from last.done property. It only fires when last.done effect is fired.

Links

Releases

No releases published

Packages

No packages published