Add AI DEV Node deployment foundation

This commit is contained in:
unknown
2026-07-13 10:33:21 +09:00
parent 6abcee3727
commit b90bfe35ca
23 changed files with 3978 additions and 0 deletions

28
server/config/env.ts Normal file
View 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
View 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
View 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;
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,6 @@
import { Router } from 'express';
import { healthRouter } from './health.js';
export const apiRouter = Router();
apiRouter.use(healthRouter);