@tahminator/sapling 0.0.0
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 +21 -0
- package/README.md +384 -0
- package/dist/index.cjs +755 -0
- package/dist/index.d.cts +521 -0
- package/dist/index.d.mts +521 -0
- package/dist/index.mjs +701 -0
- package/package.json +64 -0
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,384 @@
|
|
|
1
|
+
# [`@tahminator/sapling`](https://www.npmjs.com/package/@tahminator/sapling)
|
|
2
|
+
|
|
3
|
+
[](https://sonarcloud.io/dashboard?id=tahminator_sapling)
|
|
4
|
+
[](https://www.npmjs.com/package/@tahminator/sapling)
|
|
5
|
+
|
|
6
|
+
A lightweight Express.js dependency injection & route abstraction library.
|
|
7
|
+
|
|
8
|
+
## Table of Contents
|
|
9
|
+
|
|
10
|
+
<!-- toc -->
|
|
11
|
+
|
|
12
|
+
- [Why?](#why)
|
|
13
|
+
- [Examples](#examples)
|
|
14
|
+
- [Install](#install)
|
|
15
|
+
- [Quick Start](#quick-start)
|
|
16
|
+
- [Features](#features)
|
|
17
|
+
* [Controllers](#controllers)
|
|
18
|
+
* [HTTP Methods](#http-methods)
|
|
19
|
+
* [Responses](#responses)
|
|
20
|
+
* [Error Handling](#error-handling)
|
|
21
|
+
* [Middleware](#middleware)
|
|
22
|
+
* [Redirects](#redirects)
|
|
23
|
+
* [Dependency Injection](#dependency-injection)
|
|
24
|
+
* [Custom Serialization](#custom-serialization)
|
|
25
|
+
- [Advanced Setup](#advanced-setup)
|
|
26
|
+
* [Automatically import controllers](#automatically-import-controllers)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
<!-- tocstop -->
|
|
30
|
+
|
|
31
|
+
<!-- if toc does not update automatically, `markdown-toc -i README.md` -->
|
|
32
|
+
|
|
33
|
+
## Why?
|
|
34
|
+
|
|
35
|
+
1. 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.
|
|
36
|
+
|
|
37
|
+
2. Sapling is inspired by Spring, but without losing the developer experience, speed & simplicity of Express.js / TypeScript.
|
|
38
|
+
- 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.
|
|
39
|
+
|
|
40
|
+
3. 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.
|
|
41
|
+
|
|
42
|
+
## Examples
|
|
43
|
+
|
|
44
|
+
Check the [`/example`](./example) folder for a basic todo app with database integration.
|
|
45
|
+
|
|
46
|
+
Sapling is also powering one of my more complex projects with 660+ users in production, which you can view at [instalock-web/apps/server](https://github.com/tahminator/instalock-web/blob/main/apps/server/src/index.ts).
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# we <3 pnpm
|
|
52
|
+
pnpm install @tahminator/sapling
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quick Start
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
import express from "express";
|
|
59
|
+
import { Sapling, Controller, GET, POST, ResponseEntity, Class, HttpStatus, MiddlewareClass, Middleware } from "@tahminator/sapling";
|
|
60
|
+
|
|
61
|
+
@MiddlewareClass()
|
|
62
|
+
class LoggingMiddleware {
|
|
63
|
+
@Middleware()
|
|
64
|
+
loggingMiddleware(
|
|
65
|
+
request: express.Request,
|
|
66
|
+
response: express.Response,
|
|
67
|
+
next: express.NextFunction,
|
|
68
|
+
): void {
|
|
69
|
+
console.log(request.path);
|
|
70
|
+
next();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@Controller({ prefix: "/api" })
|
|
75
|
+
class HelloController {
|
|
76
|
+
@GET()
|
|
77
|
+
getHello(): ResponseEntity<string> {
|
|
78
|
+
return ResponseEntity.ok().body("Hello world");
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Controller({ prefix: "/api/users" })
|
|
83
|
+
class UserController {
|
|
84
|
+
@GET()
|
|
85
|
+
getUsers(): ResponseEntity<string[]> {
|
|
86
|
+
return ResponseEntity.ok().body(["Alice", "Bob"]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@POST()
|
|
90
|
+
createUser(): ResponseEntity<{ success: boolean }> {
|
|
91
|
+
return ResponseEntity.status(HttpStatus.CREATED).body({ success: true });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// you still have full access of app to do whatever you want!
|
|
96
|
+
const app = express();
|
|
97
|
+
Sapling.registerApp(app);
|
|
98
|
+
|
|
99
|
+
// @MiddlewareClass should be registered first before @Controller and should be registered in order
|
|
100
|
+
// @Injectable classes will automatically be formed into singletons by Sapling behind the scenes!
|
|
101
|
+
const middlewares: Class<any>[] = [LoggingMiddleware];
|
|
102
|
+
middlewares.map(Sapling.resolve).forEach((r) => app.use(r));
|
|
103
|
+
|
|
104
|
+
// @Controller can be registered in any order.
|
|
105
|
+
// @Injectable classes will automatically be formed into singletons by Sapling behind the scenes!
|
|
106
|
+
const controllers: Class<any>[] = [HelloController, UserController];
|
|
107
|
+
controllers.map(Sapling.resolve).forEach((r) => app.use(r));
|
|
108
|
+
|
|
109
|
+
app.listen(3000);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Hit `GET /api` for "Hello world" or `GET /api/users` for the user list.
|
|
113
|
+
|
|
114
|
+
## Features
|
|
115
|
+
|
|
116
|
+
### Controllers
|
|
117
|
+
|
|
118
|
+
Use `@Controller()` to mark a class as a controller. Routes inside get a shared prefix if you want:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
@Controller({ prefix: "/users" })
|
|
122
|
+
class UserController {
|
|
123
|
+
@GET("/:id")
|
|
124
|
+
getUser() {
|
|
125
|
+
/* ... */
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@POST()
|
|
129
|
+
createUser() {
|
|
130
|
+
/* ... */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### HTTP Methods
|
|
136
|
+
|
|
137
|
+
Sapling supports the usual suspects:
|
|
138
|
+
|
|
139
|
+
- `@GET(path?)`
|
|
140
|
+
- `@POST(path?)`
|
|
141
|
+
- `@PUT(path?)`
|
|
142
|
+
- `@DELETE(path?)`
|
|
143
|
+
- `@PATCH(path?)`
|
|
144
|
+
- `@Middleware(path?)` - for middleware
|
|
145
|
+
|
|
146
|
+
Path defaults to `"/"` if you don't pass one.
|
|
147
|
+
|
|
148
|
+
### Responses
|
|
149
|
+
|
|
150
|
+
`ResponseEntity` gives you a builder pattern for responses:
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
@Controller({ prefix: "/users" })
|
|
154
|
+
class UserController {
|
|
155
|
+
@GET("/:id")
|
|
156
|
+
getUser(): ResponseEntity<User> {
|
|
157
|
+
const user = findUser(id);
|
|
158
|
+
return ResponseEntity.ok().setHeader("X-Custom", "value").body(user);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
For status codes you can use `.ok()` (200) or `.status()` to define a specific status with the `HttpStatus` enum.
|
|
164
|
+
|
|
165
|
+
You can also set custom status codes:
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
@Controller({ prefix: "/api" })
|
|
169
|
+
class CustomController {
|
|
170
|
+
@GET("/teapot")
|
|
171
|
+
teapot(): ResponseEntity<string> {
|
|
172
|
+
return ResponseEntity.status(HttpStatus.I_AM_A_TEAPOT).body("I'm a teapot");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Error Handling
|
|
178
|
+
|
|
179
|
+
Use `ResponseStatusError` to handle bad control paths:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
@Controller({ prefix: "/users" })
|
|
183
|
+
class UserController {
|
|
184
|
+
constructor(private readonly userService: UserService) {}
|
|
185
|
+
|
|
186
|
+
@GET("/:id")
|
|
187
|
+
async getUser(request: Request): ResponseEntity<User> {
|
|
188
|
+
const user = await this.userService.findById(request.params.id);
|
|
189
|
+
|
|
190
|
+
if (!user) {
|
|
191
|
+
throw new ResponseStatusError(HttpStatus.NOT_FOUND, "User not found");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return ResponseEntity.ok().body(user);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Make sure to register an error handler middleware:
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
Sapling.loadResponseStatusErrorMiddleware(app, (err, req, res, next) => {
|
|
203
|
+
res.status(err.status).json({ error: err.message });
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Middleware
|
|
208
|
+
|
|
209
|
+
Load Express middleware plugins using `@Middleware()`:
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { MiddlewareClass, Middleware } from "@tahminator/sapling";
|
|
213
|
+
import cookieParser from "cookie-parser";
|
|
214
|
+
import { NextFunction, Request, Response } from "express";
|
|
215
|
+
|
|
216
|
+
@MiddlewareClass() // @MiddlewareClass is an alias of @Controller, provides better semantics
|
|
217
|
+
class CookieParserMiddleware {
|
|
218
|
+
private readonly plugin: ReturnType<typeof cookieParser>;
|
|
219
|
+
|
|
220
|
+
constructor() {
|
|
221
|
+
this.plugin = cookieParser();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
@Middleware()
|
|
225
|
+
register(request: Request, response: Response, next: NextFunction) {
|
|
226
|
+
return this.plugin(request, response, next);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Register it like any controller
|
|
231
|
+
app.use(Sapling.resolve(CookieParserMiddleware));
|
|
232
|
+
|
|
233
|
+
// You can also still choose to load plugins the Express.js way
|
|
234
|
+
app.use(cookieParser());
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Redirects
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
@Controller({ prefix: "/api" })
|
|
241
|
+
class RedirectController {
|
|
242
|
+
@GET("/old-route")
|
|
243
|
+
redirect() {
|
|
244
|
+
return RedirectView.redirect("/new-route");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Dependency Injection
|
|
250
|
+
|
|
251
|
+
Mark services with `@Injectable()` and inject them into controllers:
|
|
252
|
+
|
|
253
|
+
```typescript
|
|
254
|
+
@Injectable()
|
|
255
|
+
class UserService {
|
|
256
|
+
getUsers() { ... }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
@Controller({
|
|
260
|
+
prefix: "/users",
|
|
261
|
+
deps: [UserService]
|
|
262
|
+
})
|
|
263
|
+
class UserController {
|
|
264
|
+
constructor(private readonly userService: UserService) {}
|
|
265
|
+
|
|
266
|
+
@GET()
|
|
267
|
+
getAll() {
|
|
268
|
+
return ResponseEntity.ok().body(this.userService.getUsers());
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Injectables can also depend on other injectables:
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
@Injectable()
|
|
277
|
+
class Database {
|
|
278
|
+
query() { ... }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
@Injectable([Database])
|
|
282
|
+
class UserRepository {
|
|
283
|
+
constructor(private readonly db: Database) {}
|
|
284
|
+
|
|
285
|
+
findAll() {
|
|
286
|
+
return this.db.query("SELECT * FROM users");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### Custom Serialization
|
|
292
|
+
|
|
293
|
+
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:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
import superjson from "superjson";
|
|
297
|
+
|
|
298
|
+
Sapling.setSerializeFn(superjson.stringify);
|
|
299
|
+
Sapling.setDeserializeFn(superjson.parse);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
This affects how `ResponseEntity` serializes response bodies and how request bodies are deserialized.
|
|
303
|
+
|
|
304
|
+
## Advanced Setup
|
|
305
|
+
|
|
306
|
+
### Automatically import controllers
|
|
307
|
+
|
|
308
|
+
> [!NOTE]
|
|
309
|
+
> You need ESLint (or some alternative build step that has glob-import support)
|
|
310
|
+
|
|
311
|
+
Controllers can be automatically imported via a glob-import if you ensure that all controller files are:
|
|
312
|
+
|
|
313
|
+
- `export default` (so one controller per file)
|
|
314
|
+
- all controller files are marked as `*.controller.ts`
|
|
315
|
+
|
|
316
|
+
. The steps below indicate a working example inside of my webapp, [instalock-web/apps/server](https://github.com/tahminator/instalock-web/blob/main/apps/server/src/index.ts).
|
|
317
|
+
|
|
318
|
+
1. Create a bootstrap file that will glob-import all controller files and export them
|
|
319
|
+
|
|
320
|
+
[example file](https://github.com/tahminator/instalock-web/blob/main/apps/server/src/bootstrap.ts)
|
|
321
|
+
|
|
322
|
+
```ts
|
|
323
|
+
// file will automatically import any controller files
|
|
324
|
+
// it will pull out default exports, so ensure
|
|
325
|
+
// 1. one class per file
|
|
326
|
+
// 2. `export default XyzController`
|
|
327
|
+
|
|
328
|
+
// q: wont this break ordering of controller imports?
|
|
329
|
+
// a: yes but that's ok - controllers are the last in the dependency graph.
|
|
330
|
+
// they import, but are never imported themselves.
|
|
331
|
+
|
|
332
|
+
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
|
333
|
+
/* eslint-disable @typescript-eslint/no-unsafe-return */
|
|
334
|
+
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
|
335
|
+
// @ts-nocheck
|
|
336
|
+
|
|
337
|
+
import { modules } from "./controller/**/{controller,*.controller}.ts#default";
|
|
338
|
+
|
|
339
|
+
export const getControllers = (): Class<unknown>[] => {
|
|
340
|
+
return modules.map((mod) => mod.default as Class<unknown>);
|
|
341
|
+
};
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
1. Point `Sapling.resolve` to `getControllers`
|
|
345
|
+
|
|
346
|
+
[example file](https://github.com/tahminator/instalock-web/blob/main/apps/server/src/index.ts#L45-L47)
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
const controllers = getControllers();
|
|
350
|
+
console.log(`${controllers.length} controllers resolved`);
|
|
351
|
+
controllers.map(Sapling.resolve).forEach((r) => app.use(r));
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
1. Configure your ESBuild process to use the `esbuild-plugin-import-pattern` plugin
|
|
355
|
+
|
|
356
|
+
[example file](https://github.com/tahminator/instalock-web/blob/main/apps/server/build.ts)
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
import * as esbuild from "esbuild";
|
|
360
|
+
// @ts-expect-error no types
|
|
361
|
+
import { importPatternPlugin } from "esbuild-plugin-import-pattern";
|
|
362
|
+
|
|
363
|
+
async function main() {
|
|
364
|
+
const ctx = await esbuild.context({
|
|
365
|
+
entryPoints: ["src/index.ts"],
|
|
366
|
+
bundle: true,
|
|
367
|
+
sourcemap: true,
|
|
368
|
+
platform: "node",
|
|
369
|
+
outfile: "src/index.js",
|
|
370
|
+
logLevel: "info",
|
|
371
|
+
format: "cjs",
|
|
372
|
+
plugins: [importPatternPlugin()],
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
await ctx.rebuild();
|
|
376
|
+
await ctx.dispose();
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
void main();
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
## License
|
|
383
|
+
|
|
384
|
+
MIT
|