@erwininteractive/mvc 0.4.2 → 0.6.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 +62 -0
- package/dist/cli.js +23 -1
- package/dist/framework/App.js +16 -0
- package/dist/generators/listRoutes.d.ts +22 -0
- package/dist/generators/listRoutes.js +103 -0
- package/package.json +1 -1
- package/templates/appScaffold/README.md +44 -4
- package/templates/appScaffold/package.json +3 -1
- package/templates/appScaffold/src/server.ts +5 -1
package/README.md
CHANGED
|
@@ -279,6 +279,24 @@ app.get("/protected", authenticate, (req, res) => {
|
|
|
279
279
|
});
|
|
280
280
|
```
|
|
281
281
|
|
|
282
|
+
### Auto-Inject User to Views
|
|
283
|
+
|
|
284
|
+
When a user is authenticated, their information is automatically available in all EJS templates via `res.locals.user`:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// In any EJS template
|
|
288
|
+
<% if (user) { %>
|
|
289
|
+
<p>Welcome, <%= user.email %></p>
|
|
290
|
+
<% } else { %>
|
|
291
|
+
<a href="/auth/login">Login</a>
|
|
292
|
+
<% } %>
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The middleware automatically:
|
|
296
|
+
- Extracts JWT from `req.cookies.token` or `Authorization` header
|
|
297
|
+
- Verifies the token using `JWT_SECRET`
|
|
298
|
+
- Sets `req.user` and `res.locals.user` for use in views
|
|
299
|
+
|
|
282
300
|
---
|
|
283
301
|
|
|
284
302
|
### WebAuthn (Passkeys)
|
|
@@ -528,6 +546,50 @@ NODE_ENV=development # Environment
|
|
|
528
546
|
|
|
529
547
|
---
|
|
530
548
|
|
|
549
|
+
## Escape Hatch Documentation
|
|
550
|
+
|
|
551
|
+
### Island Pattern (EJS + React)
|
|
552
|
+
|
|
553
|
+
Use EJS for layout while adding React components where needed:
|
|
554
|
+
|
|
555
|
+
```html
|
|
556
|
+
<!-- layout.ejs -->
|
|
557
|
+
<!DOCTYPE html>
|
|
558
|
+
<html>
|
|
559
|
+
<head><title><%= title %></title></head>
|
|
560
|
+
<body>
|
|
561
|
+
<header><%- include('partial/header') %></header>
|
|
562
|
+
|
|
563
|
+
<main id="app"><%- body %></main>
|
|
564
|
+
|
|
565
|
+
<!-- React island -->
|
|
566
|
+
<div id="comment-section" data-post-id="<%= post.id %>"></div>
|
|
567
|
+
|
|
568
|
+
<footer><%- include('partial/footer') %></footer>
|
|
569
|
+
|
|
570
|
+
<script type="module" src="/src/components/CommentSection.tsx"></script>
|
|
571
|
+
</body>
|
|
572
|
+
</html>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### API-Only Mode
|
|
576
|
+
|
|
577
|
+
Disable EJS engine for pure API responses:
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
const { app } = await createMvcApp({
|
|
581
|
+
viewsPath: "src/views",
|
|
582
|
+
disableViewEngine: true, // Don't setup EJS
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
app.get("/api/users", async (req, res) => {
|
|
586
|
+
const users = await prisma.user.findMany();
|
|
587
|
+
res.json(users); // Always JSON, no EJS
|
|
588
|
+
});
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
531
593
|
## Learn More
|
|
532
594
|
|
|
533
595
|
- [Express.js Documentation](https://expressjs.com/)
|
package/dist/cli.js
CHANGED
|
@@ -7,11 +7,19 @@ const generateModel_1 = require("./generators/generateModel");
|
|
|
7
7
|
const generateController_1 = require("./generators/generateController");
|
|
8
8
|
const generateResource_1 = require("./generators/generateResource");
|
|
9
9
|
const generateWebAuthn_1 = require("./generators/generateWebAuthn");
|
|
10
|
+
const listRoutes_1 = require("./generators/listRoutes");
|
|
10
11
|
const program = new commander_1.Command();
|
|
11
12
|
program
|
|
12
13
|
.name("erwinmvc")
|
|
13
14
|
.description("CLI for @erwininteractive/mvc framework")
|
|
14
|
-
.version("0.2.0")
|
|
15
|
+
.version("0.2.0")
|
|
16
|
+
.addHelpText("after", `
|
|
17
|
+
Examples:
|
|
18
|
+
$ erwinmvc init myapp Create a new app
|
|
19
|
+
$ erwinmvc generate resource Post Generate CRUD for Post
|
|
20
|
+
$ erwinmvc webauthn Setup passkey authentication
|
|
21
|
+
$ erwinmvc list:routes Show all routes
|
|
22
|
+
`);
|
|
15
23
|
// Init command - scaffold a new application
|
|
16
24
|
program
|
|
17
25
|
.command("init <dir>")
|
|
@@ -94,4 +102,18 @@ program
|
|
|
94
102
|
process.exit(1);
|
|
95
103
|
}
|
|
96
104
|
});
|
|
105
|
+
// List routes command - shows all defined routes
|
|
106
|
+
program
|
|
107
|
+
.command("list:routes")
|
|
108
|
+
.alias("lr")
|
|
109
|
+
.description("List all routes in the application")
|
|
110
|
+
.action(async () => {
|
|
111
|
+
try {
|
|
112
|
+
(0, listRoutes_1.listRoutes)();
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
97
119
|
program.parse();
|
package/dist/framework/App.js
CHANGED
|
@@ -12,6 +12,7 @@ const redis_1 = require("redis");
|
|
|
12
12
|
const connect_redis_1 = __importDefault(require("connect-redis"));
|
|
13
13
|
const helmet_1 = __importDefault(require("helmet"));
|
|
14
14
|
const cors_1 = __importDefault(require("cors"));
|
|
15
|
+
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
|
15
16
|
const dotenv_1 = __importDefault(require("dotenv"));
|
|
16
17
|
const path_1 = __importDefault(require("path"));
|
|
17
18
|
// Load environment variables
|
|
@@ -74,6 +75,21 @@ async function createMvcApp(options = {}) {
|
|
|
74
75
|
// View engine
|
|
75
76
|
app.set("view engine", "ejs");
|
|
76
77
|
app.set("views", path_1.default.resolve(viewsPath));
|
|
78
|
+
// Auto-inject authenticated user into views via res.locals
|
|
79
|
+
app.use((req, res, next) => {
|
|
80
|
+
try {
|
|
81
|
+
const token = req.cookies?.token || req.headers.authorization?.split(" ")[1];
|
|
82
|
+
if (token) {
|
|
83
|
+
const decoded = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET);
|
|
84
|
+
req.user = decoded;
|
|
85
|
+
res.locals.user = decoded;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Token invalid or expired
|
|
90
|
+
}
|
|
91
|
+
next();
|
|
92
|
+
});
|
|
77
93
|
// Add respond helper to response
|
|
78
94
|
app.use((req, res, next) => {
|
|
79
95
|
res.respond = function (viewName, data) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface RouteInfo {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
handler: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse routes from server.ts file
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseRoutes(serverPath: string): RouteInfo[];
|
|
10
|
+
/**
|
|
11
|
+
* Format routes as a table
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatRoutes(routes: RouteInfo[]): string;
|
|
14
|
+
/**
|
|
15
|
+
* Get server.ts path from current directory
|
|
16
|
+
*/
|
|
17
|
+
export declare function getServerPath(cwd?: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* List all routes in the current project
|
|
20
|
+
*/
|
|
21
|
+
export declare function listRoutes(cwd?: string): void;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseRoutes = parseRoutes;
|
|
7
|
+
exports.formatRoutes = formatRoutes;
|
|
8
|
+
exports.getServerPath = getServerPath;
|
|
9
|
+
exports.listRoutes = listRoutes;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
/**
|
|
13
|
+
* Parse routes from server.ts file
|
|
14
|
+
*/
|
|
15
|
+
function parseRoutes(serverPath) {
|
|
16
|
+
const routes = [];
|
|
17
|
+
if (!fs_1.default.existsSync(serverPath)) {
|
|
18
|
+
return routes;
|
|
19
|
+
}
|
|
20
|
+
const content = fs_1.default.readFileSync(serverPath, "utf-8");
|
|
21
|
+
const lines = content.split("\n");
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
// Match app.METHOD("/path", handler)
|
|
24
|
+
const routeMatch = line.match(/app\.(\w+)\s*\(\s*["']([^"']+)["']/);
|
|
25
|
+
if (routeMatch) {
|
|
26
|
+
const currentMethod = routeMatch[1].toLowerCase();
|
|
27
|
+
const currentPath = routeMatch[2];
|
|
28
|
+
let currentHandler = "";
|
|
29
|
+
// Extract handler name - look for arrow function or identifier
|
|
30
|
+
const arrowFuncMatch = line.match(/=>\s*\{/);
|
|
31
|
+
if (arrowFuncMatch) {
|
|
32
|
+
currentHandler = "<anonymous>";
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const handlerMatch = line.match(/,\s*([^\s,)]+)/);
|
|
36
|
+
if (handlerMatch) {
|
|
37
|
+
currentHandler = handlerMatch[1].trim();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
currentHandler = "<anonymous>";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
routes.push({
|
|
44
|
+
method: currentMethod.toUpperCase(),
|
|
45
|
+
path: currentPath,
|
|
46
|
+
handler: currentHandler,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return routes;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format routes as a table
|
|
54
|
+
*/
|
|
55
|
+
function formatRoutes(routes) {
|
|
56
|
+
if (routes.length === 0) {
|
|
57
|
+
return "No routes found.";
|
|
58
|
+
}
|
|
59
|
+
const headers = ["Method", "Path", "Handler"];
|
|
60
|
+
const colWidths = [6, 20, 30];
|
|
61
|
+
const formatRow = (cells) => {
|
|
62
|
+
return cells.map((cell, i) => cell.padEnd(colWidths[i])).join(" | ");
|
|
63
|
+
};
|
|
64
|
+
const separator = colWidths.map(w => "-".repeat(w)).join("-+-");
|
|
65
|
+
let output = "";
|
|
66
|
+
output += formatRow(headers) + "\n";
|
|
67
|
+
output += separator + "\n";
|
|
68
|
+
for (const route of routes) {
|
|
69
|
+
output += formatRow([
|
|
70
|
+
route.method,
|
|
71
|
+
route.path,
|
|
72
|
+
route.handler,
|
|
73
|
+
]) + "\n";
|
|
74
|
+
}
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get server.ts path from current directory
|
|
79
|
+
*/
|
|
80
|
+
function getServerPath(cwd = process.cwd()) {
|
|
81
|
+
const possiblePaths = [
|
|
82
|
+
path_1.default.join(cwd, "src", "server.ts"),
|
|
83
|
+
path_1.default.join(cwd, "server.ts"),
|
|
84
|
+
];
|
|
85
|
+
for (const p of possiblePaths) {
|
|
86
|
+
if (fs_1.default.existsSync(p)) {
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return possiblePaths[0];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* List all routes in the current project
|
|
94
|
+
*/
|
|
95
|
+
function listRoutes(cwd = process.cwd()) {
|
|
96
|
+
const serverPath = getServerPath(cwd);
|
|
97
|
+
const routes = parseRoutes(serverPath);
|
|
98
|
+
console.log("\nDefined Routes:\n");
|
|
99
|
+
console.log(formatRoutes(routes));
|
|
100
|
+
if (routes.length === 0) {
|
|
101
|
+
console.log("\nNo routes found. Add routes to src/server.ts");
|
|
102
|
+
}
|
|
103
|
+
}
|
package/package.json
CHANGED
|
@@ -76,6 +76,8 @@ Create `.ejs` files in `src/views/`. EJS lets you use JavaScript in your HTML:
|
|
|
76
76
|
<%- include('partials/header') %>
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
**Note:** When using authentication, the `user` object is automatically available in all views when a user is logged in. No need to pass it manually!
|
|
80
|
+
|
|
79
81
|
### Adding Routes
|
|
80
82
|
|
|
81
83
|
Edit `src/server.ts` to add routes:
|
|
@@ -201,6 +203,32 @@ Then run migrations again:
|
|
|
201
203
|
npx prisma migrate dev --name add-post-fields
|
|
202
204
|
```
|
|
203
205
|
|
|
206
|
+
## Full-Stack Flexibility
|
|
207
|
+
|
|
208
|
+
### Island Pattern (EJS + React)
|
|
209
|
+
|
|
210
|
+
Use EJS for layout while adding React components where needed:
|
|
211
|
+
|
|
212
|
+
```html
|
|
213
|
+
<div id="comment-section" data-post-id="<%= post.id %>"></div>
|
|
214
|
+
<script type="module" src="/src/components/CommentSection.tsx"></script>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### API-Only Mode
|
|
218
|
+
|
|
219
|
+
For pure API development, disable EJS:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
const { app } = await createMvcApp({
|
|
223
|
+
viewsPath: "src/views",
|
|
224
|
+
disableViewEngine: true,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
app.get("/api/users", async (req, res) => {
|
|
228
|
+
res.json(users); // Always JSON
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
204
232
|
### Step 5: Use in Your Code
|
|
205
233
|
|
|
206
234
|
```typescript
|
|
@@ -284,10 +312,22 @@ Link to them in your templates:
|
|
|
284
312
|
|
|
285
313
|
| Command | Description |
|
|
286
314
|
|---------|-------------|
|
|
287
|
-
| `npx
|
|
288
|
-
| `npx erwinmvc generate
|
|
289
|
-
|
|
290
|
-
|
|
315
|
+
| `npx @erwininteractive/mvc init <dir>` | Create a new app |
|
|
316
|
+
| `npx erwinmvc generate resource <name>` | Generate model + controller + views |
|
|
317
|
+
| `npx erwinmvc generate controller <name>` | Generate a CRUD controller |
|
|
318
|
+
| `npx erwinmvc generate model <name>` | Generate a database model |
|
|
319
|
+
| `npx erwinmvc webauthn` | Generate WebAuthn authentication (security keys) |
|
|
320
|
+
| `npx erwinmvc list:routes` | Show all defined routes |
|
|
321
|
+
|
|
322
|
+
### Init Options
|
|
323
|
+
|
|
324
|
+
| Option | Description |
|
|
325
|
+
|--------|-------------|
|
|
326
|
+
| `--skip-install` | Skip running npm install |
|
|
327
|
+
| `--with-database` | Include Prisma database setup |
|
|
328
|
+
| `--with-ci` | Include GitHub Actions CI workflow |
|
|
329
|
+
|
|
330
|
+
### Resource Options
|
|
291
331
|
|
|
292
332
|
## Learn More
|
|
293
333
|
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
"db:push": "prisma db push"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@erwininteractive/mvc": "^
|
|
15
|
+
"@erwininteractive/mvc": "^0.4.0",
|
|
16
|
+
"cookie-parser": "^1.4.6"
|
|
16
17
|
},
|
|
17
18
|
"devDependencies": {
|
|
19
|
+
"@types/cookie-parser": "^1.4.7",
|
|
18
20
|
"@types/express": "^5.0.0",
|
|
19
21
|
"@types/node": "^22.7.5",
|
|
20
22
|
"tsx": "^4.19.1",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createMvcApp, startServer } from "@erwininteractive/mvc";
|
|
2
|
+
import cookieParser from "cookie-parser";
|
|
2
3
|
|
|
3
4
|
async function main() {
|
|
4
5
|
const { app } = await createMvcApp({
|
|
@@ -6,8 +7,11 @@ async function main() {
|
|
|
6
7
|
publicPath: "public",
|
|
7
8
|
});
|
|
8
9
|
|
|
10
|
+
// Parse cookies (needed for JWT authentication)
|
|
11
|
+
app.use(cookieParser());
|
|
12
|
+
|
|
9
13
|
// Root route - displays welcome page
|
|
10
|
-
app.get("/", (req, res) => {
|
|
14
|
+
app.get("/", (req: any, res: any) => {
|
|
11
15
|
res.render("index", { title: "Welcome" });
|
|
12
16
|
});
|
|
13
17
|
|