@tulipes/core 0.1.0 → 0.1.1
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/README.md +283 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# @tulipes/core
|
|
2
|
+
|
|
3
|
+
**A module-based Express framework: convention-driven boot pipeline, declarative env contracts, ACL, MongoDB, queues, sockets, and a codegen CLI — fail-closed by design.**
|
|
4
|
+
|
|
5
|
+
Tulipes exists so you never rewrite Express boilerplate again. You write **modules** — self-contained feature folders with routes, models, queues, sockets, and a declared environment contract — and the framework discovers, validates, orders, and wires them into a running API. Everything that can be checked at boot *is* checked at boot, and every failure is reported **in aggregate**: ten missing env variables means one crash listing ten problems, not ten restarts.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
// app.ts — the whole entrypoint
|
|
9
|
+
import { boot } from "@tulipes/core/boot";
|
|
10
|
+
|
|
11
|
+
await boot({ rootDir: import.meta.dirname, mode: "web" });
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
▲ my-api development · web mode
|
|
16
|
+
|
|
17
|
+
➜ Server http://localhost:4000 ● listening
|
|
18
|
+
➜ Database mongodb://127.0.0.1:27017/my-api ● connected
|
|
19
|
+
➜ Queues 1 (users.welcome) · 1 processor(s) registered
|
|
20
|
+
➜ Sockets /users
|
|
21
|
+
➜ Modules 4 (core*, security*, auth, users) · * sys tier
|
|
22
|
+
➜ Roles 2 (admin, user)
|
|
23
|
+
➜ Runtime Node v24.14.0 · PID 43054 · ready in 76ms
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Why Tulipes
|
|
29
|
+
|
|
30
|
+
- **Modules, not layers.** A feature lives in one folder: its routes, models, config, permissions, background jobs, and env contract. Delete the folder, the feature is gone.
|
|
31
|
+
- **Fail-closed everywhere.** Missing env vars, dependency cycles, duplicate ACL grants, colliding queue names — all crash the boot with a full report. Reading an undeclared variable or model throws at the call site. Unknown roles deny. Unrouted requests 404. Unexpected errors never leak internals outside development.
|
|
32
|
+
- **One process, two modes.** The same codebase boots as a `web` server or a queue-consuming `worker` — same modules, same config, forked at the last pipeline phase.
|
|
33
|
+
- **Types generated, not hand-written.** `tulipes sync` reads your modules and emits a `config.d.ts` that types every env variable and every config namespace — enum variables become literal unions, config shapes are inferred from your factories' return types.
|
|
34
|
+
- **The framework stays out of your request path.** Core mounts *no* middleware. Your sys-tier modules own the pipeline head; core only guarantees the tail (404 + terminal error handler).
|
|
35
|
+
|
|
36
|
+
## Requirements
|
|
37
|
+
|
|
38
|
+
- Node.js ≥ 20.11 (ESM, `import.meta.dirname`)
|
|
39
|
+
- Yarn workspaces (modules are workspace packages)
|
|
40
|
+
- MongoDB and Redis, when your modules use models/queues
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
yarn add @tulipes/core
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Application layout
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
my-api/
|
|
52
|
+
├── app.ts # boot({ mode: "web" })
|
|
53
|
+
├── worker.ts # boot({ mode: "worker" })
|
|
54
|
+
├── config/
|
|
55
|
+
│ └── app.config.ts # optional: seeds global config before any module
|
|
56
|
+
├── lib/ # your cross-cutting glue (cache, mongoose plugins…)
|
|
57
|
+
├── modules/ # ← the application
|
|
58
|
+
│ ├── core/ # sys tier: infra env vars, roles, global middleware
|
|
59
|
+
│ ├── security/ # sys tier: headers, logging, body parsing
|
|
60
|
+
│ └── users/ # app tier: a feature
|
|
61
|
+
├── types/config.d.ts # GENERATED — tulipes sync
|
|
62
|
+
├── .env.example # GENERATED — tulipes sync
|
|
63
|
+
├── .envs/
|
|
64
|
+
│ └── .env.development # per-mode env files, picked by APP_ENV
|
|
65
|
+
└── package.json # "workspaces": ["modules/*"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Module anatomy
|
|
69
|
+
|
|
70
|
+
Every module is a yarn workspace package under `modules/`. Every file below is optional except `package.json`.
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
modules/users/
|
|
74
|
+
├── package.json # name + "tulipes" ordering key
|
|
75
|
+
├── meta.variables.json # the module's env contract
|
|
76
|
+
├── module.config.ts # config factory + onReady/onShutdown hooks
|
|
77
|
+
├── module.acl.ts # permission grants
|
|
78
|
+
├── routes/*.routes.ts # (ctx) => Router
|
|
79
|
+
├── models/*.model.ts # { name, schema }
|
|
80
|
+
├── bootstrap/*.bootstrap.ts# post-DB, pre-server tasks (indexes, seeds)
|
|
81
|
+
├── queues/*.queues.ts # (ctx, queues) => define + process
|
|
82
|
+
├── sockets/*.sockets.ts # (ctx, sockets) => claim namespaces
|
|
83
|
+
└── controllers/ # convention only — routes import them directly
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"name": "@app/users",
|
|
89
|
+
"tulipes": { "tier": "app", "priority": 10, "dependsOn": ["auth"] },
|
|
90
|
+
"dependencies": { "@app/auth": "workspace:*" }
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Load order** is three sort keys: tier (`sys` before `app`) → `priority` (lower first, default 100) → topological sort on `dependsOn`. Cycles, unknown dependencies, and a sys module depending on an app module all crash the boot. Cross-module imports go through package names, so the package manager enforces that a module only uses what it declares.
|
|
95
|
+
|
|
96
|
+
## The environment contract
|
|
97
|
+
|
|
98
|
+
Each module declares its variables in `meta.variables.json` — plain JSON so tooling can read it without executing code:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"variables": [
|
|
103
|
+
{
|
|
104
|
+
"name": "USERS_SIGNUP_MODE",
|
|
105
|
+
"type": "enum",
|
|
106
|
+
"enum": ["open", "invite", "closed"],
|
|
107
|
+
"group": "signup",
|
|
108
|
+
"description": "Who can register",
|
|
109
|
+
"default": "open"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"name": "USERS_ADMIN_EMAIL",
|
|
113
|
+
"type": "string",
|
|
114
|
+
"required": true,
|
|
115
|
+
"description": "Seeded admin account email"
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Field | Rules |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `name` | UPPER_SNAKE. **One owner per variable across the app** — two modules declaring the same name is a boot crash naming both. Shared infra vars (`MONGO_URI`, `REDIS_URL`, `PORT`) belong to your sys core module. |
|
|
124
|
+
| `type` | `string` · `number` · `boolean` · `enum` · `url` · `secret` (redacted in every log, banner, and report) |
|
|
125
|
+
| `enum` | required with `type: "enum"` |
|
|
126
|
+
| `required` | no resolved value → boot crash; mutually exclusive with `default` |
|
|
127
|
+
| `default` | used when nothing else provides a value |
|
|
128
|
+
| `group` | free-form label; drives section grouping in the generated `.env.example` |
|
|
129
|
+
|
|
130
|
+
Resolution precedence: **`process.env` › `.envs/.env.<APP_ENV>` › `default`** — container-injected values always win. The mode comes from `APP_ENV` (`development` / `staging` / `production` / `test`), deliberately not `NODE_ENV` (core mirrors a sane `NODE_ENV` for third-party libraries).
|
|
131
|
+
|
|
132
|
+
Values are validated and coerced (`"5"` → `5`, `"true"` → `true`, enum membership, URL shape). Reads are typed after codegen and fail-closed always:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
ctx.Environment.get("USERS_SIGNUP_MODE"); // "open" | "invite" | "closed"
|
|
136
|
+
ctx.Environment.get("TYPO_NAME"); // throws: never declared by any module
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## The boot pipeline
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
1. Environment load .env.<APP_ENV>, every meta.variables.json → validate (aggregate crash)
|
|
143
|
+
2. Module graph read "tulipes" keys → tier/priority/topo sort (cycles crash)
|
|
144
|
+
3. Exploration resolve conventional file paths, load order, no imports yet
|
|
145
|
+
4. Global config config/app.config.ts seed → each module.config.ts factory,
|
|
146
|
+
in load order (each factory sees its dependencies' config)
|
|
147
|
+
5. ACL merge module.acl.ts in load order (conflicts crash)
|
|
148
|
+
6. Database connect Mongoose → model store → bootstrap tasks
|
|
149
|
+
7. Queues every *.queues.ts registers in BOTH modes
|
|
150
|
+
8. ── mode fork ──
|
|
151
|
+
web module routers (load order) → 404 → error handler → listen
|
|
152
|
+
worker start a BullMQ worker per registered processor
|
|
153
|
+
9. onReady hooks in load order → startup banner
|
|
154
|
+
shutdown SIGTERM/SIGINT → onShutdown hooks in REVERSE order →
|
|
155
|
+
disconnect sockets → close http → drain queues → close redis → close mongo
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Each phase crashes with its own aggregate report before the next begins. In dev the pipeline discovers `.ts` files (run under tsx); compiled deployments discover `.js` — detected automatically.
|
|
159
|
+
|
|
160
|
+
## Contracts
|
|
161
|
+
|
|
162
|
+
Everything a module exports receives the shared context:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
interface Ctx {
|
|
166
|
+
Environment: Environment; // typed variable store
|
|
167
|
+
config: GlobalConfig; // namespaced by module: config.users
|
|
168
|
+
acl?: Acl; // after phase 5
|
|
169
|
+
models?: ModelStore; // after phase 6
|
|
170
|
+
queues?: QueueManager; // after phase 7
|
|
171
|
+
sockets?: SocketManager; // web only
|
|
172
|
+
app?: Express; // web only
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
// module.config.ts — namespace stored at config.<module>; hooks optional
|
|
178
|
+
export default function usersConfig({ Environment }: Ctx) {
|
|
179
|
+
return { maxSessions: Environment.get("USERS_MAX_SESSIONS") };
|
|
180
|
+
}
|
|
181
|
+
export async function onReady(ctx: Ctx) {}
|
|
182
|
+
export async function onShutdown(ctx: Ctx) {} // runs in reverse load order
|
|
183
|
+
|
|
184
|
+
// routes/users.routes.ts — return a Router, or mount on ctx.app directly
|
|
185
|
+
export default function usersRoutes({ config, models }: Ctx): Router {
|
|
186
|
+
const router = Router();
|
|
187
|
+
router.get("/users", async (_req, res) => {
|
|
188
|
+
res.json(await models!.get("User").find().lean());
|
|
189
|
+
});
|
|
190
|
+
return router;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// models/user.model.ts — pure definition, no factory
|
|
194
|
+
export default { name: "User", schema: userSchema } satisfies ModelDef;
|
|
195
|
+
|
|
196
|
+
// bootstrap/seed-admin.bootstrap.ts — post-DB, pre-server; make it idempotent
|
|
197
|
+
export default async function seedAdmin({ models, Environment }: Ctx) {}
|
|
198
|
+
|
|
199
|
+
// queues/users.queues.ts — ONE file for both process modes
|
|
200
|
+
export default function usersQueues(_ctx: Ctx, queues: QueueRegistry) {
|
|
201
|
+
queues.define("users.welcome"); // producers, every mode
|
|
202
|
+
queues.process("users.welcome", async (job) => {}); // consumed in worker mode
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// sockets/users.sockets.ts — claim a namespace exactly once
|
|
206
|
+
export default function usersSockets(_ctx: Ctx, sockets: SocketRegistry) {
|
|
207
|
+
sockets.namespace("/users", (nsp) => {
|
|
208
|
+
nsp.on("connection", (socket) => socket.emit("welcome"));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// module.acl.ts — roles are global (define in your sys module), grants are local
|
|
213
|
+
export default function usersAcl(acl: AclBuilder) {
|
|
214
|
+
acl.allow("user", "users:read"); // resources namespaced by module
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Producing a job from anywhere: `await ctx.queues.add("users.welcome", "welcome", { email })`.
|
|
219
|
+
Emitting from anywhere: `ctx.sockets.of("/users").emit("event", data)` — unclaimed namespaces throw.
|
|
220
|
+
|
|
221
|
+
## Global middleware
|
|
222
|
+
|
|
223
|
+
Core mounts nothing. Sys modules load before every app module, so a sys module's routes file mounting on `ctx.app` **is** the "before all routes" slot:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// modules/security/routes/security.routes.ts (tier: "sys")
|
|
227
|
+
export default function securityRoutes({ app }: Ctx): void {
|
|
228
|
+
app!.use(securityHeaders());
|
|
229
|
+
app!.use(pinoHttp({ /* … */ }));
|
|
230
|
+
app!.use(express.json({ limit: "1mb" }));
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Core guarantees only the fail-closed tail: a 404 for anything unrouted, and a terminal error handler that catches thrown/rejected async handlers. Modules signal errors with one class:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import { HttpError } from "@tulipes/core/http";
|
|
238
|
+
|
|
239
|
+
router.get("/users/:id", async (req) => {
|
|
240
|
+
const user = await findUser(req.params.id);
|
|
241
|
+
if (!user) throw new HttpError(404, "no such user", { id: req.params.id });
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`HttpError` renders `{ error, details? }` with its status. Anything else becomes an anonymous `500 internal server error` — full message only in `development`/`test`.
|
|
246
|
+
|
|
247
|
+
## The CLI
|
|
248
|
+
|
|
249
|
+
```
|
|
250
|
+
tulipes sync regenerate types/config.d.ts and .env.example
|
|
251
|
+
tulipes env:check validate env against every meta contract — CI gate, exit 1 on failure
|
|
252
|
+
tulipes new module <name> scaffold a module (scope auto-detected from siblings)
|
|
253
|
+
tulipes dev [entry] sync, then run the entry under tsx watch
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`sync` output — committed, marked generated:
|
|
257
|
+
|
|
258
|
+
- **`types/config.d.ts`** — augments `KnownVariables` (every variable typed from its spec) and `GlobalConfig` (each namespace typed as its factory's return type via `typeof import(...)` — inference, never execution).
|
|
259
|
+
- **`.env.example`** — every variable with description, type, `REQUIRED` markers, grouped by module and `group`.
|
|
260
|
+
|
|
261
|
+
## Subpath exports
|
|
262
|
+
|
|
263
|
+
| Import | Contents |
|
|
264
|
+
|---|---|
|
|
265
|
+
| `@tulipes/core` | everything below |
|
|
266
|
+
| `@tulipes/core/boot` | `boot`, `BootOptions`, `BootHandle`, `Ctx`, contract types |
|
|
267
|
+
| `@tulipes/core/env` | `Environment`, `KnownVariables` (codegen target), spec types |
|
|
268
|
+
| `@tulipes/core/http` | `HttpError`, `notFoundHandler`, `errorHandler` |
|
|
269
|
+
| `@tulipes/core/acl` | `Acl`, `AclBuilder`, `AclFn` |
|
|
270
|
+
| `@tulipes/core/db` | `ModelStore`, `ModelDef`, `BootstrapFn` |
|
|
271
|
+
| `@tulipes/core/queues` | `QueueManager`, `QueueRegistry`, `QueuesFn` |
|
|
272
|
+
| `@tulipes/core/sockets` | `SocketManager`, `SocketRegistry`, `SocketsFn` |
|
|
273
|
+
| `@tulipes/core/modules` | module graph, explorer, manifest schema |
|
|
274
|
+
| `@tulipes/core/config` | `GlobalConfig` (codegen target), config builder |
|
|
275
|
+
| `@tulipes/core/errors` | `TulipesBootError`, `BootReport` |
|
|
276
|
+
|
|
277
|
+
## The committed stack
|
|
278
|
+
|
|
279
|
+
Express 5 · Mongoose 8 · BullMQ 5 · ioredis 5 · Socket.IO 4 · Zod. Deliberately opinionated in v1 — a framework that wires everything can only do so by choosing.
|
|
280
|
+
|
|
281
|
+
## Well-known variables
|
|
282
|
+
|
|
283
|
+
Core reads three variable names when present (declare them in your sys core module): `PORT` (web listen port, default 3000), `MONGO_URI` (required if any module ships models), `REDIS_URL` (required if any module ships queues). No models and no `MONGO_URI` is a valid database-less app; models without `MONGO_URI` is a boot refusal.
|