Add AI DEV Node deployment foundation
This commit is contained in:
28
server/config/env.ts
Normal file
28
server/config/env.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
const projectEnvPath = path.resolve(process.cwd(), '.project-env');
|
||||
const dotEnvPath = path.resolve(process.cwd(), '.env');
|
||||
|
||||
if (existsSync(projectEnvPath)) {
|
||||
dotenv.config({ path: projectEnvPath });
|
||||
} else if (existsSync(dotEnvPath)) {
|
||||
dotenv.config({ path: dotEnvPath });
|
||||
}
|
||||
|
||||
export const env = {
|
||||
nodeEnv: process.env.NODE_ENV ?? 'development',
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me',
|
||||
publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000',
|
||||
smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev',
|
||||
};
|
||||
|
||||
export function requireDatabaseUrl(): string {
|
||||
if (!env.databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required. In AI DEV it should be supplied by .project-env.');
|
||||
}
|
||||
return env.databaseUrl;
|
||||
}
|
||||
56
server/db/migrate.ts
Normal file
56
server/db/migrate.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { closePool, query } from './pool.js';
|
||||
|
||||
const migrationsDir = path.resolve(process.cwd(), 'migrations');
|
||||
|
||||
async function ensureMigrationTable(): Promise<void> {
|
||||
await query(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename VARCHAR(255) PRIMARY KEY,
|
||||
applied_at TIMESTAMP NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function appliedMigrations(): Promise<Set<string>> {
|
||||
const result = await query<{ filename: string }>('SELECT filename FROM schema_migrations');
|
||||
return new Set(result.rows.map((row) => row.filename));
|
||||
}
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
await ensureMigrationTable();
|
||||
const applied = await appliedMigrations();
|
||||
const files = (await readdir(migrationsDir))
|
||||
.filter((file) => file.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) continue;
|
||||
|
||||
const sql = await readFile(path.join(migrationsDir, file), 'utf8');
|
||||
await query('BEGIN');
|
||||
try {
|
||||
await query(sql);
|
||||
await query('INSERT INTO schema_migrations (filename) VALUES ($1)', [file]);
|
||||
await query('COMMIT');
|
||||
console.log(`Applied migration ${file}`);
|
||||
} catch (error) {
|
||||
await query('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
runMigrations()
|
||||
.then(async () => {
|
||||
await closePool();
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error(error);
|
||||
await closePool();
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
23
server/db/pool.ts
Normal file
23
server/db/pool.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import pg from 'pg';
|
||||
import { requireDatabaseUrl } from '../config/env.js';
|
||||
|
||||
let pool: pg.Pool | undefined;
|
||||
|
||||
export function getPool(): pg.Pool {
|
||||
pool ??= new pg.Pool({
|
||||
connectionString: requireDatabaseUrl(),
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
|
||||
export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
|
||||
text: string,
|
||||
params?: unknown[],
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
return getPool().query<T>(text, params);
|
||||
}
|
||||
|
||||
export async function closePool(): Promise<void> {
|
||||
await pool?.end();
|
||||
pool = undefined;
|
||||
}
|
||||
15
server/http/apiResponse.ts
Normal file
15
server/http/apiResponse.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Response } from 'express';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T | null;
|
||||
}
|
||||
|
||||
export function ok<T>(res: Response, data: T, message = 'OK'): void {
|
||||
res.status(200).json({ code: 200, message, data } satisfies ApiResponse<T>);
|
||||
}
|
||||
|
||||
export function created<T>(res: Response, data: T, message = 'CREATED'): void {
|
||||
res.status(201).json({ code: 201, message, data } satisfies ApiResponse<T>);
|
||||
}
|
||||
33
server/http/errors.ts
Normal file
33
server/http/errors.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
error: unknown,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction,
|
||||
): void {
|
||||
if (error instanceof ApiError) {
|
||||
res.status(error.status).json({
|
||||
code: error.status,
|
||||
message: error.message,
|
||||
data: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
res.status(500).json({
|
||||
code: 500,
|
||||
message: 'Internal server error',
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
28
server/index.ts
Normal file
28
server/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { env } from './config/env.js';
|
||||
import { apiRouter } from './routes/index.js';
|
||||
import { platformStatusRouter } from './routes/health.js';
|
||||
import { errorHandler } from './http/errors.js';
|
||||
|
||||
const app = express();
|
||||
const frontendDist = path.resolve(process.cwd(), 'frontend', 'dist');
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
app.use(platformStatusRouter);
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
app.use(express.static(frontendDist));
|
||||
app.get('*', (_req, res) => {
|
||||
res.sendFile(path.join(frontendDist, 'index.html'));
|
||||
});
|
||||
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`ACS Node app listening on port ${env.port}`);
|
||||
});
|
||||
38
server/routes/health.ts
Normal file
38
server/routes/health.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Router } from 'express';
|
||||
import { ok } from '../http/apiResponse.js';
|
||||
import { query } from '../db/pool.js';
|
||||
|
||||
export const healthRouter = Router();
|
||||
|
||||
export const platformStatusRouter = Router();
|
||||
|
||||
healthRouter.get('/health', (_req, res) => {
|
||||
ok(res, {
|
||||
status: 'UP',
|
||||
service: 'acs-node',
|
||||
});
|
||||
});
|
||||
|
||||
platformStatusRouter.get('/healthz', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
platformStatusRouter.get('/db', async (_req, res) => {
|
||||
try {
|
||||
const result = await query<{ now: Date }>('SELECT now() AS now');
|
||||
res.json({ ok: true, now: result.rows[0]?.now });
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
platformStatusRouter.get('/s3', (_req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
reason: 'ACS does not currently use S3/MinIO storage.',
|
||||
});
|
||||
});
|
||||
6
server/routes/index.ts
Normal file
6
server/routes/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { healthRouter } from './health.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
apiRouter.use(healthRouter);
|
||||
Reference in New Issue
Block a user