feat(server): with api server, service-lize (#807)

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
Co-authored-by: Lovehsigure_520 <1260907335@qq.com>
This commit is contained in:
RainbowBird
2026-01-05 16:24:03 +08:00
committed by RainbowBird
co-authored by Neko Ayaka Lovehsigure_520
parent dd46b8deff
commit d064a959cf
30 changed files with 2163 additions and 184 deletions
+99
View File
@@ -0,0 +1,99 @@
import process, { exit } from 'node:process'
import { initLogger, LoggerFormat, LoggerLevel, useLogger } from '@guiiai/logg'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger as honoLogger } from 'hono/logger'
import { injeca } from 'injeca'
import { createAuth } from './services/auth'
import { createDrizzle } from './services/db'
import { parsedEnv } from './services/env'
import { getTrustedOrigin } from './utils/origin'
async function createApp() {
initLogger(LoggerLevel.Debug, LoggerFormat.Pretty)
const resolved = await injeca.resolve({ parsedEnv })
const logger = useLogger('app').useGlobalConfig()
const db = createDrizzle(resolved.parsedEnv.DATABASE_URL)
const auth = createAuth(db, resolved.parsedEnv)
db.execute('SELECT 1')
.then(() => {
logger.log('Connected to database')
})
.catch((err) => {
logger.withError(err).error('Failed to connect to database')
exit(1)
})
const app = new Hono<{
Variables: {
user: typeof auth.$Infer.Session.user | null
session: typeof auth.$Infer.Session.session | null
}
}>()
app.use(
'/api/auth/*', // or replace with "*" to enable cors for all routes
cors({
origin(origin: string) {
return getTrustedOrigin(origin)
},
credentials: true,
}),
)
app.use(honoLogger())
app.use('*', async (c, next) => {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
c.set('user', null)
c.set('session', null)
await next()
return
}
c.set('user', session.user)
c.set('session', session.session)
await next()
})
app.get('/session', (c) => {
const session = c.get('session')
const user = c.get('user')
if (!user)
return c.body(null, 401)
return c.json({
session,
user,
})
})
// NOTICE: required by better-auth
app.on(['POST', 'GET'], '/api/auth/*', (c) => {
return auth.handler(c.req.raw)
})
logger.withFields({ port: 3000 }).log('Server started')
return app
}
// eslint-disable-next-line antfu/no-top-level-await
serve(await createApp())
function handleError(error: unknown, type: string) {
useLogger().withError(error).error(type)
}
process.on('uncaughtException', error => handleError(error, 'Uncaught exception'))
process.on('unhandledRejection', error => handleError(error, 'Unhandled rejection'))
+93
View File
@@ -0,0 +1,93 @@
import { relations } from 'drizzle-orm'
import { boolean, index, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
})
export const session = pgTable(
'session',
{
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
},
table => [index('session_userId_idx').on(table.userId)],
)
export const account = pgTable(
'account',
{
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('account_userId_idx').on(table.userId)],
)
export const verification = pgTable(
'verification',
{
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at')
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
table => [index('verification_identifier_idx').on(table.identifier)],
)
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}))
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}))
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}))
+8
View File
@@ -0,0 +1,8 @@
import process from 'node:process'
import { createAuth } from '../services/auth'
import { createDrizzle } from '../services/db'
import { parseEnv } from '../services/env'
const env = parseEnv(process.env)
export default createAuth(createDrizzle(env.DATABASE_URL), env)
+52
View File
@@ -0,0 +1,52 @@
import type { Database } from './db'
import type { Env } from './env'
import process from 'node:process'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { bearer } from 'better-auth/plugins'
import * as authSchema from '../schemas/auth'
export function createAuth(db: Database, env: Env) {
return betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
...authSchema,
},
}),
plugins: [
bearer(),
],
emailAndPassword: {
enabled: true,
},
baseURL: process.env.API_SERVER_URL || 'http://localhost:3000',
trustedOrigins: ['*'],
// To skip state-mismatch errors
// https://github.com/better-auth/better-auth/issues/4969#issuecomment-3397804378
advanced: {
defaultCookieAttributes: {
sameSite: 'None', // this enables cross-site cookies
secure: true, // required for SameSite=None
},
},
socialProviders: {
google: {
clientId: env.AUTH_GOOGLE_CLIENT_ID,
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
},
github: {
clientId: env.AUTH_GITHUB_CLIENT_ID,
clientSecret: env.AUTH_GITHUB_CLIENT_SECRET,
},
},
})
}
+9
View File
@@ -0,0 +1,9 @@
import postgres from 'postgres'
import { drizzle } from 'drizzle-orm/postgres-js'
export type Database = ReturnType<typeof createDrizzle>
export function createDrizzle(dsn: string) {
return drizzle(postgres(dsn))
}
+29
View File
@@ -0,0 +1,29 @@
import type { InferOutput } from 'valibot'
import { env, exit } from 'node:process'
import { useLogger } from '@guiiai/logg'
import { injeca } from 'injeca'
import { nonEmpty, object, parse, pipe, string } from 'valibot'
const EnvSchema = object({
DATABASE_URL: pipe(string(), nonEmpty('DATABASE_URL is required')),
AUTH_GOOGLE_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_ID is required')),
AUTH_GOOGLE_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GOOGLE_CLIENT_SECRET is required')),
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
})
export type Env = InferOutput<typeof EnvSchema>
export function parseEnv(inputEnv: Record<string, string> | typeof env): Env {
try {
return parse(EnvSchema, inputEnv)
}
catch (err) {
useLogger().withError(err).error('Invalid environment variables')
exit(1)
}
}
export const parsedEnv = injeca.provide('env', () => parseEnv(env))
+20
View File
@@ -0,0 +1,20 @@
export function getTrustedOrigin(origin: string): string {
// 1. Allow Dev (Localhost with any port)
if (!origin || origin.startsWith('http://localhost:')) {
return origin
}
// 2. Allow Production (Exact Match)
if (origin === 'https://airi.moeru.ai') {
return origin
}
// 3. Allow Dynamic Subdomains (Strict Regex)
// Matches: https://foo.kwaa.workers.dev
if (/^https:\/\/.*\.kwaa\.workers\.dev$/.test(origin)) {
return origin
}
// Default: Block
return ''
}