@tahminator/sapling 1.5.0 → 1.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Tahmid Ahmed
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,281 @@
1
+ # Sapling
2
+
3
+ A lightweight library that brings some structure to Express.js
4
+
5
+ ## Table of Contents
6
+
7
+ - [Why?](#why)
8
+ - [Examples](#examples)
9
+ - [Install](#install)
10
+ - [Quick Start](#quick-start)
11
+ - [Features](#features)
12
+ - [Controllers](#controllers)
13
+ - [HTTP Methods](#http-methods)
14
+ - [Responses](#responses)
15
+ - [Error Handling](#error-handling)
16
+ - [Middleware](#middleware)
17
+ - [Redirects](#redirects)
18
+ - [Dependency Injection](#dependency-injection)
19
+ - [Custom Serialization](#custom-serialization)
20
+
21
+ ## Why?
22
+
23
+ 1. Express is great, but it can get really messy really quickly. Sapling lets you define controllers and routes using decorators instead of manually wiring everything up.
24
+
25
+ 2. I took a lot of inspiration from Spring, but I don't believe that it would be correct to try to force Express.js or Typescript to adopt OOP entirely, which leads me to my next point:
26
+ - The best reason to use Sapling is that you can also eject out of the object oriented environment and run regular functional Express.js without having to do anything extra or hacky.
27
+
28
+ 3. I prefer Sapling to other libraries like Nest.js because they are usually too heavy of an abstraction. I only want what would be helpful to improve development speed without getting in my way, nothing more & (ideally) nothing else.
29
+
30
+ ## Examples
31
+
32
+ Check the `/example` folder for a basic todo app with database integration.
33
+
34
+ Sapling is also powering one of my more complex projects with 600+ users in production, which you can view at [instalock-web](https://github.com/tahminator/instalock-web).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ # we <3 pnpm
40
+ pnpm install @tahminator/sapling
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```typescript
46
+ import express from "express";
47
+ import {
48
+ Sapling,
49
+ Controller,
50
+ GET,
51
+ POST,
52
+ ResponseEntity,
53
+ Class,
54
+ } from "@tahminator/sapling";
55
+
56
+ @Controller({ prefix: "/api" })
57
+ class HelloController {
58
+ @GET()
59
+ getHello(): ResponseEntity<string> {
60
+ return ResponseEntity.ok().body("Hello world");
61
+ }
62
+ }
63
+
64
+ @Controller({ prefix: "/api/users" })
65
+ class UserController {
66
+ @GET()
67
+ getUsers(): ResponseEntity<string[]> {
68
+ return ResponseEntity.ok().body(["Alice", "Bob"]);
69
+ }
70
+
71
+ @POST()
72
+ createUser(): ResponseEntity<{ success: boolean }> {
73
+ return ResponseEntity.status(HttpStatus.CREATED).body({ success: true });
74
+ }
75
+ }
76
+
77
+ // you still have full access of app to do whatever you want!
78
+ const app = express();
79
+ Sapling.registerApp(app);
80
+
81
+ const controllers: Class<any>[] = [HelloController, UserController];
82
+ controllers.map(Sapling.resolve).forEach((r) => app.use(r));
83
+
84
+ app.listen(3000);
85
+ ```
86
+
87
+ Hit `GET /api` for "Hello world" or `GET /api/users` for the user list.
88
+
89
+ ## Features
90
+
91
+ ### Controllers
92
+
93
+ Use `@Controller()` to mark a class as a controller. Routes inside get a shared prefix if you want:
94
+
95
+ ```typescript
96
+ @Controller({ prefix: "/users" })
97
+ class UserController {
98
+ @GET("/:id")
99
+ getUser() {
100
+ /* ... */
101
+ }
102
+
103
+ @POST()
104
+ createUser() {
105
+ /* ... */
106
+ }
107
+ }
108
+ ```
109
+
110
+ ### HTTP Methods
111
+
112
+ Sapling supports the usual suspects:
113
+
114
+ - `@GET(path?)`
115
+ - `@POST(path?)`
116
+ - `@PUT(path?)`
117
+ - `@DELETE(path?)`
118
+ - `@PATCH(path?)`
119
+ - `@Middleware(path?)` - for middleware
120
+
121
+ Path defaults to `"/"` if you don't pass one.
122
+
123
+ ### Responses
124
+
125
+ `ResponseEntity` gives you a builder pattern for responses:
126
+
127
+ ```typescript
128
+ @Controller({ prefix: "/users" })
129
+ class UserController {
130
+ @GET("/:id")
131
+ getUser(): ResponseEntity<User> {
132
+ const user = findUser(id);
133
+ return ResponseEntity.ok().setHeader("X-Custom", "value").body(user);
134
+ }
135
+ }
136
+ ```
137
+
138
+ For status codes you can use `.ok()` (200) or `.status()` to define a specific status with the `HttpStatus` enum.
139
+
140
+ You can also set custom status codes:
141
+
142
+ ```typescript
143
+ @Controller({ prefix: "/api" })
144
+ class CustomController {
145
+ @GET("/teapot")
146
+ teapot(): ResponseEntity<string> {
147
+ return ResponseEntity.status(HttpStatus.I_AM_A_TEAPOT).body("I'm a teapot");
148
+ }
149
+ }
150
+ ```
151
+
152
+ ### Error Handling
153
+
154
+ Use `ResponseStatusError` to handle bad control paths:
155
+
156
+ ```typescript
157
+ @Controller({ prefix: "/users" })
158
+ class UserController {
159
+ constructor(private readonly userService: UserService) {}
160
+
161
+ @GET("/:id")
162
+ async getUser(request: Request): ResponseEntity<User> {
163
+ const user = await this.userService.findById(request.params.id);
164
+
165
+ if (!user) {
166
+ throw new ResponseStatusError(HttpStatus.NOT_FOUND, "User not found");
167
+ }
168
+
169
+ return ResponseEntity.ok().body(user);
170
+ }
171
+ }
172
+ ```
173
+
174
+ Make sure to register an error handler middleware:
175
+
176
+ ```typescript
177
+ Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
178
+ res.status(err.status).json({ error: err.message });
179
+ });
180
+ ```
181
+
182
+ ### Middleware
183
+
184
+ Load Express middleware plugins using `@Middleware()`:
185
+
186
+ ```typescript
187
+ import { Controller, Middleware } from "@tahminator/sapling";
188
+ import cookieParser from "cookie-parser";
189
+ import { NextFunction, Request, Response } from "express";
190
+
191
+ @MiddlewareClass() // works the same as @Controller, semantically different
192
+ class CookieParserMiddleware {
193
+ private readonly plugin: ReturnType<typeof cookieParser>;
194
+
195
+ constructor() {
196
+ this.plugin = cookieParser();
197
+ }
198
+
199
+ @Middleware()
200
+ register(request: Request, response: Response, next: NextFunction) {
201
+ return this.plugin(request, response, next);
202
+ }
203
+ }
204
+
205
+ // Register it like any controller
206
+ app.use(Sapling.resolve(CookieParserMiddleware));
207
+
208
+ // You can also still choose to load plugins the Express.js way
209
+ app.use(cookieParser());
210
+ ```
211
+
212
+ ### Redirects
213
+
214
+ ```typescript
215
+ @Controller({ prefix: "/api" })
216
+ class RedirectController {
217
+ @GET("/old-route")
218
+ redirect() {
219
+ return RedirectView.redirect("/new-route");
220
+ }
221
+ }
222
+ ```
223
+
224
+ ### Dependency Injection
225
+
226
+ Mark services with `@Injectable()` and inject them into controllers:
227
+
228
+ ```typescript
229
+ @Injectable()
230
+ class UserService {
231
+ getUsers() { ... }
232
+ }
233
+
234
+ @Controller({
235
+ prefix: "/users",
236
+ deps: [UserService]
237
+ })
238
+ class UserController {
239
+ constructor(private readonly userService: UserService) {}
240
+
241
+ @GET()
242
+ getAll() {
243
+ return ResponseEntity.ok().body(this.userService.getUsers());
244
+ }
245
+ }
246
+ ```
247
+
248
+ Injectables can also depend on other injectables:
249
+
250
+ ```typescript
251
+ @Injectable()
252
+ class Database {
253
+ query() { ... }
254
+ }
255
+
256
+ @Injectable([Database])
257
+ class UserRepository {
258
+ constructor(private readonly db: Database) {}
259
+
260
+ findAll() {
261
+ return this.db.query("SELECT * FROM users");
262
+ }
263
+ }
264
+ ```
265
+
266
+ ### Custom Serialization
267
+
268
+ By default, Sapling uses `JSON.stringify` and `JSON.parse` for serialization. You can override these with custom serializers like [superjson](https://github.com/flightcontrolhq/superjson#readme) to automatically handle Dates, BigInts, and more:
269
+
270
+ ```typescript
271
+ import superjson from "superjson";
272
+
273
+ Sapling.setSerializeFn(superjson.stringify);
274
+ Sapling.setDeserializeFn(superjson.parse);
275
+ ```
276
+
277
+ This affects how `ResponseEntity` serializes response bodies and how request bodies are deserialized.
278
+
279
+ ## License
280
+
281
+ MIT
@@ -1,3 +1,4 @@
1
1
  export * from "./controller";
2
2
  export * from "./injectable";
3
3
  export * from "./route";
4
+ export * from "./middleware";
@@ -1,3 +1,4 @@
1
1
  export * from "./controller";
2
2
  export * from "./injectable";
3
3
  export * from "./route";
4
+ export * from "./middleware";
@@ -0,0 +1,9 @@
1
+ import { Controller } from "./controller";
2
+ /**
3
+ * Used to define a middleware-only class.
4
+ *
5
+ * __NOTE:__ `@MiddlewareClass` works exactly the same as `@Controller`. As such, you
6
+ * can still register `@Route` and `@Middleware` methods, though you very well should not
7
+ * for the sake of semantics.
8
+ */
9
+ export declare function MiddlewareClass(...args: Parameters<typeof Controller>): ClassDecorator;
@@ -0,0 +1,11 @@
1
+ import { Controller } from "./controller";
2
+ /**
3
+ * Used to define a middleware-only class.
4
+ *
5
+ * __NOTE:__ `@MiddlewareClass` works exactly the same as `@Controller`. As such, you
6
+ * can still register `@Route` and `@Middleware` methods, though you very well should not
7
+ * for the sake of semantics.
8
+ */
9
+ export function MiddlewareClass(...args) {
10
+ return Controller(...args);
11
+ }
@@ -8,7 +8,7 @@ export function _Route({ method, path = "", }) {
8
8
  var _a;
9
9
  const ctor = target.constructor;
10
10
  const list = (_a = _routeStore.get(ctor)) !== null && _a !== void 0 ? _a : [];
11
- list.push({ method, path, fnName: String(propertyKey) });
11
+ list.push({ method, path: path !== null && path !== void 0 ? path : "", fnName: String(propertyKey) });
12
12
  _routeStore.set(ctor, list);
13
13
  };
14
14
  }
package/package.json CHANGED
@@ -1,20 +1,31 @@
1
1
  {
2
2
  "name": "@tahminator/sapling",
3
- "version": "1.5.0",
3
+ "version": "1.5.3",
4
+ "author": "Tahmid Ahmed",
4
5
  "description": "A library to help you write cleaner Express.js code",
6
+ "repository": {
7
+ "url": "git+https://github.com/tahminator/sapling.git"
8
+ },
5
9
  "scripts": {
6
- "test": "echo \"Error: no test specified\" && exit 1",
10
+ "test": "pnpm run build",
7
11
  "build": "rm -rf dist && tsc"
8
12
  },
9
- "keywords": [],
10
- "author": "",
11
- "license": "ISC",
12
- "packageManager": "pnpm@10.12.4",
13
- "main": "dist/index.js",
14
- "types": "dist/index.d.ts",
13
+ "main": "./dist/index.js",
14
+ "module": "./dist/index.js",
15
+ "types": "./dist/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "require": "./dist/index.js"
21
+ }
22
+ },
15
23
  "files": [
16
24
  "/dist"
17
25
  ],
26
+ "keywords": [],
27
+ "license": "ISC",
28
+ "packageManager": "pnpm@10.12.4",
18
29
  "dependencies": {
19
30
  "@types/express": "^5.0.6",
20
31
  "express": "^5.2.1"