Skip to content

Commit 411cdbf

Browse files
committed
feat(monorepo): complete migration to microservices architecture
- Extracted public API into a dedicated 'user-server' workspace. - Refined Dashboard API into 'admin-server' workspace. - Implemented concurrent npm scripts to run both backends and Vite frontend simultaneously. - Cleanly deleted legacy-backend, fully deprecating the monolith. - Updated .env template and cross-workspace package imports.
1 parent 7dbcfea commit 411cdbf

55 files changed

Lines changed: 1802 additions & 309 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
# Copy this file to .env and fill in your values before running docker-compose.
44
# ─────────────────────────────────────────────────────────────────────────────
55

6-
# Server
7-
PORT=1234
8-
NODE_ENV=production
6+
# ── Server Ports ────────────────────────────────────────────────────────────
7+
PORT=1234 # For Admin Server (Dashboard)
8+
USER_PORT=1235 # For User Server (Public API)
9+
NODE_ENV=development
10+
911

1012
# ── Database & Cache ──────────────────────────────────────────────────────────
1113
# When using docker-compose, these are automatically overridden to point to

apps/admin-server/package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
{
22
"name": "admin-server",
3-
"version": "1.0.0",
3+
"version": "0.2.0",
44
"private": true,
55
"main": "src/app.js",
6+
"scripts": {
7+
"dev": "node src/app.js",
8+
"start": "node src/app.js"
9+
},
610
"dependencies": {
711
"@urbackend/common": "*",
812
"@kiroo/sdk": "^0.1.2",

apps/admin-server/src/app.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const dotenv = require('dotenv');
2-
dotenv.config();
2+
const path = require('path');
3+
dotenv.config({ path: path.join(__dirname, '../../../.env') });
34

45
const { validateEnv } = require('@urbackend/common');
56

apps/user-server/package.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"name": "user-server",
3+
"version": "0.2.0",
4+
"private": true,
5+
"main": "src/app.js",
6+
"scripts": {
7+
"dev": "node src/app.js",
8+
"start": "node src/app.js"
9+
},
10+
"dependencies": {
11+
"@urbackend/common": "*",
12+
"@kiroo/sdk": "^0.1.2",
13+
"@supabase/supabase-js": "^2.84.0",
14+
"bcryptjs": "^3.0.2",
15+
"bullmq": "^5.70.1",
16+
"cors": "^2.8.5",
17+
"dotenv": "^17.2.3",
18+
"express": "^5.1.0",
19+
"express-rate-limit": "^8.2.1",
20+
"ioredis": "^5.10.0",
21+
"jsonwebtoken": "^9.0.2",
22+
"mongoose": "^8.19.2",
23+
"multer": "^2.0.2",
24+
"resend": "^6.6.0",
25+
"uuid": "^9.0.1",
26+
"zod": "^4.1.13"
27+
}
28+
}
29+

apps/user-server/src/app.js

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
const dotenv = require('dotenv');
2+
const path = require('path');
3+
dotenv.config({ path: path.join(__dirname, '../../../.env') });
4+
5+
const {validateEnv} = require('@urbackend/common');
6+
7+
if (process.env.NODE_ENV !== 'test') {
8+
validateEnv();
9+
}
10+
11+
const express = require('express')
12+
const mongoose = require('mongoose')
13+
const cors = require('cors')
14+
const app = express();
15+
app.set('trust proxy', 1);
16+
const { garbageCollect, storageGarbageCollect, getPublicIp } = require('@urbackend/common');
17+
const { capture } = require('@kiroo/sdk');
18+
19+
20+
// Initialize Queue Workers
21+
const {emailQueue} = require('@urbackend/common');
22+
const {authEmailQueue} = require('@urbackend/common');
23+
24+
app.use(express.json());
25+
app.use(express.urlencoded({ extended: true }));
26+
app.use(cors());
27+
28+
29+
if (process.env.NODE_ENV !== 'test') {
30+
garbageCollect();
31+
storageGarbageCollect();
32+
}
33+
34+
app.use(capture({
35+
supabaseUrl: process.env.SUPABASE_URL,
36+
supabaseKey: process.env.SUPABASE_KEY,
37+
bucket: process.env.SUPABASE_BUCKET,
38+
sampleRate: 0.2
39+
}));
40+
41+
42+
43+
// LOGGING
44+
const { limiter, logger } = require('./middlewares/api_usage');
45+
46+
// Route Imports
47+
const dataRoute = require('./routes/data');
48+
const userAuthRoute = require('./routes/userAuth');
49+
const storageRoute = require('./routes/storage');
50+
const schemaRoute = require('./routes/schemas');
51+
52+
// ROUTES SETUP
53+
app.use('/api/userAuth', limiter, logger, userAuthRoute);
54+
55+
const projectCorsPreflight = (req, res, next) => {
56+
res.header("Access-Control-Allow-Origin", req.headers.origin || "*");
57+
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH");
58+
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, x-api-key");
59+
if (req.method === 'OPTIONS') {
60+
return res.status(200).end();
61+
}
62+
next();
63+
};
64+
65+
app.use('/api/data', projectCorsPreflight, limiter, logger, dataRoute);
66+
app.use('/api/schemas', projectCorsPreflight, limiter, logger, schemaRoute);
67+
app.use('/api/storage', projectCorsPreflight, limiter, logger, storageRoute);
68+
69+
app.get('/api/server-ip', async (req, res) => {
70+
const ip = await getPublicIp();
71+
res.json({ ip });
72+
});
73+
74+
// Test Route
75+
app.get('/', (req, res) => {
76+
res.status(200).json({ status: "success", message: "urBackend API is running 🚀" })
77+
});
78+
79+
// Global Error Handler
80+
app.use((err, req, res, next) => {
81+
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
82+
return res.status(400).json({
83+
error: "Invalid JSON format",
84+
message: "Check your request body syntax. Stray characters outside the JSON object are not allowed."
85+
});
86+
}
87+
88+
console.error("🔥 Unhandled Error:", err.stack);
89+
res.status(500).json({
90+
error: "Something went wrong!",
91+
message: err.message
92+
});
93+
});
94+
95+
app.use((req, res) => {
96+
const id = res.get("X-Kiroo-Replay-ID");
97+
res.json({error: "Not Found", replayId: id})
98+
})
99+
// INITIALIZATION
100+
if (process.env.NODE_ENV !== 'test') {
101+
102+
const PORT = process.env.USER_PORT || 1235;
103+
104+
const { connectDB } = require('@urbackend/common');
105+
106+
// Start DB & Server
107+
connectDB();
108+
const server = app.listen(PORT, () => {
109+
console.log(`Server running on port ${PORT}`);
110+
});
111+
112+
// SHUTDOWN
113+
const gracefulShutdown = async () => {
114+
console.log('🛑 SIGTERM/SIGINT received. Shutting down gracefully...');
115+
116+
server.close(async () => {
117+
console.log('✅ HTTP server closed.');
118+
try {
119+
await mongoose.connection.close(false);
120+
console.log('✅ MongoDB connection closed.');
121+
process.exit(0);
122+
} catch (err) {
123+
console.error('❌ Error closing MongoDB connection:', err);
124+
process.exit(1);
125+
}
126+
});
127+
128+
// Force close after 10s
129+
setTimeout(() => {
130+
console.error('Force shutting down...');
131+
process.exit(1);
132+
}, 10000);
133+
};
134+
135+
process.on('SIGTERM', gracefulShutdown);
136+
process.on('SIGINT', gracefulShutdown);
137+
}
138+
139+
// Export for Testing
140+
module.exports = app;
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
const { sanitize } = require("@urbackend/common");
2+
const mongoose = require('mongoose');
3+
const { Project } = require("@urbackend/common");
4+
const { getConnection } = require("@urbackend/common");
5+
const { getCompiledModel } = require("@urbackend/common");
6+
const {QueryEngine} = require("@urbackend/common");
7+
const { validateData, validateUpdateData } = require("@urbackend/common");
8+
9+
// Validate MongoDB ObjectId
10+
const isValidId = (id) => mongoose.Types.ObjectId.isValid(id);
11+
12+
// INSERT DATA
13+
module.exports.insertData = async (req, res) => {
14+
try {
15+
console.time("insert data")
16+
const { collectionName } = req.params;
17+
const project = req.project;
18+
19+
const collectionConfig = project.collections.find(c => c.name === collectionName);
20+
if (!collectionConfig) return res.status(404).json({ error: "Collection not found" });
21+
22+
const schemaRules = collectionConfig.model;
23+
const incomingData = req.body;
24+
25+
// Recursive validation for all field types
26+
const { error, cleanData } = validateData(incomingData, schemaRules);
27+
if (error) return res.status(400).json({ error });
28+
29+
const safeData = sanitize(cleanData);
30+
31+
let docSize = 0;
32+
if (!project.resources.db.isExternal) {
33+
docSize = Buffer.byteLength(JSON.stringify(safeData));
34+
if ((project.databaseUsed || 0) + docSize > project.databaseLimit) {
35+
return res.status(403).json({ error: "Database limit exceeded." });
36+
}
37+
}
38+
39+
const connection = await getConnection(project._id);
40+
const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal);
41+
42+
const result = await Model.create(safeData);
43+
44+
if (!project.resources.db.isExternal) {
45+
await Project.updateOne(
46+
{ _id: project._id },
47+
{ $inc: { databaseUsed: docSize } }
48+
);
49+
}
50+
51+
console.timeEnd("insert data")
52+
res.status(201).json(result);
53+
} catch (err) {
54+
console.error(err);
55+
res.status(500).json({ error: err.message });
56+
}
57+
};
58+
59+
// GET ALL DATA
60+
module.exports.getAllData = async (req, res) => {
61+
try {
62+
console.time("getall")
63+
const { collectionName } = req.params;
64+
const project = req.project;
65+
66+
const collectionConfig = project.collections.find(c => c.name === collectionName);
67+
if (!collectionConfig) return res.status(404).json({ error: "Collection not found" });
68+
69+
const connection = await getConnection(project._id);
70+
const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal);
71+
72+
const features = new QueryEngine(Model.find(), req.query)
73+
.filter()
74+
.sort()
75+
.paginate();
76+
77+
const data = await features.query.lean();
78+
console.timeEnd("getall")
79+
res.json(data);
80+
} catch (err) {
81+
console.error(err);
82+
res.status(500).json({ error: err.message });
83+
}
84+
};
85+
86+
// GET SINGLE DOC
87+
module.exports.getSingleDoc = async (req, res) => {
88+
try {
89+
const { collectionName, id } = req.params;
90+
const project = req.project;
91+
92+
// ensure valid mongose objct id
93+
if (!isValidId(id)) return res.status(400).json({ error: "Invalid ID format." });
94+
95+
const collectionConfig = project.collections.find(c => c.name === collectionName);
96+
if (!collectionConfig) return res.status(404).json({ error: "Collection not found" });
97+
98+
const connection = await getConnection(project._id);
99+
const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal);
100+
101+
const doc = await Model.findById(id).lean();
102+
if (!doc) return res.status(404).json({ error: "Document not found." });
103+
104+
res.json(doc);
105+
} catch (err) {
106+
console.error(err);
107+
res.status(500).json({ error: err.message });
108+
}
109+
};
110+
111+
// UPDATE DATA
112+
module.exports.updateSingleData = async (req, res) => {
113+
try {
114+
const { collectionName, id } = req.params;
115+
const project = req.project;
116+
const incomingData = req.body;
117+
118+
if (!isValidId(id)) return res.status(400).json({ error: "Invalid ID format." });
119+
120+
const collectionConfig = project.collections.find(c => c.name === collectionName);
121+
if (!collectionConfig) return res.status(404).json({ error: "Collection not found" });
122+
123+
const connection = await getConnection(project._id);
124+
const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal);
125+
126+
// Recursive validation for all field types
127+
const schemaRules = collectionConfig.model;
128+
const { error: validationError, updateData } = validateUpdateData(incomingData, schemaRules);
129+
if (validationError) return res.status(400).json({ error: validationError });
130+
131+
const sanitizedData = sanitize(updateData);
132+
133+
const result = await Model.findByIdAndUpdate(id, { $set: sanitizedData }, { new: true }).lean();
134+
if (!result) return res.status(404).json({ error: "Document not found." });
135+
136+
res.json({ message: "Updated", data: result });
137+
} catch (err) {
138+
console.error(err);
139+
res.status(500).json({ error: err.message });
140+
}
141+
};
142+
143+
// DELETE DATA
144+
module.exports.deleteSingleDoc = async (req, res) => {
145+
try {
146+
const { collectionName, id } = req.params;
147+
const project = req.project;
148+
149+
if (!isValidId(id)) return res.status(400).json({ error: "Invalid ID format." });
150+
151+
const collectionConfig = project.collections.find(c => c.name === collectionName);
152+
if (!collectionConfig) return res.status(404).json({ error: "Collection not found" });
153+
154+
const connection = await getConnection(project._id);
155+
const Model = getCompiledModel(connection, collectionConfig, project._id, project.resources.db.isExternal);
156+
157+
const docToDelete = await Model.findById(id);
158+
if (!docToDelete) return res.status(404).json({ error: "Document not found." });
159+
160+
let docSize = 0;
161+
if (!project.resources.db.isExternal) {
162+
docSize = Buffer.byteLength(JSON.stringify(docToDelete));
163+
}
164+
165+
await Model.deleteOne({ _id: id });
166+
167+
if (!project.resources.db.isExternal) {
168+
let databaseUsed = Math.max(0, (project.databaseUsed || 0) - docSize);
169+
await Project.updateOne(
170+
{ _id: project._id },
171+
{ $set: { databaseUsed } }
172+
);
173+
}
174+
175+
res.json({ message: "Document deleted", id });
176+
} catch (err) {
177+
console.error(err);
178+
res.status(500).json({ error: err.message });
179+
}
180+
};

0 commit comments

Comments
 (0)