A lightweight Express.js dependency injection & route abstraction library.
-
Express is a fantastic way to build server-side apps in JavaScript, but wiring can get messy very quickly. Sapling abstracts away complicated wiring of controllers & routes, allowing you to focus on business logic & write unit tests in a painless way.
-
Sapling is inspired by Spring, but without losing the developer experience, speed & simplicity of Express.js / TypeScript.
- The best reason to use Sapling is that you can opt-in or opt-out as much as you would like; run any traditional & functional Express.js without having to hack around the library.
-
Sapling DI & routing is designed to be very light. This may be preferable to other libraries like Nest.js that provide a much heavier abstraction. Get what would be helpful to your improve development speed, ignore anything else that may get in your way.
Check the /example folder for a basic todo app with database integration.
Sapling is also powering one of my more complex projects with 660+ users in production, which you can view at instalock-web/apps/server.
# we <3 pnpm
pnpm install @tahminator/saplingimport express from "express";
import { Sapling, Controller, GET, POST, ResponseEntity, Class, HttpStatus, MiddlewareClass, Middleware } from "@tahminator/sapling";
@MiddlewareClass()
class LoggingMiddleware {
@Middleware()
loggingMiddleware(
request: express.Request,
response: express.Response,
next: express.NextFunction,
): void {
console.log(request.path);
next();
}
}
@Controller({ prefix: "/api" })
class HelloController {
@GET()
getHello(): ResponseEntity<string> {
return ResponseEntity.ok().body("Hello world");
}
}
@Controller({ prefix: "/api/users" })
class UserController {
@GET()
getUsers(): ResponseEntity<string[]> {
return ResponseEntity.ok().body(["Alice", "Bob"]);
}
@POST()
createUser(): ResponseEntity<{ success: boolean }> {
return ResponseEntity.status(HttpStatus.CREATED).body({ success: true });
}
}
// you still have full access of app to do whatever you want!
const app = express();
Sapling.registerApp(app);
// @MiddlewareClass should be registered first before @Controller and should be registered in order
// @Injectable classes will automatically be formed into singletons by Sapling behind the scenes!
const middlewares: Class<any>[] = [LoggingMiddleware];
middlewares.map(Sapling.resolve).forEach((r) => app.use(r));
// @Controller can be registered in any order.
// @Injectable classes will automatically be formed into singletons by Sapling behind the scenes!
const controllers: Class<any>[] = [HelloController, UserController];
controllers.map(Sapling.resolve).forEach((r) => app.use(r));
app.listen(3000);Hit GET /api for "Hello world" or GET /api/users for the user list.
Use @Controller() to mark a class as a controller. Routes inside get a shared prefix if you want:
@Controller({ prefix: "/users" })
class UserController {
@GET("/:id")
getUser() {
/* ... */
}
@POST()
createUser() {
/* ... */
}
}Sapling supports the usual suspects:
@GET(path?)@POST(path?)@PUT(path?)@DELETE(path?)@PATCH(path?)@Middleware(path?)- for middleware
Path defaults to "/" if you don't pass one.
ResponseEntity gives you a builder pattern for responses:
@Controller({ prefix: "/users" })
class UserController {
@GET("/:id")
getUser(): ResponseEntity<User> {
const user = findUser(id);
return ResponseEntity.ok().setHeader("X-Custom", "value").body(user);
}
}For status codes you can use .ok() (200) or .status() to define a specific status with the HttpStatus enum.
You can also set custom status codes:
@Controller({ prefix: "/api" })
class CustomController {
@GET("/teapot")
teapot(): ResponseEntity<string> {
return ResponseEntity.status(HttpStatus.I_AM_A_TEAPOT).body("I'm a teapot");
}
}Use ResponseStatusError to handle bad control paths:
@Controller({ prefix: "/users" })
class UserController {
constructor(private readonly userService: UserService) {}
@GET("/:id")
async getUser(request: Request): ResponseEntity<User> {
const user = await this.userService.findById(request.params.id);
if (!user) {
throw new ResponseStatusError(HttpStatus.NOT_FOUND, "User not found");
}
return ResponseEntity.ok().body(user);
}
}Make sure to register an error handler middleware:
Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
res.status(err.status).json({ error: err.message });
});Load Express middleware plugins using @Middleware():
import { MiddlewareClass, Middleware } from "@tahminator/sapling";
import cookieParser from "cookie-parser";
import { NextFunction, Request, Response } from "express";
@MiddlewareClass() // @MiddlewareClass is an alias of @Controller, provides better semantics
class CookieParserMiddleware {
private readonly plugin: ReturnType<typeof cookieParser>;
constructor() {
this.plugin = cookieParser();
}
@Middleware()
register(request: Request, response: Response, next: NextFunction) {
return this.plugin(request, response, next);
}
}
// Register it like any controller
app.use(Sapling.resolve(CookieParserMiddleware));
// You can also still choose to load plugins the Express.js way
app.use(cookieParser());@Controller({ prefix: "/api" })
class RedirectController {
@GET("/old-route")
redirect() {
return RedirectView.redirect("/new-route");
}
}Mark services with @Injectable() and inject them into controllers:
@Injectable()
class UserService {
getUsers() { ... }
}
@Controller({
prefix: "/users",
deps: [UserService]
})
class UserController {
constructor(private readonly userService: UserService) {}
@GET()
getAll() {
return ResponseEntity.ok().body(this.userService.getUsers());
}
}Injectables can also depend on other injectables:
@Injectable()
class Database {
query() { ... }
}
@Injectable([Database])
class UserRepository {
constructor(private readonly db: Database) {}
findAll() {
return this.db.query("SELECT * FROM users");
}
}By default, Sapling uses JSON.stringify and JSON.parse for serialization. You can override these with custom serializers like superjson to automatically handle Dates, BigInts, and more:
import superjson from "superjson";
Sapling.setSerializeFn(superjson.stringify);
Sapling.setDeserializeFn(superjson.parse);This affects how ResponseEntity serializes response bodies and how request bodies are deserialized.
Note
You need ESLint (or some alternative build step that has glob-import support)
Controllers can be automatically imported via a glob-import if you ensure that all controller files are:
export default(so one controller per file)- all controller files are marked as
*.controller.ts
. The steps below indicate a working example inside of my webapp, instalock-web/apps/server.
- Create a bootstrap file that will glob-import all controller files and export them
// file will automatically import any controller files
// it will pull out default exports, so ensure
// 1. one class per file
// 2. `export default XyzController`
// q: wont this break ordering of controller imports?
// a: yes but that's ok - controllers are the last in the dependency graph.
// they import, but are never imported themselves.
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-nocheck
import { modules } from "./controller/**/{controller,*.controller}.ts#default";
export const getControllers = (): Class<unknown>[] => {
return modules.map((mod) => mod.default as Class<unknown>);
};- Point
Sapling.resolvetogetControllers
const controllers = getControllers();
console.log(`${controllers.length} controllers resolved`);
controllers.map(Sapling.resolve).forEach((r) => app.use(r));- Configure your ESBuild process to use the
esbuild-plugin-import-patternplugin
import * as esbuild from "esbuild";
// @ts-expect-error no types
import { importPatternPlugin } from "esbuild-plugin-import-pattern";
async function main() {
const ctx = await esbuild.context({
entryPoints: ["src/index.ts"],
bundle: true,
sourcemap: true,
platform: "node",
outfile: "src/index.js",
logLevel: "info",
format: "cjs",
plugins: [importPatternPlugin()],
});
await ctx.rebuild();
await ctx.dispose();
}
void main();MIT