Skip to content

Commit b07deaa

Browse files
committed
feat(admin): bootstrap Bun-native fullstack admin app foundation
Initialize @nbw/admin with a Bun.serve backend and React SPA static delivery, plus monorepo wiring. Add explicit env validation, @nbw/database model registration, modular S3 client scaffolding, and Tailwind/shadcn-style UI baseline.
1 parent f0d079b commit b07deaa

33 files changed

Lines changed: 947 additions & 42 deletions

apps/admin/.env.example

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
NODE_ENV=development
2+
ADMIN_APP_HOST=0.0.0.0
3+
ADMIN_APP_PORT=4010
4+
5+
MONGO_URL=mongodb://localhost:27017/noteblockworld
6+
7+
S3_ENDPOINT=http://localhost:9000
8+
S3_BUCKET_SONGS=noteblockworld-songs
9+
S3_BUCKET_THUMBS=noteblockworld-thumbs
10+
S3_KEY=minioadmin
11+
S3_SECRET=minioadmin
12+
S3_REGION=us-east-1

apps/admin/components.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"$schema": "https://ui.shadcn.com/schema.json",
3+
"style": "new-york",
4+
"tsx": true,
5+
"tailwind": {
6+
"css": "src/web/styles.css",
7+
"baseColor": "zinc",
8+
"cssVariables": false
9+
},
10+
"aliases": {
11+
"components": "@admin-web/components",
12+
"utils": "@admin-web/lib"
13+
}
14+
}

apps/admin/package.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "@nbw/admin",
3+
"version": "0.0.1",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "NODE_ENV=production bun run scripts/build.ts",
8+
"dev": "bun run build && bun --watch run src/index.ts",
9+
"start": "bun run dist/server/index.js",
10+
"lint": "eslint \"src/**/*.{ts,tsx}\" --fix",
11+
"typecheck": "tsc -p tsconfig.json --noEmit"
12+
},
13+
"dependencies": {
14+
"@aws-sdk/client-s3": "^3.946.0",
15+
"@aws-sdk/s3-request-presigner": "^3.946.0",
16+
"@nbw/config": "workspace:*",
17+
"@nbw/database": "workspace:*",
18+
"@nbw/validation": "workspace:*",
19+
"@radix-ui/react-slot": "^1.2.4",
20+
"class-variance-authority": "^0.7.1",
21+
"clsx": "^2.1.1",
22+
"lucide-react": "^0.556.0",
23+
"mongoose": "^9.0.1",
24+
"react": "^19.2.1",
25+
"react-dom": "^19.2.1",
26+
"tailwind-merge": "^3.4.0",
27+
"zod": "^4.1.13"
28+
},
29+
"devDependencies": {
30+
"@tailwindcss/postcss": "^4.1.18",
31+
"@types/bun": "^1.3.4",
32+
"@types/react": "^19.2.7",
33+
"@types/react-dom": "^19.2.3",
34+
"postcss": "^8.5.6",
35+
"tailwindcss": "^4.1.18",
36+
"typescript": "^5.9.3"
37+
}
38+
}

apps/admin/postcss.config.mjs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const config = {
2+
plugins: {
3+
'@tailwindcss/postcss': {},
4+
},
5+
};
6+
7+
export default config;

apps/admin/scripts/build.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { $ } from 'bun';
2+
3+
import tailwindcss from '@tailwindcss/postcss';
4+
import postcss from 'postcss';
5+
6+
const DIST_DIR = './dist';
7+
const PUBLIC_DIR = `${DIST_DIR}/public`;
8+
const SERVER_DIR = `${DIST_DIR}/server`;
9+
10+
async function buildStyles() {
11+
const sourceCss = await Bun.file('./src/web/styles.css').text();
12+
const output = await postcss([tailwindcss()]).process(sourceCss, {
13+
from: './src/web/styles.css',
14+
to: `${PUBLIC_DIR}/styles.css`,
15+
});
16+
17+
await Bun.write(`${PUBLIC_DIR}/styles.css`, output.css);
18+
}
19+
20+
async function buildSpa() {
21+
const result = await Bun.build({
22+
entrypoints: ['./src/web/main.tsx'],
23+
outdir: PUBLIC_DIR,
24+
target: 'browser',
25+
format: 'esm',
26+
splitting: false,
27+
minify: false,
28+
sourcemap: 'linked',
29+
});
30+
31+
if (!result.success) {
32+
throw new Error(
33+
`SPA build failed: ${result.logs.map((log) => log.message).join('\n')}`,
34+
);
35+
}
36+
37+
await Bun.write(
38+
`${PUBLIC_DIR}/index.html`,
39+
await Bun.file('./src/web/index.html').text(),
40+
);
41+
}
42+
43+
async function buildServer() {
44+
const result = await Bun.build({
45+
entrypoints: ['./src/index.ts'],
46+
outdir: SERVER_DIR,
47+
target: 'bun',
48+
format: 'esm',
49+
splitting: false,
50+
minify: false,
51+
sourcemap: 'linked',
52+
external: ['@nbw/database', '@nbw/config', '@nbw/validation'],
53+
});
54+
55+
if (!result.success) {
56+
throw new Error(
57+
`Server build failed: ${result.logs
58+
.map((log) => log.message)
59+
.join('\n')}`,
60+
);
61+
}
62+
}
63+
64+
async function buildAll() {
65+
await $`rm -rf ${DIST_DIR}`;
66+
await $`mkdir -p ${PUBLIC_DIR} ${SERVER_DIR}`;
67+
68+
await Promise.all([buildStyles(), buildSpa(), buildServer()]);
69+
}
70+
71+
if (process.argv.includes('--watch')) {
72+
throw new Error(
73+
'Watch mode is handled by `bun --watch run src/index.ts` in `bun run dev`.',
74+
);
75+
}
76+
77+
buildAll()
78+
.then(() => {
79+
process.stdout.write('Built admin app successfully.\n');
80+
})
81+
.catch((error) => {
82+
process.stderr.write(`${String(error)}\n`);
83+
process.exit(1);
84+
});

apps/admin/src/index.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import path from 'path';
2+
3+
import { connectDatabase } from '@admin/db/connect';
4+
import { createDatabaseModels } from '@admin/db/models';
5+
import { parseEnvironment } from '@admin/env';
6+
import { routeApiRequest } from '@admin/http/router';
7+
import { type ServiceContext } from '@admin/services/context';
8+
import { createS3StorageClient } from '@admin/storage/s3-client';
9+
10+
const env = parseEnvironment(process.env);
11+
12+
await connectDatabase(env.MONGO_URL);
13+
14+
const models = createDatabaseModels();
15+
const storage = createS3StorageClient(env);
16+
await storage.verifyBuckets();
17+
18+
const context: ServiceContext = {
19+
env,
20+
models,
21+
storage,
22+
startedAt: Date.now(),
23+
};
24+
25+
async function resolvePublicRoot() {
26+
const candidates = [
27+
path.resolve(import.meta.dir, '../public'),
28+
path.resolve(import.meta.dir, '../dist/public'),
29+
];
30+
31+
for (const candidate of candidates) {
32+
if (await Bun.file(path.join(candidate, 'index.html')).exists()) {
33+
return candidate;
34+
}
35+
}
36+
37+
return candidates[0];
38+
}
39+
40+
const publicRoot = await resolvePublicRoot();
41+
42+
function safeResolvePublicPath(urlPath: string) {
43+
const normalized = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, '');
44+
const fullPath = path.resolve(publicRoot, `.${normalized}`);
45+
46+
if (!fullPath.startsWith(publicRoot)) {
47+
return null;
48+
}
49+
50+
return fullPath;
51+
}
52+
53+
async function serveStatic(request: Request) {
54+
const url = new URL(request.url);
55+
const pathname = url.pathname === '/' ? '/index.html' : url.pathname;
56+
const filePath = safeResolvePublicPath(pathname);
57+
58+
if (filePath) {
59+
const file = Bun.file(filePath);
60+
if (await file.exists()) {
61+
return new Response(file);
62+
}
63+
}
64+
65+
const spaFallback = Bun.file(path.join(publicRoot, 'index.html'));
66+
if (await spaFallback.exists()) {
67+
return new Response(spaFallback);
68+
}
69+
70+
return new Response('index.html was not found. Run `bun run build` first.', {
71+
status: 500,
72+
});
73+
}
74+
75+
Bun.serve({
76+
hostname: env.ADMIN_APP_HOST,
77+
port: env.ADMIN_APP_PORT,
78+
async fetch(request) {
79+
const url = new URL(request.url);
80+
81+
if (url.pathname.startsWith('/api/')) {
82+
return routeApiRequest(request, context);
83+
}
84+
85+
return serveStatic(request);
86+
},
87+
});
88+
89+
process.stdout.write(
90+
`@nbw/admin listening on http://${env.ADMIN_APP_HOST}:${env.ADMIN_APP_PORT}\n`,
91+
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import mongoose from 'mongoose';
2+
3+
export async function connectDatabase(mongoUrl: string) {
4+
await mongoose.connect(mongoUrl);
5+
}

apps/admin/src/server/db/models.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import mongoose from 'mongoose';
2+
3+
import { Song, SongSchema, User, UserSchema } from '@nbw/database';
4+
5+
export function createDatabaseModels() {
6+
const songs =
7+
mongoose.models[Song.name] ?? mongoose.model(Song.name, SongSchema);
8+
9+
const users =
10+
mongoose.models[User.name] ?? mongoose.model(User.name, UserSchema);
11+
12+
return { songs, users };
13+
}
14+
15+
export type DatabaseModels = ReturnType<typeof createDatabaseModels>;

apps/admin/src/server/env.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { z } from 'zod';
2+
3+
const envSchema = z.object({
4+
NODE_ENV: z.enum(['development', 'production']).default('development'),
5+
ADMIN_APP_HOST: z.string().default('0.0.0.0'),
6+
ADMIN_APP_PORT: z.coerce.number().int().positive().default(4010),
7+
MONGO_URL: z.string().min(1),
8+
S3_ENDPOINT: z.string().min(1),
9+
S3_BUCKET_SONGS: z.string().min(1),
10+
S3_BUCKET_THUMBS: z.string().min(1),
11+
S3_KEY: z.string().min(1),
12+
S3_SECRET: z.string().min(1),
13+
S3_REGION: z.string().min(1),
14+
});
15+
16+
export type AdminEnvironment = z.output<typeof envSchema>;
17+
18+
export function parseEnvironment(source: Record<string, string | undefined>) {
19+
const result = envSchema.safeParse(source);
20+
21+
if (!result.success) {
22+
const messages = result.error.issues
23+
.map((issue) => `${issue.path.join('.')}: ${issue.message}`)
24+
.join('\n');
25+
throw new Error(`Admin environment validation failed:\n${messages}`);
26+
}
27+
28+
return result.data;
29+
}

apps/admin/src/server/http/json.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
export function json(
2+
payload: unknown,
3+
init: Omit<ResponseInit, 'headers'> & {
4+
headers?: HeadersInit;
5+
} = {},
6+
) {
7+
return new Response(JSON.stringify(payload), {
8+
...init,
9+
headers: {
10+
'content-type': 'application/json; charset=utf-8',
11+
...init.headers,
12+
},
13+
});
14+
}
15+
16+
export function notFound() {
17+
return json(
18+
{
19+
error: 'Not Found',
20+
},
21+
{ status: 404 },
22+
);
23+
}

0 commit comments

Comments
 (0)