-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
feat(server): with api server, service-lize #807
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 36 commits
38ef7be
f3c9105
76cdff6
8a6504b
96dc495
a8bacfd
a553cb7
60d9d27
3607606
ae0b0b2
badf2ce
32f5f82
b91f16d
5bf748b
de45c38
1e51866
b3eef77
d875f2a
d746b6e
8a2df3b
09f75a9
a6ad149
e84526b
cdeb4e4
8f360f9
e42c980
ef83e85
4985d77
32d9d14
698072d
e28bb3f
3d301a4
3345084
a28bef1
e848e9e
ff361f2
9521f04
12595e4
f176f1a
234bc22
7b78bb2
6175a03
64d07b0
b7cf0cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| DATABASE_URL="" | ||
|
|
||
| AUTH_GOOGLE_CLIENT_ID="" | ||
| AUTH_GOOGLE_CLIENT_SECRET="" | ||
|
|
||
| AUTH_GITHUB_CLIENT_ID="" | ||
| AUTH_GITHUB_CLIENT_SECRET="" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| FROM node:24-alpine | ||
|
|
||
| WORKDIR /app | ||
|
|
||
| RUN corepack enable | ||
|
|
||
| COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ | ||
| COPY apps/server apps/server | ||
|
|
||
| RUN pnpm install --frozen-lockfile --ignore-scripts | ||
|
|
||
| EXPOSE 3000 | ||
|
|
||
| CMD ["pnpm", "-F", "@proj-airi/api-server", "start"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| version: '3.9' | ||
|
|
||
| services: | ||
| db: | ||
| image: postgres:16-alpine | ||
| container_name: airi-postgres | ||
| environment: | ||
| - POSTGRES_DB=airi | ||
| - POSTGRES_USER=airi | ||
| - POSTGRES_PASSWORD=airi | ||
| ports: | ||
| - '5432:5432' | ||
| healthcheck: | ||
| test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] | ||
| interval: 5s | ||
| timeout: 5s | ||
| retries: 10 | ||
| volumes: | ||
| - pgdata:/var/lib/postgresql/data | ||
luoling8192 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| server: | ||
| build: | ||
| context: ../.. | ||
| dockerfile: apps/server/Dockerfile | ||
| depends_on: | ||
| db: | ||
| condition: service_healthy | ||
| ports: | ||
| - '3000:3000' | ||
|
|
||
| volumes: | ||
| pgdata: | ||
luoling8192 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| driver: local | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import process from 'node:process' | ||
|
|
||
| export default { | ||
| schema: './src/schemas/**/*.ts', | ||
| out: './drizzle', | ||
| dialect: 'postgresql', | ||
| dbCredentials: { | ||
| url: process.env.DATABASE_URL, | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "name": "@proj-airi/api-server", | ||
| "version": "0.0.1", | ||
| "private": true, | ||
| "scripts": { | ||
| "apply:env": "dotenvx run -f .env -f .env.local --overload --ignore=MISSING_ENV_FILE", | ||
| "generate:auth": "pnpm run apply:env -- better-auth generate --config src/scripts/auth.ts --output src/schemas/auth.ts -y", | ||
| "dev": "pnpm run apply:env -- tsx --watch src/app.ts", | ||
| "start": "tsx src/app.ts", | ||
| "push:db": "pnpm run apply:env -- drizzle-kit push" | ||
luoling8192 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }, | ||
| "dependencies": { | ||
| "@dotenvx/dotenvx": "^1.51.1", | ||
| "@guiiai/logg": "catalog:", | ||
| "@hono/node-server": "^1.19.6", | ||
| "better-auth": "^1.4.5", | ||
| "drizzle-orm": "^0.44.7", | ||
| "hono": "^4.10.7", | ||
| "postgres": "^3.4.7", | ||
| "tsx": "^4.21.0" | ||
| }, | ||
| "devDependencies": { | ||
| "@better-auth/cli": "^1.4.5", | ||
| "drizzle-kit": "^0.31.7" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| [build] | ||
| builder = "DOCKERFILE" | ||
| dockerfilePath = "apps/server/Dockerfile" | ||
| watchPatterns = [ | ||
| "apps/server/**", | ||
| "packages/**", | ||
| "pnpm-lock.yaml" | ||
| ] | ||
|
|
||
| [deploy] | ||
| startCommand = "pnpm -F @proj-airi/api-server start" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import process 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 { createAuth } from './services/auth' | ||
| import { createDrizzle } from './services/db' | ||
| import { parseEnv } from './services/env' | ||
| import { getTrustedOrigin } from './utils/origin' | ||
|
|
||
| function createApp() { | ||
| initLogger(LoggerLevel.Debug, LoggerFormat.Pretty) | ||
|
|
||
| const app = new Hono<{ | ||
| Variables: { | ||
| user: typeof auth.$Infer.Session.user | null | ||
| session: typeof auth.$Infer.Session.session | null | ||
| } | ||
| }>() | ||
| const env = parseEnv(process.env) | ||
|
|
||
| const logger = useLogger('app').useGlobalConfig() | ||
| const db = createDrizzle(env.DATABASE_URL) | ||
| const auth = createAuth(db, env) | ||
luoling8192 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| db.execute('SELECT 1') | ||
| .then(() => { | ||
| logger.log('Connected to database') | ||
| }) | ||
| .catch((err) => { | ||
| logger.withError(err).error('Failed to connect to database') | ||
| process.exit(1) | ||
| }) | ||
luoling8192 marked this conversation as resolved.
Show resolved
Hide resolved
Comment on lines
+24
to
+31
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is the difference of using this other than
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This won't block the loading of subsequent routes, although you could also use await if you want. |
||
|
|
||
| app.use( | ||
| '/api/auth/*', // or replace with "*" to enable cors for all routes | ||
| cors({ | ||
| origin(origin: string) { | ||
| return getTrustedOrigin(origin) | ||
| }, | ||
|
Comment on lines
+43
to
+45
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add |
||
| 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, | ||
| }) | ||
| }) | ||
|
|
||
| app.on(['POST', 'GET'], '/api/auth/*', (c) => { | ||
| return auth.handler(c.req.raw) | ||
| }) | ||
luoling8192 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| logger.withFields({ port: 3000 }).log('Server started') | ||
|
|
||
| return app | ||
| } | ||
|
|
||
| serve(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')) | ||
luoling8192 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
luoling8192 marked this conversation as resolved.
Show resolved
Hide resolved
|
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,101 @@ | ||||||||||||||||
| import { relations } from 'drizzle-orm' | ||||||||||||||||
luoling8192 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||
| 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()) | ||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Don't do this. |
||||||||||||||||
| .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()) | ||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Remove this. |
||||||||||||||||
| .notNull(), | ||||||||||||||||
|
Comment on lines
+24
to
+26
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||
| 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 jwks = pgTable('jwks', { | ||||||||||||||||
| id: text('id').primaryKey(), | ||||||||||||||||
| publicKey: text('public_key').notNull(), | ||||||||||||||||
| privateKey: text('private_key').notNull(), | ||||||||||||||||
| createdAt: timestamp('created_at').notNull(), | ||||||||||||||||
| expiresAt: timestamp('expires_at'), | ||||||||||||||||
| }) | ||||||||||||||||
|
|
||||||||||||||||
| 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], | ||||||||||||||||
| }), | ||||||||||||||||
| })) | ||||||||||||||||
|
Comment on lines
+76
to
+93
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! I learned! |
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| }, | ||
| }, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)) | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,17 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| export interface Env { | ||||||||||||||||||||||||||||||||||||||||||||||||
| DATABASE_URL: string | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GOOGLE_CLIENT_ID: string | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GOOGLE_CLIENT_SECRET: string | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GITHUB_CLIENT_ID: string | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GITHUB_CLIENT_SECRET: string | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function parseEnv(env: any): Env { | ||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||
| DATABASE_URL: env.DATABASE_URL, | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GOOGLE_CLIENT_ID: env.AUTH_GOOGLE_CLIENT_ID, | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GOOGLE_CLIENT_SECRET: env.AUTH_GOOGLE_CLIENT_SECRET, | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GITHUB_CLIENT_ID: env.AUTH_GITHUB_CLIENT_ID, | ||||||||||||||||||||||||||||||||||||||||||||||||
| AUTH_GITHUB_CLIENT_SECRET: env.AUTH_GITHUB_CLIENT_SECRET, | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export function parseEnv(env: any): Env { | |
| return { | |
| DATABASE_URL: env.DATABASE_URL, | |
| AUTH_GOOGLE_CLIENT_ID: env.AUTH_GOOGLE_CLIENT_ID, | |
| AUTH_GOOGLE_CLIENT_SECRET: env.AUTH_GOOGLE_CLIENT_SECRET, | |
| AUTH_GITHUB_CLIENT_ID: env.AUTH_GITHUB_CLIENT_ID, | |
| AUTH_GITHUB_CLIENT_SECRET: env.AUTH_GITHUB_CLIENT_SECRET, | |
| } | |
| } | |
| import { z } from 'zod' | |
| const envSchema = z.object({ | |
| DATABASE_URL: z.string().url(), | |
| AUTH_GOOGLE_CLIENT_ID: z.string().min(1), | |
| AUTH_GOOGLE_CLIENT_SECRET: z.string().min(1), | |
| AUTH_GITHUB_CLIENT_ID: z.string().min(1), | |
| AUTH_GITHUB_CLIENT_SECRET: z.string().min(1), | |
| // Add other required env vars like BASE_URL, CORS_ORIGINS, etc. | |
| }) | |
| export function parseEnv(env: any): Env { | |
| return envSchema.parse(env) | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Follow this.
Uh oh!
There was an error while loading. Please reload this page.