(
4 | key: T,
5 | ...concatKeys: string[]
6 | ): `${'nest'}:${T}${string | ''}` => {
7 | return `${'nest'}:${key}${
8 | concatKeys && concatKeys.length ? `:${concatKeys.join('_')}` : ''
9 | }`
10 | }
11 |
--------------------------------------------------------------------------------
/apps/core/src/main.ts:
--------------------------------------------------------------------------------
1 | #!env node
2 | import { name } from '../package.json'
3 | import { register } from './global/index.global'
4 | import '@wahyubucil/nestjs-zod-openapi/boot'
5 |
6 | process.title = `${name} - ${process.env.NODE_ENV || 'unknown'}`
7 | async function main() {
8 | register()
9 | const { bootstrap } = await import('./bootstrap')
10 | bootstrap()
11 | }
12 |
13 | main()
14 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/ProviderComposer.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { cloneElement } from 'react'
4 | import type { JSX } from 'react'
5 |
6 | export const ProviderComposer: Component<{
7 | contexts: JSX.Element[]
8 | }> = ({ contexts, children }) =>
9 | contexts.reduceRight(
10 | (kids: any, parent: any) => cloneElement(parent, { children: kids }),
11 | children,
12 | )
13 |
--------------------------------------------------------------------------------
/apps/core/src/constants/path.constant.ts:
--------------------------------------------------------------------------------
1 | import { homedir } from 'node:os'
2 | import { join } from 'node:path'
3 |
4 | import { isDev } from '../global/env.global'
5 |
6 | const appName = 'nest-app-template'
7 | export const HOME = homedir()
8 | export const DATA_DIR = isDev
9 | ? join(process.cwd(), './tmp')
10 | : join(HOME, `.${appName}`)
11 |
12 | export const LOG_DIR = join(DATA_DIR, 'log')
13 |
--------------------------------------------------------------------------------
/apps/core/src/modules/user/user.dto.ts:
--------------------------------------------------------------------------------
1 | import { createZodDto } from '@wahyubucil/nestjs-zod-openapi'
2 | import { createSelectSchema } from 'drizzle-zod'
3 | import { users } from '@packages/drizzle/schema'
4 |
5 | const selectUserSchema = createSelectSchema(users, {
6 | name: (schema) => schema.name.openapi({ description: 'User name' }),
7 | })
8 | export class UserSessionDto extends createZodDto(selectUserSchema) {}
9 |
--------------------------------------------------------------------------------
/apps/web/readme.md:
--------------------------------------------------------------------------------
1 | # Vite React Tailwind Template
2 |
3 | This template provide toolchain below:
4 |
5 | - Vite
6 | - React, ReactDOM
7 | - Biomejs
8 | - Prettier
9 | - Git Hook (simple-git-hook, Lint Staged)
10 | - TailwindCSS 3
11 | - daisyui
12 | - React Router DOM (auto generated routes)
13 | - Auth.js
14 |
15 | # Usage
16 |
17 | ```sh
18 | pnpm i
19 | pnpm dev
20 | ```
21 |
22 | # Screenshot
23 |
24 |
--------------------------------------------------------------------------------
/apps/core/src/processors/gateway/gateway.module.ts:
--------------------------------------------------------------------------------
1 | import { Global, Module } from '@nestjs/common'
2 |
3 | import { SharedGateway } from './shared/events.gateway'
4 | import { WebEventsGateway } from './web/events.gateway'
5 |
6 | @Global()
7 | @Module({
8 | imports: [],
9 | providers: [WebEventsGateway, SharedGateway],
10 | exports: [WebEventsGateway, SharedGateway],
11 | })
12 | export class GatewayModule {}
13 |
--------------------------------------------------------------------------------
/apps/core/src/common/exceptions/biz.exception.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ErrorCode,
3 | type ErrorCodeEnum,
4 | } from '@core/constants/error-code.constant'
5 | import { HttpException } from '@nestjs/common'
6 |
7 | export class BizException extends HttpException {
8 | constructor(public code: ErrorCodeEnum) {
9 | const [message, chMessage, status] = ErrorCode[code]
10 | super(HttpException.createBody({ code, message, chMessage }), status)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "config:base",
4 | "group:allNonMajor",
5 | ":automergePatch",
6 | ":automergeTesters",
7 | ":automergeLinters",
8 | ":rebaseStalePrs"
9 | ],
10 | "labels": ["dependencies"],
11 | "rangeStrategy": "bump",
12 | "packageRules": [
13 | {
14 | "updateTypes": ["major"],
15 | "labels": ["UPDATE-MAJOR"]
16 | }
17 | ],
18 | "ignoreDeps": ["nanoid", "chalk", "consola"]
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/src/global.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | export type Component = FC
3 |
4 | export type ComponentType = {
5 | className?: string
6 | } & PropsWithChildren &
7 | P
8 | export type Nullable = T | null | undefined
9 |
10 | export const APP_DEV_CWD: string
11 | export const APP_NAME: string
12 |
13 | interface ImportMetaEnv {
14 | VITE_API_URL: string
15 | }
16 | }
17 |
18 | export {}
19 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/common/useTitle.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 |
3 | const titleTemplate = `%s | ${APP_NAME}`
4 | export const useTitle = (title?: Nullable) => {
5 | const currentTitleRef = useRef(document.title)
6 | useEffect(() => {
7 | if (!title) return
8 |
9 | document.title = titleTemplate.replace('%s', title)
10 | return () => {
11 | document.title = currentTitleRef.current
12 | }
13 | }, [title])
14 | }
15 |
--------------------------------------------------------------------------------
/apps/core/src/processors/gateway/shared/events.gateway.ts:
--------------------------------------------------------------------------------
1 | import { BusinessEvents } from '@core/constants/business-event.constant'
2 | import { Injectable } from '@nestjs/common'
3 |
4 | import { WebEventsGateway } from '../web/events.gateway'
5 |
6 | @Injectable()
7 | export class SharedGateway {
8 | constructor(private readonly web: WebEventsGateway) {}
9 |
10 | broadcast(event: BusinessEvents, data: any) {
11 | this.web.broadcast(event, data)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/apps/core/src/constants/cache.constant.ts:
--------------------------------------------------------------------------------
1 | import { name } from 'package.json'
2 |
3 | export enum RedisKeys {
4 | JWTStore = 'jwt_store',
5 | /** HTTP 请求缓存 */
6 | HTTPCache = 'http_cache',
7 |
8 | ConfigCache = 'config_cache',
9 |
10 | /** 最大在线人数 */
11 | MaxOnlineCount = 'max_online_count',
12 |
13 | // Article count
14 | //
15 | Read = 'read',
16 | Like = 'like',
17 | }
18 | export const API_CACHE_PREFIX = `${name}#api:`
19 |
20 | export enum CacheKeys {}
21 |
--------------------------------------------------------------------------------
/packages/utils/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2021",
4 | "experimentalDecorators": true,
5 | "module": "CommonJS",
6 | "strict": true,
7 | "noImplicitAny": false,
8 | "declaration": true,
9 | "outDir": "./dist",
10 | "removeComments": true,
11 | "sourceMap": false,
12 | "allowSyntheticDefaultImports": true,
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | },
16 | "exclude": [
17 | "dist",
18 | ]
19 | }
--------------------------------------------------------------------------------
/packages/compiled/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es2021",
4 | "experimentalDecorators": true,
5 | "module": "CommonJS",
6 | "strict": true,
7 | "noImplicitAny": false,
8 | "declaration": true,
9 | "outDir": "./dist",
10 | "removeComments": true,
11 | "sourceMap": false,
12 | "allowSyntheticDefaultImports": true,
13 | "esModuleInterop": true,
14 | "skipLibCheck": true,
15 | },
16 | "exclude": [
17 | "dist",
18 | ]
19 | }
--------------------------------------------------------------------------------
/drizzle/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2021",
5 | "experimentalDecorators": true,
6 | "module": "CommonJS",
7 | "strict": true,
8 | "noImplicitAny": false,
9 | "declaration": false,
10 | "outDir": ".",
11 | "removeComments": true,
12 | "sourceMap": false,
13 | "allowSyntheticDefaultImports": true,
14 | "esModuleInterop": true,
15 | "skipLibCheck": true,
16 | },
17 | "exclude": [
18 | "dist",
19 | ]
20 | }
--------------------------------------------------------------------------------
/apps/core/src/shared/interface/paginator.interface.ts:
--------------------------------------------------------------------------------
1 | export interface PaginationResult {
2 | data: T[]
3 | pagination: Paginator
4 | }
5 | export class Paginator {
6 | /**
7 | * 总条数
8 | */
9 | readonly total: number
10 | /**
11 | * 一页多少条
12 | */
13 | readonly size: number
14 | /**
15 | * 当前页
16 | */
17 | readonly currentPage: number
18 | /**
19 | * 总页数
20 | */
21 | readonly totalPage: number
22 | readonly hasNextPage: boolean
23 | readonly hasPrevPage: boolean
24 | }
25 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/loading.tsx:
--------------------------------------------------------------------------------
1 | import { clsxm } from '~/lib/cn'
2 |
3 | interface LoadingCircleProps {
4 | size: 'small' | 'medium' | 'large'
5 | }
6 |
7 | const sizeMap = {
8 | small: 'text-md',
9 | medium: 'text-xl',
10 | large: 'text-3xl',
11 | }
12 | export const LoadingCircle: Component = ({
13 | className,
14 | size,
15 | }) => (
16 |
17 |
18 |
19 | )
20 |
--------------------------------------------------------------------------------
/test/mock/processors/database/database.module.ts:
--------------------------------------------------------------------------------
1 | import { DatabaseService } from '@core/processors/database/database.service'
2 | import { Global, Module } from '@nestjs/common'
3 |
4 | // import { MockedDatabaseService } from './database.service'
5 |
6 | // const mockDatabaseService = {
7 | // provide: DatabaseService,
8 | // useClass: DatabaseService,
9 | // }
10 | @Module({
11 | providers: [DatabaseService],
12 | exports: [DatabaseService],
13 | })
14 | @Global()
15 | export class MockedDatabaseModule {}
16 |
--------------------------------------------------------------------------------
/packages/compiled/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@packages/compiled",
3 | "private": true,
4 | "description": "",
5 | "type": "module",
6 | "license": "MIT",
7 | "author": "Innei ",
8 | "main": "dist/index.cjs",
9 | "exports": {
10 | ".": {
11 | "require": "./dist/index.cjs"
12 | }
13 | },
14 | "scripts": {
15 | "build": "tsup"
16 | },
17 | "devDependencies": {
18 | "@auth/core": "0.37.4",
19 | "@auth/drizzle-adapter": "1.7.4",
20 | "typescript": "^5.7.2"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/src/pages/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet, redirect } from 'react-router-dom'
2 |
3 | import { getSession } from '~/api/session'
4 | import { MainLayoutHeader } from '~/modules/main-layout/MainLayoutHeader'
5 |
6 | export const loader = async () => {
7 | const session = await getSession()
8 |
9 | if (!session) {
10 | return redirect('/login')
11 | }
12 |
13 | return null
14 | }
15 | export const Component: Component = () => {
16 | return (
17 |
18 |
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/biz/useSession.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from '@tanstack/react-query'
2 | import type { users } from '@packages/drizzle/schema'
3 |
4 | import { apiFetch } from '~/lib/api-fetch'
5 |
6 | export const useSession = () => {
7 | const { data } = useQuery({
8 | queryKey: ['auth', 'session'],
9 | queryFn: async () => {
10 | return apiFetch('/users/me', {
11 | method: 'GET',
12 | headers: {
13 | 'Content-Type': 'application/json',
14 | },
15 | })
16 | },
17 | })
18 |
19 | return data
20 | }
21 |
--------------------------------------------------------------------------------
/apps/web/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { createRoot } from 'react-dom/client'
2 |
3 | import './styles/index.css'
4 |
5 | import { ClickToComponent } from 'click-to-react-component'
6 | import React from 'react'
7 | import { RouterProvider } from 'react-router-dom'
8 |
9 | import { initializeApp } from './initialize'
10 | import { router } from './router'
11 |
12 | initializeApp()
13 | const $container = document.querySelector('#root') as HTMLElement
14 |
15 | createRoot($container).render(
16 |
17 |
18 |
19 | ,
20 | )
21 |
--------------------------------------------------------------------------------
/apps/web/src/lib/query-client.ts:
--------------------------------------------------------------------------------
1 | import { QueryClient } from '@tanstack/react-query'
2 | import { FetchError } from 'ofetch'
3 |
4 | const queryClient = new QueryClient({
5 | defaultOptions: {
6 | queries: {
7 | gcTime: Infinity,
8 | retryDelay: 1000,
9 | retry(failureCount, error) {
10 | console.error(error)
11 | if (error instanceof FetchError && error.statusCode === undefined) {
12 | return false
13 | }
14 |
15 | return !!(3 - failureCount)
16 | },
17 | // throwOnError: import.meta.env.DEV,
18 | },
19 | },
20 | })
21 |
22 | export { queryClient }
23 |
--------------------------------------------------------------------------------
/apps/core/src/common/decorators/ip.decorator.ts:
--------------------------------------------------------------------------------
1 | import { getIp } from '@core/shared/utils/ip.util'
2 | import { type ExecutionContext, createParamDecorator } from '@nestjs/common'
3 | import type { FastifyRequest } from 'fastify'
4 |
5 | export type IpRecord = {
6 | ip: string
7 | agent: string
8 | }
9 | export const IpLocation = createParamDecorator(
10 | (data: unknown, ctx: ExecutionContext) => {
11 | const request = ctx.switchToHttp().getRequest()
12 | const ip = getIp(request)
13 | const agent = request.headers['user-agent']
14 | return {
15 | ip,
16 | agent,
17 | }
18 | },
19 | )
20 |
--------------------------------------------------------------------------------
/apps/web/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from 'react-router-dom'
2 | import type { FC } from 'react'
3 |
4 | import { useAppIsReady } from './atoms/app'
5 | import { RootProviders } from './providers/root-providers'
6 |
7 | export const App: FC = () => {
8 | return (
9 |
10 |
11 |
12 | )
13 | }
14 |
15 | const AppLayer = () => {
16 | const appIsReady = useAppIsReady()
17 | return appIsReady ? :
18 | }
19 |
20 | const AppSkeleton = () => {
21 | return null
22 | }
23 | // eslint-disable-next-line import/no-default-export
24 | export default App
25 |
--------------------------------------------------------------------------------
/apps/core/src/modules/user/user.controller.ts:
--------------------------------------------------------------------------------
1 | import { RequestContext } from '@core/common/contexts/request.context'
2 | import { ApiController } from '@core/common/decorators/api-controller.decorator'
3 | import { Auth } from '@core/common/decorators/auth.decorator'
4 | import { Get } from '@nestjs/common'
5 | import { ApiOkResponse } from '@nestjs/swagger'
6 | import { UserSessionDto } from './user.dto'
7 |
8 | @ApiController('users')
9 | @Auth()
10 | export class UserController {
11 | @Get('/me')
12 | @ApiOkResponse({
13 | type: UserSessionDto,
14 | })
15 | async me() {
16 | return RequestContext.currentSession().user
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/common/useIsOnline.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export const useIsOnline = () => {
4 | const [isOnline, setIsOnline] = useState(navigator.onLine)
5 |
6 | useEffect(() => {
7 | const handleOnline = () => setIsOnline(true)
8 | const handleOffline = () => setIsOnline(false)
9 |
10 | window.addEventListener('online', handleOnline)
11 | window.addEventListener('offline', handleOffline)
12 |
13 | return () => {
14 | window.removeEventListener('online', handleOnline)
15 | window.removeEventListener('offline', handleOffline)
16 | }
17 | }, [])
18 |
19 | return isOnline
20 | }
21 |
--------------------------------------------------------------------------------
/drizzle/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@packages/drizzle",
3 | "private": true,
4 | "description": "",
5 | "license": "MIT",
6 | "author": "Innei ",
7 | "main": "./index.js",
8 | "exports": {
9 | ".": "./index.js",
10 | "./schema": "./schema.js"
11 | },
12 | "scripts": {
13 | "build": "tsc"
14 | },
15 | "dependencies": {
16 | "@packages/compiled": "workspace:*",
17 | "@packages/utils": "workspace:*",
18 | "drizzle-kit": "0.29.1",
19 | "drizzle-orm": "0.37.0",
20 | "pg": "8.13.1",
21 | "postgres": "3.4.5"
22 | },
23 | "devDependencies": {
24 | "typescript": "^5.7.2"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/test/mock/processors/database/database.service.ts:
--------------------------------------------------------------------------------
1 | import { inspect } from 'node:util'
2 |
3 | import { DATABASE } from '@core/app.config'
4 | import { createDrizzle } from '@packages/drizzle'
5 | import { Injectable, Logger } from '@nestjs/common'
6 |
7 | @Injectable()
8 | export class DatabaseService {
9 | public drizzle: ReturnType
10 |
11 | constructor() {
12 | const drizzleLogger = new Logger('')
13 | this.drizzle = createDrizzle(DATABASE.url, {
14 | logger: {
15 | logQuery(query, params) {
16 | drizzleLogger.debug(query + inspect(params))
17 | },
18 | },
19 | })
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/src/router.tsx:
--------------------------------------------------------------------------------
1 | import { createBrowserRouter } from 'react-router-dom'
2 |
3 | import { App } from './App'
4 | import { ErrorElement } from './components/common/ErrorElement'
5 | import { NotFound } from './components/common/NotFound'
6 | import { buildGlobRoutes } from './lib/route-builder'
7 |
8 | const globTree = import.meta.glob('./pages/**/*.tsx')
9 | const tree = buildGlobRoutes(globTree)
10 |
11 | export const router = createBrowserRouter([
12 | {
13 | path: '/',
14 | element: ,
15 | children: tree,
16 | errorElement: ,
17 | },
18 | {
19 | path: '*',
20 | element: ,
21 | },
22 | ])
23 |
--------------------------------------------------------------------------------
/apps/core/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "[javascript]": {
4 | "editor.defaultFormatter": "esbenp.prettier-vscode"
5 | },
6 | "[javascriptreact]": {
7 | "editor.defaultFormatter": "esbenp.prettier-vscode"
8 | },
9 | "[typescript]": {
10 | "editor.defaultFormatter": "esbenp.prettier-vscode"
11 | },
12 | "[typescriptreact]": {
13 | "editor.defaultFormatter": "esbenp.prettier-vscode"
14 | },
15 | "editor.codeActionsOnSave": {},
16 | "material-icon-theme.activeIconPack": "nest",
17 | "typescript.tsdk": "node_modules/typescript/lib",
18 | "typescript.preferences.preferTypeOnlyAutoImports": false
19 | }
20 |
--------------------------------------------------------------------------------
/apps/web/vite.config.ts:
--------------------------------------------------------------------------------
1 | import reactRefresh from '@vitejs/plugin-react'
2 | import { defineConfig } from 'vite'
3 | import { checker } from 'vite-plugin-checker'
4 | import tsconfigPaths from 'vite-tsconfig-paths'
5 |
6 | import PKG from './package.json'
7 |
8 | // https://vitejs.dev/config/
9 | export default defineConfig({
10 | plugins: [
11 | reactRefresh(),
12 | tsconfigPaths(),
13 | checker({
14 | typescript: true,
15 | enableBuild: true,
16 | }),
17 | ],
18 | define: {
19 | APP_DEV_CWD: JSON.stringify(process.cwd()),
20 | APP_NAME: JSON.stringify(PKG.name),
21 | },
22 | server: {
23 | port: 9000,
24 | },
25 | })
26 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/button/MotionButton.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef } from 'react'
2 | import { m } from 'framer-motion'
3 | import type { HTMLMotionProps } from 'framer-motion'
4 |
5 | export const MotionButtonBase = forwardRef<
6 | HTMLButtonElement,
7 | HTMLMotionProps<'button'>
8 | >(({ children, ...rest }, ref) => {
9 | return (
10 |
18 | {children}
19 |
20 | )
21 | })
22 |
23 | MotionButtonBase.displayName = 'MotionButtonBase'
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # compiled output
2 | /dist
3 | /node_modules
4 |
5 | # Logs
6 | logs
7 | *.log
8 | npm-debug.log*
9 | yarn-debug.log*
10 | yarn-error.log*
11 | lerna-debug.log*
12 |
13 | # OS
14 | .DS_Store
15 |
16 | # Tests
17 | /coverage
18 | /.nyc_output
19 |
20 | # IDEs and editors
21 | /.idea
22 | .project
23 | .classpath
24 | .c9/
25 | *.launch
26 | .settings/
27 | *.sublime-workspace
28 |
29 | # IDE - VSCode
30 | .vscode/*
31 | !.vscode/settings.json
32 | !.vscode/tasks.json
33 | !.vscode/launch.json
34 | !.vscode/extensions.json
35 |
36 | patch/dist
37 | tmp
38 | out
39 | release.zip
40 |
41 | run
42 |
43 | apps/core/dist
44 |
45 | drizzle/*.js
46 | packages/*/dist
47 | .vite
--------------------------------------------------------------------------------
/ecosystem.config.js:
--------------------------------------------------------------------------------
1 | const { cpus } = require('node:os')
2 | const { execSync } = require('node:child_process')
3 | const nodePath = execSync(`npm root --quiet -g`, { encoding: 'utf-8' }).split(
4 | '\n',
5 | )[0]
6 |
7 | const cpuLen = cpus().length
8 | module.exports = {
9 | apps: [
10 | {
11 | name: 'mx-server',
12 | script: 'index.js',
13 | autorestart: true,
14 | exec_mode: 'cluster',
15 | watch: false,
16 | instances: Math.min(2, cpuLen),
17 | max_memory_restart: '230M',
18 | args: '--color',
19 | env: {
20 | NODE_ENV: 'production',
21 | NODE_PATH: nodePath,
22 | },
23 | },
24 | ],
25 | }
26 |
--------------------------------------------------------------------------------
/apps/core/src/constants/meta.constant.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CACHE_KEY_METADATA,
3 | CACHE_TTL_METADATA,
4 | } from '@nestjs/common/cache/cache.constants'
5 |
6 | export const HTTP_CACHE_KEY_METADATA = CACHE_KEY_METADATA
7 | export const HTTP_CACHE_TTL_METADATA = CACHE_TTL_METADATA
8 | export const HTTP_CACHE_DISABLE = 'cache_module:cache_disable'
9 | export const HTTP_REQUEST_TIME = 'http:req_time'
10 |
11 | export const HTTP_IDEMPOTENCE_KEY = '__idempotence_key__'
12 | export const HTTP_IDEMPOTENCE_OPTIONS = '__idempotence_options__'
13 |
14 | export const HTTP_RES_UPDATE_DOC_COUNT_TYPE = '__updateDocCount__'
15 | export const HTTP_CACHE_META_OPTIONS = 'cache_module:cache_meta_options'
16 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.formatOnSave": true,
3 | "[javascript]": {
4 | "editor.defaultFormatter": "esbenp.prettier-vscode"
5 | },
6 | "[javascriptreact]": {
7 | "editor.defaultFormatter": "esbenp.prettier-vscode"
8 | },
9 | "[typescript]": {
10 | "editor.defaultFormatter": "esbenp.prettier-vscode"
11 | },
12 | "[typescriptreact]": {
13 | "editor.defaultFormatter": "esbenp.prettier-vscode"
14 | },
15 | "material-icon-theme.activeIconPack": "nest",
16 | "typescript.tsdk": "node_modules/typescript/lib",
17 | "typescript.experimental.updateImportsOnPaste": true,
18 | "editor.codeActionsOnSave": {
19 | "quickfix.biome": "explicit"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/test/helper/defineProvider.ts:
--------------------------------------------------------------------------------
1 | export interface Provider {
2 | provide: new (...args: any[]) => T
3 | useValue: Partial
4 | }
5 |
6 | export const defineProvider = (provider: Provider) => {
7 | return provider
8 | }
9 |
10 | export function defineProviders(providers: [Provider]): [Provider]
11 | export function defineProviders(
12 | providers: [Provider, Provider],
13 | ): [Provider, Provider]
14 | export function defineProviders(
15 | providers: [Provider, Provider, Provider],
16 | ): [Provider, Provider, Provider]
17 |
18 | export function defineProviders(providers: Provider[]) {
19 | return providers
20 | }
21 |
--------------------------------------------------------------------------------
/test/lib/reset-db.ts:
--------------------------------------------------------------------------------
1 | import { schema as schemas } from '@packages/drizzle'
2 |
3 | import { drizzle } from './drizzle'
4 |
5 | const noop = () => {}
6 | // eslint-disable-next-line import/no-default-export
7 | export default async () => {
8 | drizzle
9 | .transaction(async (db) => {
10 | // for (const key in schemas) {
11 | // console.log('key', key, schemas[key])
12 | // await db.delete(schemas[key])
13 | // }
14 | await db.delete(schemas.users)
15 | await db.delete(schemas.accounts)
16 | await db.delete(schemas.authenticators)
17 | await db.delete(schemas.sessions)
18 | await db.delete(schemas.verificationTokens)
19 | })
20 | .catch(noop)
21 | }
22 |
--------------------------------------------------------------------------------
/apps/core/src/shared/dto/pager.dto.ts:
--------------------------------------------------------------------------------
1 | import { createZodDto } from '@wahyubucil/nestjs-zod-openapi'
2 | import { z } from 'zod'
3 |
4 | export const basePagerSchema = z.object({
5 | size: z.coerce.number().int().min(1).max(50).default(10).optional(),
6 | page: z.coerce.number().int().min(1).default(1).optional(),
7 | sortBy: z.string().optional(),
8 | sortOrder: z.coerce.number().or(z.literal(1)).or(z.literal(-1)).optional(),
9 | })
10 |
11 | export class PagerDto extends createZodDto(basePagerSchema) {}
12 |
13 | const withYearPagerSchema = basePagerSchema.extend({
14 | year: z.coerce.number().int().min(1970).max(2100),
15 | })
16 |
17 | export class WithYearPagerDto extends createZodDto(withYearPagerSchema) {}
18 |
--------------------------------------------------------------------------------
/apps/core/src/common/adapter/io.adapter.ts:
--------------------------------------------------------------------------------
1 | import { redisSubPub } from '@core/utils/redis-sub-pub.util'
2 | import { IoAdapter } from '@nestjs/platform-socket.io'
3 | import { createAdapter } from '@socket.io/redis-adapter'
4 |
5 | export const RedisIoAdapterKey = 'meta-socket'
6 |
7 | export class RedisIoAdapter extends IoAdapter {
8 | createIOServer(port: number, options?: any) {
9 | const server = super.createIOServer(port, options)
10 |
11 | const { pubClient, subClient } = redisSubPub
12 |
13 | const redisAdapter = createAdapter(pubClient, subClient, {
14 | key: RedisIoAdapterKey,
15 | requestsTimeout: 10000,
16 | })
17 | server.adapter(redisAdapter)
18 | return server
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/test/mock/helper/helper.event.ts:
--------------------------------------------------------------------------------
1 | import { type Mock, vi } from 'vitest'
2 |
3 | import { EventManagerService } from '@core/processors/helper/helper.event.service'
4 | import { defineProvider } from '@test/helper/defineProvider'
5 |
6 | export class MockedEventManagerService {
7 | constructor() {}
8 |
9 | emit = vi.fn().mockResolvedValue(null) as Mock
10 |
11 | event = vi.fn().mockResolvedValue(null) as Mock
12 |
13 | get broadcast() {
14 | return this.emit
15 | }
16 | }
17 |
18 | export const mockedEventManagerService = new MockedEventManagerService()
19 |
20 | export const mockedEventManagerServiceProvider = defineProvider({
21 | provide: EventManagerService,
22 | useValue: mockedEventManagerService,
23 | })
24 |
--------------------------------------------------------------------------------
/apps/web/src/pages/(auth)/login/index.tsx:
--------------------------------------------------------------------------------
1 | import { redirect } from 'react-router-dom'
2 |
3 | import { useSession } from '~/hooks/biz/useSession'
4 | import { signIn } from '~/lib/auth'
5 |
6 | export const Component = () => {
7 | const session = useSession()
8 | if (session) {
9 | redirect('/')
10 | return null
11 | }
12 |
13 | return (
14 |
15 |
Login to App
16 |
17 |
25 |
26 | )
27 | }
28 |
--------------------------------------------------------------------------------
/dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20-alpine as builder
2 | WORKDIR /app
3 | COPY . .
4 | RUN apk add git make g++ alpine-sdk python3 py3-pip unzip
5 | RUN npm i -g pnpm
6 | RUN pnpm install
7 | RUN npm run build
8 |
9 | FROM node:20-alpine
10 | RUN apk add zip unzip bash --no-cache
11 | RUN npm i -g pnpm
12 | WORKDIR /app
13 | COPY --from=builder /app/apps/core/dist apps/core/dist
14 |
15 | ENV NODE_ENV=production
16 | COPY package.json ./
17 | COPY pnpm-lock.yaml ./
18 | COPY pnpm-workspace.yaml ./
19 | COPY apps ./apps/
20 | COPY .npmrc ./
21 | COPY external ./external/
22 |
23 | RUN pnpm install --prod
24 |
25 | COPY docker-clean.sh ./
26 | RUN sh docker-clean.sh
27 |
28 | ENV TZ=Asia/Shanghai
29 | EXPOSE 3333
30 |
31 | CMD ["pnpm", "-C apps/core run start:prod"]
32 |
--------------------------------------------------------------------------------
/apps/core/src/processors/cache/cache.module.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Cache module.
3 | * @file Cache 全局模块
4 | * @module processor/cache/module
5 | * @author Surmon
6 | */
7 | import { CacheModule as NestCacheModule } from '@nestjs/cache-manager'
8 | import { Global, Module } from '@nestjs/common'
9 |
10 | import { CacheConfigService } from './cache.config.service'
11 | import { CacheService } from './cache.service'
12 |
13 | @Global()
14 | @Module({
15 | imports: [
16 | NestCacheModule.registerAsync({
17 | useClass: CacheConfigService,
18 | inject: [CacheConfigService],
19 | }),
20 | ],
21 | providers: [CacheConfigService, CacheService],
22 | exports: [CacheService],
23 | })
24 | export class CacheModule {}
25 |
--------------------------------------------------------------------------------
/apps/core/src/processors/database/database.service.ts:
--------------------------------------------------------------------------------
1 | import { DATABASE } from '@core/app.config'
2 | import { createDrizzle, migrateDb } from '@packages/drizzle'
3 | import { Injectable, OnModuleInit } from '@nestjs/common'
4 | // const drizzleLogger = new Logger('')
5 |
6 | export const db = createDrizzle(DATABASE.url, {
7 | // logger: {
8 | // logQuery(query, params) {
9 | // drizzleLogger.debug(query + inspect(params))
10 | // },
11 | // },
12 | })
13 |
14 | @Injectable()
15 | export class DatabaseService implements OnModuleInit {
16 | public drizzle: ReturnType
17 |
18 | constructor() {
19 | this.drizzle = db
20 | }
21 |
22 | async onModuleInit() {
23 | await migrateDb(DATABASE.url)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/external/pino/index.js:
--------------------------------------------------------------------------------
1 | // why this, because we dont need pino logger, and this logger can not bundle whole package into only one file with ncc.
2 | // only work with fastify v4+ with pino v8+
3 |
4 | module.exports = {
5 | symbols: {
6 | // https://github.com/pinojs/pino/blob/master/lib/symbols.js
7 | serializersSym: Symbol.for('pino.serializers'),
8 | },
9 | stdSerializers: {
10 | error: function asErrValue(err) {
11 | const obj = {
12 | type: err.constructor.name,
13 | msg: err.message,
14 | stack: err.stack,
15 | }
16 | for (const key in err) {
17 | if (obj[key] === undefined) {
18 | obj[key] = err[key]
19 | }
20 | }
21 | return obj
22 | },
23 | },
24 | }
25 |
--------------------------------------------------------------------------------
/apps/core/src/global/index.global.ts:
--------------------------------------------------------------------------------
1 | import { mkdirSync } from 'node:fs'
2 |
3 | import crypto from 'node:crypto'
4 | import { DATA_DIR, LOG_DIR } from '@core/constants/path.constant'
5 | import { Logger } from '@nestjs/common'
6 |
7 | import './dayjs.global'
8 |
9 | import chalk from 'chalk'
10 |
11 | // 建立目录
12 | function mkdirs() {
13 | mkdirSync(DATA_DIR, { recursive: true })
14 | Logger.log(chalk.blue(`Data dir is make up: ${DATA_DIR}`))
15 |
16 | mkdirSync(LOG_DIR, { recursive: true })
17 | Logger.log(chalk.blue(`Log dir is make up: ${LOG_DIR}`))
18 | }
19 |
20 | export function register() {
21 | mkdirs()
22 |
23 | if (!globalThis.crypto)
24 | // @ts-expect-error
25 | // Compatibility with node 18
26 | globalThis.crypto = crypto.webcrypto
27 | }
28 |
--------------------------------------------------------------------------------
/apps/core/src/modules/auth/auth.middleware.ts:
--------------------------------------------------------------------------------
1 | import { Inject, type NestMiddleware } from '@nestjs/common'
2 | import type { IncomingMessage, ServerResponse } from 'node:http'
3 | import { AuthConfigInjectKey } from './auth.constant'
4 | import { CreateAuth, ServerAuthConfig } from './auth.implement'
5 |
6 | export class AuthMiddleware implements NestMiddleware {
7 | constructor(
8 | @Inject(AuthConfigInjectKey) private readonly config: ServerAuthConfig,
9 | ) {}
10 | authHandler = CreateAuth(this.config)
11 | async use(req: IncomingMessage, res: ServerResponse, next: () => void) {
12 | if (req.method !== 'GET' && req.method !== 'POST') {
13 | next()
14 | return
15 | }
16 |
17 | await this.authHandler(req, res)
18 |
19 | next()
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/zod.util.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod'
2 |
3 | export function makeOptionalPropsNullable(
4 | schema: Schema,
5 | ) {
6 | const entries = Object.entries(schema.shape) as [
7 | keyof Schema['shape'],
8 | z.ZodTypeAny,
9 | ][]
10 | const newProps = entries.reduce(
11 | (acc, [key, value]) => {
12 | acc[key] =
13 | value instanceof z.ZodOptional
14 | ? value.unwrap().nullable()
15 | : value.optional()
16 | return acc
17 | },
18 | {} as {
19 | [key in keyof Schema['shape']]: Schema['shape'][key] extends z.ZodOptional<
20 | infer T
21 | >
22 | ? z.ZodOptional
23 | : z.ZodOptional
24 | },
25 | )
26 | return z.object(newProps)
27 | }
28 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/ip.util.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module utils/ip
3 | * @description IP utility functions
4 | */
5 | import type { IncomingMessage } from 'node:http'
6 | import type { FastifyRequest } from 'fastify'
7 |
8 | export const getIp = (request: FastifyRequest | IncomingMessage) => {
9 | const _ = request as any
10 |
11 | let ip: string =
12 | _.headers['x-forwarded-for'] ||
13 | _.ip ||
14 | _.raw.connection.remoteAddress ||
15 | _.raw.socket.remoteAddress ||
16 | undefined
17 | if (ip && ip.split(',').length > 0) {
18 | ip = ip.split(',')[0]
19 | }
20 | return ip
21 | }
22 |
23 | export const parseRelativeUrl = (path: string) => {
24 | if (!path || !path.startsWith('/')) {
25 | return new URL('http://a.com')
26 | }
27 | return new URL(`http://a.com${path}`)
28 | }
29 |
--------------------------------------------------------------------------------
/apps/web/cssAsPlugin.js:
--------------------------------------------------------------------------------
1 | // https://github.com/tailwindlabs/tailwindcss-intellisense/issues/227#issuecomment-1462034856
2 | // cssAsPlugin.js
3 | const { readFileSync } = require('node:fs')
4 | const postcss = require('postcss')
5 | const postcssJs = require('postcss-js')
6 |
7 | require.extensions['.css'] = function (module, filename) {
8 | module.exports = ({ addBase, addComponents, addUtilities }) => {
9 | const css = readFileSync(filename, 'utf8')
10 | const root = postcss.parse(css)
11 | const jss = postcssJs.objectify(root)
12 |
13 | if ('@layer base' in jss) {
14 | addBase(jss['@layer base'])
15 | }
16 | if ('@layer components' in jss) {
17 | addComponents(jss['@layer components'])
18 | }
19 | if ('@layer utilities' in jss) {
20 | addUtilities(jss['@layer utilities'])
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/drizzle/index.ts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path'
2 | import { drizzle } from 'drizzle-orm/postgres-js'
3 | import { migrate } from 'drizzle-orm/postgres-js/migrator'
4 | import postgres from 'postgres'
5 |
6 | import * as schema from './schema'
7 | import type { DrizzleConfig } from 'drizzle-orm'
8 |
9 | export const createDrizzle = (
10 | url: string,
11 | options: Omit,
12 | ) => {
13 | const client = postgres(url, {})
14 | return drizzle(client, {
15 | schema,
16 | ...options,
17 | })
18 | }
19 |
20 | export const migrateDb = async (url: string) => {
21 | const migrationConnection = postgres(url, { max: 1 })
22 |
23 | await migrate(drizzle(migrationConnection), {
24 | migrationsFolder: resolve(__dirname, '.'),
25 | })
26 | }
27 |
28 | export { schema }
29 |
30 | export * from 'drizzle-orm'
31 |
--------------------------------------------------------------------------------
/test/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { AppController } from '@core/app.controller'
2 | import { fastifyApp } from '@core/common/adapter/fastify.adapter'
3 | import { Test, type TestingModule } from '@nestjs/testing'
4 | import type { NestFastifyApplication } from '@nestjs/platform-fastify'
5 |
6 | describe('AppController (e2e)', () => {
7 | let app: NestFastifyApplication
8 |
9 | beforeEach(async () => {
10 | const moduleFixture: TestingModule = await Test.createTestingModule({
11 | controllers: [AppController],
12 | }).compile()
13 |
14 | app = moduleFixture.createNestApplication(fastifyApp)
15 | await app.init()
16 | await app.getHttpAdapter().getInstance().ready()
17 | })
18 |
19 | it('/ (GET)', () => {
20 | return app.inject('/').then((res) => {
21 | expect(res.statusCode).toBe(200)
22 | })
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/apps/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2021",
5 | "emitDecoratorMetadata": true,
6 | "experimentalDecorators": true,
7 | "baseUrl": ".",
8 | "module": "CommonJS",
9 | "paths": {
10 | "@core": [
11 | "./src"
12 | ],
13 | "@core/*": [
14 | "./src/*"
15 | ],
16 | "@packages/utils": [
17 | "../../packages/utils"
18 | ]
19 | },
20 | "resolveJsonModule": true,
21 | "strictNullChecks": true,
22 | "noImplicitAny": false,
23 | "declaration": true,
24 | "outDir": "./dist",
25 | "removeComments": true,
26 | "sourceMap": true,
27 | "allowSyntheticDefaultImports": true,
28 | "esModuleInterop": true,
29 | "skipLibCheck": true
30 | },
31 | "exclude": [
32 | "dist",
33 | "tmp"
34 | ]
35 | }
--------------------------------------------------------------------------------
/apps/web/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster as Sonner } from 'sonner'
2 |
3 | type ToasterProps = React.ComponentProps
4 |
5 | const Toaster = ({ ...props }: ToasterProps) => (
6 |
22 | )
23 |
24 | export { Toaster }
25 |
--------------------------------------------------------------------------------
/packages/compiled/index.ts:
--------------------------------------------------------------------------------
1 | import * as AuthCore from '@auth/core'
2 | import * as AuthCoreAdapters from '@auth/core/adapters'
3 |
4 | import * as AuthCoreErrors from '@auth/core/errors'
5 | import * as AuthCoreGithub from '@auth/core/providers/github'
6 | import * as AuthCoreGoogle from '@auth/core/providers/google'
7 |
8 | export const authjs = {
9 | ...AuthCore,
10 | ...AuthCoreAdapters,
11 |
12 | ...AuthCoreErrors,
13 | providers: {
14 | google: AuthCoreGoogle.default,
15 | github: AuthCoreGithub.default,
16 | },
17 | }
18 |
19 | export type * from '@auth/core/errors'
20 | export type * from '@auth/core/types'
21 | export type * from '@auth/core/providers/google'
22 | export type * from '@auth/core/providers/github'
23 | export * from '@auth/core'
24 |
25 | export type * from '@auth/core/adapters'
26 |
27 | export { DrizzleAdapter } from '@auth/drizzle-adapter'
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2021",
5 | "emitDecoratorMetadata": true,
6 | "experimentalDecorators": true,
7 | "baseUrl": ".",
8 | "module": "CommonJS",
9 | "paths": {
10 | "@core": [
11 | "./apps/core/src"
12 | ],
13 | "@core/*": [
14 | "./apps/core/src/*"
15 | ],
16 | "@test": [
17 | "."
18 | ],
19 | "@test/*": [
20 | "./*"
21 | ],
22 | },
23 | "resolveJsonModule": true,
24 | "strictNullChecks": true,
25 | "noImplicitAny": false,
26 | "declaration": true,
27 | "outDir": "./dist",
28 | "removeComments": true,
29 | "sourceMap": true,
30 | "allowSyntheticDefaultImports": true,
31 | "esModuleInterop": true,
32 | "skipLibCheck": true,
33 | },
34 | "exclude": [
35 | "dist",
36 | "tmp"
37 | ]
38 | }
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "jsx": "preserve",
5 | "lib": [
6 | "DOM",
7 | "DOM.Iterable",
8 | "ESNext"
9 | ],
10 | "module": "ESNext",
11 | "moduleResolution": "Node",
12 | "paths": {
13 | "~/*": [
14 | "./src/*"
15 | ],
16 | "@pkg": [
17 | "./package.json"
18 | ],
19 | "@core/*": [
20 | "../core/src/*"
21 | ]
22 | },
23 | "resolveJsonModule": true,
24 | "allowJs": false,
25 | "strict": true,
26 | "noImplicitAny": false,
27 | "noEmit": true,
28 | "allowSyntheticDefaultImports": true,
29 | "esModuleInterop": false,
30 | "forceConsistentCasingInFileNames": true,
31 | "isolatedModules": true,
32 | "skipDefaultLibCheck": true,
33 | "skipLibCheck": true,
34 | },
35 | "include": [
36 | "./src/**/*",
37 | ]
38 | }
--------------------------------------------------------------------------------
/packages/utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@packages/utils",
3 | "private": true,
4 | "description": "",
5 | "type": "module",
6 | "license": "MIT",
7 | "author": "Innei ",
8 | "main": "dist/index.cjs",
9 | "module": "dist/index.js",
10 | "exports": {
11 | ".": {
12 | "require": "./dist/index.cjs",
13 | "import": "./dist/index.js"
14 | },
15 | "./_": {
16 | "require": "./dist/_.cjs",
17 | "import": "./dist/_.js"
18 | },
19 | "./snowflake": {
20 | "require": "./dist/snowflake.cjs",
21 | "import": "./dist/snowflake.js"
22 | },
23 | "./nanoid": {
24 | "require": "./dist/nanoid.cjs",
25 | "import": "./dist/nanoid.js"
26 | }
27 | },
28 | "scripts": {
29 | "build": "tsup"
30 | },
31 | "devDependencies": {
32 | "es-toolkit": "^1.29.0",
33 | "nanoid": "5.0.9",
34 | "typescript": "^5.7.2"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/apps/core/src/common/middlewares/request-context.middleware.ts:
--------------------------------------------------------------------------------
1 | // https://github.dev/ever-co/ever-gauzy/packages/core/src/core/context/request-context.middleware.ts
2 |
3 | import * as cls from 'cls-hooked'
4 | import { Injectable } from '@nestjs/common'
5 | import { RequestContext } from '../contexts/request.context'
6 | import type { NestMiddleware } from '@nestjs/common'
7 | import type { IncomingMessage, ServerResponse } from 'node:http'
8 |
9 | @Injectable()
10 | export class RequestContextMiddleware implements NestMiddleware {
11 | use(req: IncomingMessage, res: ServerResponse, next: () => any) {
12 | const requestContext = new RequestContext(req, res)
13 |
14 | const session =
15 | cls.getNamespace(RequestContext.name) ||
16 | cls.createNamespace(RequestContext.name)
17 |
18 | session.run(async () => {
19 | session.set(RequestContext.name, requestContext)
20 | next()
21 | })
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/apps/core/src/processors/gateway/base.gateway.ts:
--------------------------------------------------------------------------------
1 | import { BusinessEvents } from '@core/constants/business-event.constant'
2 | import type { Socket } from 'socket.io'
3 |
4 | export abstract class BaseGateway {
5 | public gatewayMessageFormat(
6 | type: BusinessEvents,
7 | message: any,
8 | code?: number,
9 | ) {
10 | return {
11 | type,
12 | data: message,
13 | code,
14 | }
15 | }
16 |
17 | handleDisconnect(client: Socket) {
18 | client.send(
19 | this.gatewayMessageFormat(
20 | BusinessEvents.GATEWAY_CONNECT,
21 | 'WebSocket 断开',
22 | ),
23 | )
24 | }
25 | handleConnect(client: Socket) {
26 | client.send(
27 | this.gatewayMessageFormat(
28 | BusinessEvents.GATEWAY_CONNECT,
29 | 'WebSocket 已连接',
30 | ),
31 | )
32 | }
33 | }
34 |
35 | export abstract class BroadcastBaseGateway extends BaseGateway {
36 | broadcast(_event: BusinessEvents, _data: any) {}
37 | }
38 |
--------------------------------------------------------------------------------
/apps/web/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Vite App
8 |
9 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Nest Drizzle + Auth.js
2 |
3 | A Simple Nest.js Template Using Drizzle + Postgres, Auth.js.
4 |
5 | ## Demo
6 | 
7 |
8 |
9 | ## Getting Started
10 |
11 | Clone this project. Install the dependencies using pnpm. Copy the example environment variables.
12 |
13 | ```sh
14 | git clone https://github.com/innei-template/nest-drizzle.git
15 | cp .env.template .env
16 | pnpm i
17 | ```
18 |
19 | ## Configure Auth.js
20 |
21 | The configuration is located at `/apps/core/src/modules/auth/auth.config.ts` Please change your desired Provider here, GitHub OAuth is used by default.
22 |
23 | `AUTH_SECRET` is a 64bit hash string, you can generate by this command.
24 |
25 | ```
26 | openssl rand -hex 32
27 | ```
28 |
29 | ### License
30 |
31 | 2024 © Innei, Released under the MIT License.
32 |
33 | > [Personal Website](https://innei.in/) · GitHub [@Innei](https://github.com/innei/)
34 |
--------------------------------------------------------------------------------
/apps/core/src/constants/business-event.constant.ts:
--------------------------------------------------------------------------------
1 | export const enum BusinessEvents {
2 | GATEWAY_CONNECT = 'GATEWAY_CONNECT',
3 | GATEWAY_DISCONNECT = 'GATEWAY_DISCONNECT',
4 |
5 | VISITOR_ONLINE = 'VISITOR_ONLINE',
6 | VISITOR_OFFLINE = 'VISITOR_OFFLINE',
7 |
8 | AUTH_FAILED = 'AUTH_FAILED',
9 |
10 | POST_CREATE = 'POST_CREATE',
11 | }
12 |
13 | /// ============= types =========
14 | //
15 | //
16 |
17 | interface IGatewayConnectData {}
18 |
19 | interface IGatewayDisconnectData {}
20 |
21 | interface IVisitorOnlineData {}
22 |
23 | interface IVisitorOfflineData {}
24 |
25 | interface IAuthFailedData {}
26 |
27 | export type BizEventDataMap = {
28 | [BusinessEvents.GATEWAY_CONNECT]: IGatewayConnectData
29 | [BusinessEvents.GATEWAY_DISCONNECT]: IGatewayDisconnectData
30 | [BusinessEvents.VISITOR_ONLINE]: IVisitorOnlineData
31 | [BusinessEvents.VISITOR_OFFLINE]: IVisitorOfflineData
32 | [BusinessEvents.AUTH_FAILED]: IAuthFailedData
33 | [BusinessEvents.POST_CREATE]: any
34 | }
35 |
--------------------------------------------------------------------------------
/apps/web/src/lib/api-fetch.ts:
--------------------------------------------------------------------------------
1 | import { createFetch } from 'ofetch'
2 |
3 | import { getCsrfToken } from './auth'
4 |
5 | let csrfTokenPromise: Promise | null = null
6 | export const apiFetch = createFetch({
7 | defaults: {
8 | baseURL: import.meta.env.VITE_API_URL,
9 | credentials: 'include',
10 | async onRequest({ options }) {
11 | if (!csrfTokenPromise) csrfTokenPromise = getCsrfToken()
12 |
13 | const csrfToken = await csrfTokenPromise
14 | if (options.method && options.method.toLowerCase() !== 'get') {
15 | if (typeof options.body === 'string') {
16 | options.body = JSON.parse(options.body)
17 | }
18 | if (!options.body) {
19 | options.body = {}
20 | }
21 | if (options.body instanceof FormData) {
22 | options.body.append('csrfToken', csrfToken)
23 | } else {
24 | ;(options.body as Record).csrfToken = csrfToken
25 | }
26 | }
27 | },
28 | },
29 | })
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 寻
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/scripts/workflow/test-server.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | MAX_RETRIES=10
4 |
5 | node -v
6 |
7 | if [[ $? -ne 0 ]]; then
8 | echo "failed to run node"
9 | exit 1
10 | fi
11 |
12 | nohup node dist/main.js 1>/dev/null &
13 | p=$!
14 | echo "started server with pid $p"
15 |
16 | if [[ $? -ne 0 ]]; then
17 | echo "failed to run node index.js"
18 | exit 1
19 | fi
20 |
21 | RETRY=0
22 |
23 | do_request() {
24 | curl -f -m 10 localhost:3333/ping -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36'
25 |
26 | }
27 |
28 | do_request
29 |
30 | while [[ $? -ne 0 ]] && [[ $RETRY -lt $MAX_RETRIES ]]; do
31 | sleep 5
32 | ((RETRY++))
33 | echo -e "RETRY: ${RETRY}\n"
34 | do_request
35 | done
36 | request_exit_code=$?
37 |
38 | echo -e "\nrequest code: ${request_exit_code}\n"
39 |
40 | if [[ $RETRY -gt $MAX_RETRIES ]]; then
41 | echo -n "Unable to run, aborted"
42 | kill -9 $p
43 | exit 1
44 |
45 | elif [[ $request_exit_code -ne 0 ]]; then
46 | echo -n "Request error"
47 | kill -9 $p
48 | exit 1
49 |
50 | else
51 | echo -e "\nSuccessfully acquire homepage, passing"
52 | kill -9 $p
53 | exit 0
54 | fi
55 |
--------------------------------------------------------------------------------
/apps/core/src/common/decorators/http.decorator.ts:
--------------------------------------------------------------------------------
1 | import { HTTP_IDEMPOTENCE_OPTIONS } from '@core/constants/meta.constant'
2 | import * as SYSTEM from '@core/constants/system.constant'
3 | import { SetMetadata } from '@nestjs/common'
4 |
5 | import type { IdempotenceOption } from '../interceptors/idempotence.interceptor'
6 |
7 | /**
8 | * @description 跳过响应体处理
9 | */
10 | const Bypass: MethodDecorator = (
11 | target,
12 | key,
13 | descriptor: PropertyDescriptor,
14 | ) => {
15 | SetMetadata(SYSTEM.RESPONSE_PASSTHROUGH_METADATA, true)(descriptor.value)
16 | }
17 |
18 | /**
19 | * 幂等
20 | */
21 | const Idempotence: (options?: IdempotenceOption) => MethodDecorator =
22 | (options) => (target, key, descriptor: PropertyDescriptor) => {
23 | SetMetadata(HTTP_IDEMPOTENCE_OPTIONS, options || {})(descriptor.value)
24 | }
25 |
26 | /**
27 | * @description 过滤响应体中的字段
28 | */
29 | const ProtectKeys: (keys: string[]) => MethodDecorator =
30 | (keys) => (target, key, descriptor: PropertyDescriptor) => {
31 | SetMetadata(SYSTEM.OMIT_RESPONSE_PROTECT_KEYS, keys)(descriptor.value)
32 | }
33 |
34 | export const HTTPDecorators = {
35 | Bypass,
36 | Idempotence,
37 | ProtectKeys,
38 | }
39 |
--------------------------------------------------------------------------------
/apps/core/src/common/guards/auth.guard.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable dot-notation */
2 | import { isTest } from '@core/global/env.global'
3 | import { AuthService } from '@core/modules/auth/auth.service'
4 |
5 | import { getNestExecutionContextRequest } from '@core/transformers/get-req.transformer'
6 | import {
7 | CanActivate,
8 | ExecutionContext,
9 | Inject,
10 | Injectable,
11 | UnauthorizedException,
12 | } from '@nestjs/common'
13 |
14 | @Injectable()
15 | export class AuthGuard implements CanActivate {
16 | constructor(
17 | @Inject(AuthService)
18 | private readonly authService: AuthService,
19 | ) {}
20 | async canActivate(context: ExecutionContext): Promise {
21 | if (isTest) {
22 | return true
23 | }
24 |
25 | const req = this.getRequest(context)
26 | const session = await this.authService.getSessionUser(req.raw)
27 |
28 | req.raw['session'] = session
29 | req.raw['isAuthenticated'] = !!session
30 |
31 | if (!session) {
32 | throw new UnauthorizedException()
33 | }
34 |
35 | return !!session
36 | }
37 |
38 | getRequest(context: ExecutionContext) {
39 | return getNestExecutionContextRequest(context)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/apps/web/src/lib/jotai.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from 'react'
2 | import { createStore, useAtom, useAtomValue, useSetAtom } from 'jotai'
3 | import { selectAtom } from 'jotai/utils'
4 | import type { Atom, PrimitiveAtom } from 'jotai'
5 |
6 | export const jotaiStore = createStore()
7 |
8 | export const createAtomAccessor = (atom: PrimitiveAtom) =>
9 | [
10 | () => jotaiStore.get(atom),
11 | (value: T) => jotaiStore.set(atom, value),
12 | ] as const
13 |
14 | const options = { store: jotaiStore }
15 | /**
16 | * @param atom - jotai
17 | * @returns - [atom, useAtom, useAtomValue, useSetAtom, jotaiStore.get, jotaiStore.set]
18 | */
19 | export const createAtomHooks = (atom: PrimitiveAtom) =>
20 | [
21 | atom,
22 | () => useAtom(atom, options),
23 | () => useAtomValue(atom, options),
24 | () => useSetAtom(atom, options),
25 | ...createAtomAccessor(atom),
26 | ] as const
27 |
28 | export const createAtomSelector = (atom: Atom) => {
29 | const useHook = (selector: (a: T) => R, deps: any[] = []) =>
30 | useAtomValue(
31 | selectAtom(
32 | atom,
33 | useCallback((a) => selector(a as T), deps),
34 | ),
35 | )
36 |
37 | useHook.__atom = atom
38 | return useHook
39 | }
40 |
--------------------------------------------------------------------------------
/apps/core/src/processors/cache/cache.config.service.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Cache config service.
3 | * @file Cache 配置器
4 | * @module processor/cache/config.service
5 | * @author Surmon
6 | */
7 | // import redisStore from 'cache-manager-redis-store'
8 | import redisStore from 'cache-manager-ioredis'
9 |
10 | import { REDIS } from '@core/app.config'
11 | import { CacheModuleOptions, CacheOptionsFactory } from '@nestjs/cache-manager'
12 | import { Injectable } from '@nestjs/common'
13 |
14 | @Injectable()
15 | export class CacheConfigService implements CacheOptionsFactory {
16 | // 缓存配置
17 | public createCacheOptions(): CacheModuleOptions {
18 | const redisOptions: any = {
19 | host: REDIS.host as string,
20 | port: REDIS.port as number,
21 | }
22 | if (REDIS.password) {
23 | redisOptions.password = REDIS.password
24 | }
25 | return {
26 | store: redisStore,
27 | ttl: REDIS.ttl,
28 | // https://github.com/dabroek/node-cache-manager-redis-store/blob/master/CHANGELOG.md#breaking-changes
29 | // Any value (undefined | null) return true (cacheable) after redisStore v2.0.0
30 | is_cacheable_value: () => true,
31 | max: REDIS.max,
32 | ...redisOptions,
33 | }
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/apps/core/src/common/guards/spider.guard.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @module common/guard/spider.guard
3 | * @description 禁止爬虫的守卫
4 | * @author Innei
5 | */
6 | import { Observable } from 'rxjs'
7 |
8 | import { isDev } from '@core/global/env.global'
9 | import { getNestExecutionContextRequest } from '@core/transformers/get-req.transformer'
10 | import {
11 | CanActivate,
12 | ExecutionContext,
13 | ForbiddenException,
14 | Injectable,
15 | } from '@nestjs/common'
16 |
17 | @Injectable()
18 | export class SpiderGuard implements CanActivate {
19 | canActivate(
20 | context: ExecutionContext,
21 | ): boolean | Promise | Observable {
22 | if (isDev) {
23 | return true
24 | }
25 |
26 | const request = this.getRequest(context)
27 | const headers = request.headers
28 | const ua: string = headers['user-agent'] || ''
29 | const isSpiderUA =
30 | !!/(scrapy|httpclient|axios|python|requests)/i.test(ua) &&
31 | !/(mx-space|rss|google|baidu|bing)/gi.test(ua)
32 | if (ua && !isSpiderUA) {
33 | return true
34 | }
35 | throw new ForbiddenException(`爬虫是被禁止的哦,UA: ${ua}`)
36 | }
37 |
38 | getRequest(context: ExecutionContext) {
39 | return getNestExecutionContextRequest(context)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/test/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es2022",
5 | "emitDecoratorMetadata": true,
6 | "experimentalDecorators": true,
7 | "baseUrl": ".",
8 | "module": "CommonJS",
9 | "paths": {
10 | "@core": [
11 | "../apps/core/src"
12 | ],
13 | "@core/*": [
14 | "../apps/core/src/*"
15 | ],
16 | "@test": [
17 | "."
18 | ],
19 | "@test/*": [
20 | "./*"
21 | ],
22 | "@prisma/client": [
23 | "../prisma/client"
24 | ],
25 | "@prisma/client/*": [
26 | "../prisma/*"
27 | ]
28 | },
29 | "resolveJsonModule": true,
30 | "types": [
31 | "vitest/globals"
32 | ],
33 | "allowJs": true,
34 | "strict": true,
35 | "noImplicitAny": false,
36 | "declaration": true,
37 | "noEmit": true,
38 | "outDir": "./dist",
39 | "removeComments": true,
40 | "sourceMap": true,
41 | "allowSyntheticDefaultImports": true,
42 | "esModuleInterop": true,
43 | "skipLibCheck": true
44 | },
45 | "include": [
46 | "./src/**/*.ts",
47 | "./src/**/*.tsx",
48 | "./src/**/*.js",
49 | "./src/**/*.jsx",
50 | "./**/*.ts",
51 | "../apps/core/src/**/*.ts",
52 | "vitest.config.mts",
53 | ]
54 | }
--------------------------------------------------------------------------------
/apps/web/src/providers/root-providers.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClientProvider } from '@tanstack/react-query'
2 | import { LazyMotion, MotionConfig } from 'framer-motion'
3 | import { Provider } from 'jotai'
4 | import { ThemeProvider } from 'next-themes'
5 | import type { FC, PropsWithChildren } from 'react'
6 |
7 | import { Toaster } from '~/components/ui/sonner'
8 | import { jotaiStore } from '~/lib/jotai'
9 | import { queryClient } from '~/lib/query-client'
10 |
11 | import { StableRouterProvider } from './stable-router-provider'
12 |
13 | const loadFeatures = () =>
14 | import('../framer-lazy-feature').then((res) => res.default)
15 | export const RootProviders: FC = ({ children }) => (
16 |
17 |
24 |
25 |
26 |
27 |
28 | {children}
29 |
30 |
31 |
32 |
33 |
34 |
35 | )
36 |
--------------------------------------------------------------------------------
/apps/core/src/modules/auth/auth.module.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type DynamicModule,
3 | Global,
4 | Inject,
5 | type MiddlewareConsumer,
6 | Module,
7 | type NestModule,
8 | } from '@nestjs/common'
9 |
10 | import { AuthConfigInjectKey } from './auth.constant'
11 | import type { ServerAuthConfig } from './auth.implement'
12 | import { AuthMiddleware } from './auth.middleware'
13 | import { AuthService } from './auth.service'
14 |
15 | @Module({})
16 | @Global()
17 | export class AuthModule implements NestModule {
18 | constructor(
19 | @Inject(AuthConfigInjectKey) private readonly config: ServerAuthConfig,
20 | ) {}
21 | static forRoot(config: ServerAuthConfig): DynamicModule {
22 | return {
23 | module: AuthModule,
24 | global: true,
25 | exports: [AuthService],
26 | providers: [
27 | {
28 | provide: AuthService,
29 | useFactory() {
30 | return new AuthService(config)
31 | },
32 | },
33 | {
34 | provide: AuthConfigInjectKey,
35 | useValue: config,
36 | },
37 | ],
38 | }
39 | }
40 |
41 | configure(consumer: MiddlewareConsumer) {
42 | const config = this.config
43 |
44 | consumer
45 | .apply(AuthMiddleware)
46 | .forRoutes(`${config.basePath || '/auth'}/(.*)`)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/apps/core/src/common/decorators/cache.decorator.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Cache decorator.
3 | * @file 缓存装饰器
4 | * @module decorator/cache
5 | * @author Surmon
6 | */
7 | import * as META from '@core/constants/meta.constant'
8 | import { CacheKey, CacheTTL } from '@nestjs/cache-manager'
9 | import { SetMetadata } from '@nestjs/common'
10 |
11 | // 缓存器配置
12 | interface ICacheOption {
13 | ttl?: number
14 | key?: string
15 | disable?: boolean
16 | }
17 |
18 | /**
19 | * 统配构造器
20 | * @function HttpCache
21 | * @description 两种用法
22 | * @example @HttpCache({ key: CACHE_KEY, ttl: 60 * 60 })
23 | * @example @HttpCache({ disable: true })
24 | */
25 |
26 | export function HttpCache(option: ICacheOption): MethodDecorator {
27 | const { disable, key, ttl = 60 } = option
28 | return (_, __, descriptor: PropertyDescriptor) => {
29 | if (disable) {
30 | SetMetadata(META.HTTP_CACHE_DISABLE, true)(descriptor.value)
31 | return descriptor
32 | }
33 | if (key) {
34 | CacheKey(key)(descriptor.value)
35 | }
36 | if (typeof ttl === 'number' && !Number.isNaN(ttl)) {
37 | CacheTTL(ttl)(descriptor.value)
38 | }
39 | return descriptor
40 | }
41 | }
42 |
43 | HttpCache.disable = (_, __, descriptor) => {
44 | SetMetadata(META.HTTP_CACHE_DISABLE, true)(descriptor.value)
45 | }
46 |
--------------------------------------------------------------------------------
/apps/web/src/atoms/route.ts:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react'
2 | import { atom, useAtomValue } from 'jotai'
3 | import { selectAtom } from 'jotai/utils'
4 | import type { Location, NavigateFunction, Params } from 'react-router-dom'
5 |
6 | import { createAtomHooks } from '~/lib/jotai'
7 |
8 | interface RouteAtom {
9 | params: Readonly>
10 | searchParams: URLSearchParams
11 | location: Location
12 | }
13 |
14 | export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
15 | atom({
16 | params: {},
17 | searchParams: new URLSearchParams(),
18 | location: {
19 | pathname: '',
20 | search: '',
21 | hash: '',
22 | state: null,
23 | key: '',
24 | },
25 | }),
26 | )
27 |
28 | const noop = []
29 | export const useReadonlyRouteSelector = (
30 | selector: (route: RouteAtom) => T,
31 | deps: any[] = noop,
32 | ): T =>
33 | useAtomValue(
34 | useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps),
35 | )
36 |
37 | // Vite HMR will create new router instance, but RouterProvider always stable
38 |
39 | const [, , , , navigate, setNavigate] = createAtomHooks(
40 | atom<{ fn: NavigateFunction | null }>({ fn() {} }),
41 | )
42 | const getStableRouterNavigate = () => navigate().fn
43 | export { getStableRouterNavigate, setNavigate }
44 |
--------------------------------------------------------------------------------
/apps/core/src/common/adapter/fastify.adapter.ts:
--------------------------------------------------------------------------------
1 | import { FastifyAdapter } from '@nestjs/platform-fastify'
2 |
3 | const app: FastifyAdapter = new FastifyAdapter({
4 | trustProxy: true,
5 | })
6 | export { app as fastifyApp }
7 |
8 | app.register(require('@scalar/fastify-api-reference'), {
9 | routePrefix: '/reference',
10 | configuration: {
11 | title: 'Our API Reference',
12 | spec: {
13 | url: '/docs-json',
14 | },
15 | },
16 | })
17 |
18 | app.getInstance().addHook('onRequest', (request, reply, done) => {
19 | // set undefined origin
20 | const origin = request.headers.origin
21 | if (!origin) {
22 | request.headers.origin = request.headers.host
23 | }
24 |
25 | // forbidden php
26 |
27 | const url = request.url
28 |
29 | if (url.endsWith('.php')) {
30 | reply.raw.statusMessage =
31 | 'Eh. PHP is not support on this machine. Yep, I also think PHP is bestest programming language. But for me it is beyond my reach.'
32 |
33 | return reply.code(418).send()
34 | } else if (/\/(adminer|admin|wp-login)$/g.test(url)) {
35 | reply.raw.statusMessage = 'Hey, What the fuck are you doing!'
36 | return reply.code(200).send()
37 | }
38 |
39 | // skip favicon request
40 | if (/favicon.ico$/.test(url) || /manifest.json$/.test(url)) {
41 | return reply.code(204).send()
42 | }
43 |
44 | done()
45 | })
46 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/schema.util.ts:
--------------------------------------------------------------------------------
1 | // type DefaultKeys = 'id' | 'created' | 'modified' | 'deleted'
2 | type DefaultKeys = 'id'
3 | const defaultProjectKeys = ['id', 'created', 'modified', 'deleted'] as const
4 |
5 | type Projection = {
6 | [P in K]: true
7 | }
8 |
9 | export function createProjectionOmit(
10 | obj: T,
11 | keys: K[],
12 | withDefaults: true,
13 | ): Projection
14 | export function createProjectionOmit(
15 | obj: T,
16 | keys: K[],
17 | ): Projection
18 |
19 | export function createProjectionOmit(
20 | obj: T,
21 | keys: K[],
22 | withDefaults: boolean = false,
23 | ): any {
24 | const projection: Partial> = {}
25 |
26 | // Add default keys if withDefaults is true
27 | if (withDefaults) {
28 | defaultProjectKeys.forEach((key) => {
29 | projection[key] = true
30 | })
31 | }
32 |
33 | // Add specified keys
34 | for (const key of keys) {
35 | projection[key] = true
36 | }
37 |
38 | // @ts-ignore
39 | projection.keys = [...keys, ...(withDefaults ? defaultProjectKeys : [])]
40 | return projection as any
41 | }
42 |
43 | export const getProjectionKeys = (projection: Projection): string[] => {
44 | // @ts-expect-error
45 | return projection.keys
46 | }
47 |
--------------------------------------------------------------------------------
/apps/core/src/processors/helper/helper.module.ts:
--------------------------------------------------------------------------------
1 | import { isDev } from '@core/global/env.global'
2 | import { Global, Module, Provider } from '@nestjs/common'
3 | import { EventEmitterModule } from '@nestjs/event-emitter/dist/event-emitter.module'
4 | import { ThrottlerModule } from '@nestjs/throttler'
5 |
6 | import { HttpService } from './helper.http.service'
7 |
8 | const providers: Provider[] = [HttpService]
9 |
10 | @Module({
11 | imports: [
12 | ThrottlerModule.forRoot([
13 | {
14 | ttl: 60,
15 | limit: 100,
16 | },
17 | ]),
18 | EventEmitterModule.forRoot({
19 | wildcard: false,
20 | // the delimiter used to segment namespaces
21 | delimiter: '.',
22 | // set this to `true` if you want to emit the newListener event
23 | newListener: false,
24 | // set this to `true` if you want to emit the removeListener event
25 | removeListener: false,
26 | // the maximum amount of listeners that can be assigned to an event
27 | maxListeners: 10,
28 | // show event name in memory leak message when more than maximum amount of listeners is assigned
29 | verboseMemoryLeak: isDev,
30 | // disable throwing uncaughtException if an error event is emitted and it has no listeners
31 | ignoreErrors: false,
32 | }),
33 | ],
34 |
35 | providers,
36 | exports: providers,
37 | })
38 | @Global()
39 | export class HelperModule {}
40 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Node.js Build CI
5 |
6 | on:
7 | push:
8 | branches: [main]
9 | pull_request:
10 | branches: [main]
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | services:
17 | postgres:
18 | image: postgres
19 |
20 | env:
21 | POSTGRES_PASSWORD: postgres
22 | POSTGRES_USER: postgres
23 | POSTGRES_DB: postgres
24 |
25 | options: >-
26 | --health-cmd pg_isready
27 | --health-interval 10s
28 | --health-timeout 5s
29 | --health-retries 5
30 | ports:
31 | - 5432:5432
32 |
33 | strategy:
34 | matrix:
35 | node-version: [20.x]
36 |
37 | steps:
38 | - uses: actions/checkout@v4
39 | - uses: pnpm/action-setup@v4.0.0
40 |
41 | - name: Use Node.js ${{ matrix.node-version }}
42 | uses: actions/setup-node@v4
43 | with:
44 | node-version: ${{ matrix.node-version }}
45 | cache: pnpm
46 |
47 | - name: Install Dependencies
48 | run: |
49 | pnpm i
50 | - name: Build project
51 | run: |
52 | npm run build
53 |
--------------------------------------------------------------------------------
/apps/core/src/common/interceptors/json-transformer.interceptor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 对响应体进行 JSON 标准的转换
3 | * @author Innei
4 | */
5 | import { isObjectLike } from 'lodash'
6 | import { Observable, map } from 'rxjs'
7 | import snakecaseKeys from 'snakecase-keys'
8 |
9 | import { RESPONSE_PASSTHROUGH_METADATA } from '@core/constants/system.constant'
10 | import {
11 | CallHandler,
12 | ExecutionContext,
13 | Injectable,
14 | NestInterceptor,
15 | } from '@nestjs/common'
16 | import { Reflector } from '@nestjs/core'
17 |
18 | @Injectable()
19 | export class JSONTransformerInterceptor implements NestInterceptor {
20 | constructor(private readonly reflector: Reflector) {}
21 | intercept(context: ExecutionContext, next: CallHandler): Observable {
22 | const handler = context.getHandler()
23 | // 跳过 bypass 装饰的请求
24 | const bypass = this.reflector.get(
25 | RESPONSE_PASSTHROUGH_METADATA,
26 | handler,
27 | )
28 | if (bypass) {
29 | return next.handle()
30 | }
31 | const http = context.switchToHttp()
32 |
33 | if (!http.getRequest()) {
34 | return next.handle()
35 | }
36 |
37 | return next.handle().pipe(
38 | map((data) => {
39 | return this.serialize(data)
40 | }),
41 | )
42 | }
43 |
44 | private serialize(obj: any) {
45 | if (!isObjectLike(obj)) {
46 | return obj
47 | }
48 | return snakecaseKeys(obj, { deep: true })
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/test/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@private/testing",
3 | "private": true,
4 | "scripts": {
5 | "test": "dotenv -e ../.env.test vitest"
6 | },
7 | "dependencies": {
8 | "@nestjs/cache-manager": "2.3.0",
9 | "@nestjs/common": "10.4.13",
10 | "@nestjs/config": "3.3.0",
11 | "@nestjs/core": "10.4.13",
12 | "@nestjs/event-emitter": "2.1.1",
13 | "@nestjs/jwt": "10.2.0",
14 | "@nestjs/passport": "10.0.3",
15 | "@nestjs/platform-fastify": "10.4.13",
16 | "@nestjs/platform-socket.io": "10.4.13",
17 | "@nestjs/schedule": "4.1.1",
18 | "@nestjs/testing": "10.4.13",
19 | "@nestjs/throttler": "6.2.1",
20 | "@nestjs/websockets": "10.4.13",
21 | "@packages/drizzle": "workspace:*",
22 | "@packages/utils": "workspace:*",
23 | "@swc/cli": "0.5.2",
24 | "@types/bcryptjs": "2.4.6",
25 | "@types/lodash": "4.17.13",
26 | "bcryptjs": "2.4.3",
27 | "cross-env": "7.0.3",
28 | "dayjs": "1.11.13",
29 | "dotenv": "16.4.7",
30 | "dotenv-cli": "7.4.4",
31 | "dotenv-expand": "12.0.1",
32 | "drizzle-orm": "0.37.0",
33 | "ioredis": "^5.4.1",
34 | "lodash": "4.17.21",
35 | "nanoid": "5.0.9",
36 | "redis-memory-server": "0.11.0",
37 | "slugify": "1.6.6",
38 | "snakecase-keys": "8.0.1",
39 | "unplugin-swc": "1.5.1",
40 | "vite-tsconfig-paths": "5.1.3",
41 | "vitest": "2.1.8",
42 | "zod": "3.23.8",
43 | "zod-fixture": "2.5.2"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/scripts/workflow/test-docker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | MAX_RETRIES=20
4 | # Try running the docker and get the output
5 | # then try getting homepage in 3 mins
6 |
7 | docker -v
8 |
9 | if [[ $? -ne 0 ]]; then
10 | echo "failed to run docker"
11 | exit 1
12 | fi
13 |
14 | docker-compose -v
15 |
16 | if [[ $? -ne 0 ]]; then
17 | echo "failed to run docker-compose"
18 | exit 1
19 | fi
20 |
21 | curl https://cdn.jsdelivr.net/gh/Innei/meta-muse-template@master/docker-compose.yml >docker-compose.yml
22 |
23 | docker-compose up -d
24 |
25 | if [[ $? -ne 0 ]]; then
26 | echo "failed to run docker-compose instance"
27 | exit 1
28 | fi
29 |
30 | RETRY=0
31 |
32 | do_request() {
33 | curl -f -m 10 localhost:3333/ping -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.55 Safari/537.36'
34 |
35 | }
36 |
37 | do_request
38 |
39 | while [[ $? -ne 0 ]] && [[ $RETRY -lt $MAX_RETRIES ]]; do
40 | sleep 5
41 | ((RETRY++))
42 | echo -e "RETRY: ${RETRY}\n"
43 | do_request
44 | done
45 | request_exit_code=$?
46 |
47 | echo -e "\nrequest code: ${request_exit_code}\n"
48 |
49 | if [[ $RETRY -gt $MAX_RETRIES ]]; then
50 | echo -n "Unable to run, aborted"
51 | kill -9 $p
52 | exit 1
53 |
54 | elif [[ $request_exit_code -ne 0 ]]; then
55 | echo -n "Request error"
56 | kill -9 $p
57 | exit 1
58 |
59 | else
60 | echo -e "\nSuccessfully acquire homepage, passing"
61 | kill -9 $p
62 | exit 0
63 | fi
64 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/LoadRemixAsyncComponent.tsx:
--------------------------------------------------------------------------------
1 | import { createElement, useEffect, useState } from 'react'
2 | import type { FC, ReactNode } from 'react'
3 |
4 | import { LoadingCircle } from '../ui/loading'
5 |
6 | export const LoadRemixAsyncComponent: FC<{
7 | loader: () => Promise
8 | Header: FC<{ loader: () => any; [key: string]: any }>
9 | }> = ({ loader, Header }) => {
10 | const [loading, setLoading] = useState(true)
11 |
12 | const [Component, setComponent] = useState<{ c: () => ReactNode }>({
13 | c: () => null,
14 | })
15 |
16 | useEffect(() => {
17 | let isUnmounted = false
18 | setLoading(true)
19 | loader()
20 | .then((module) => {
21 | if (!module.Component) {
22 | return
23 | }
24 | if (isUnmounted) return
25 |
26 | const { loader } = module
27 | setComponent({
28 | c: () => (
29 | <>
30 |
31 |
32 | >
33 | ),
34 | })
35 | })
36 | .finally(() => {
37 | setLoading(false)
38 | })
39 | return () => {
40 | isUnmounted = true
41 | }
42 | }, [Header, loader])
43 |
44 | if (loading) {
45 | return (
46 |
47 |
48 |
49 | )
50 | }
51 |
52 | return createElement(Component.c)
53 | }
54 |
--------------------------------------------------------------------------------
/test/helper/create-service-unit.ts:
--------------------------------------------------------------------------------
1 | import { CacheService } from '@core/processors/cache/cache.service'
2 | import { ConfigModule } from '@nestjs/config'
3 | import { Test, type TestingModule } from '@nestjs/testing'
4 | import { mockedEventManagerServiceProvider } from '@test/mock/helper/helper.event'
5 | import { MockedDatabaseModule } from '@test/mock/processors/database/database.module'
6 | import type { ModuleMetadata } from '@nestjs/common'
7 |
8 | type ClassType = new (...args: any[]) => T
9 | export const createServiceUnitTestApp = (
10 | Service: ClassType,
11 | module?: ModuleMetadata,
12 | ) => {
13 | const proxy = {} as {
14 | service: T
15 | app: TestingModule
16 | }
17 |
18 | beforeAll(async () => {
19 | const { imports, providers } = module || {}
20 | const app = await Test.createTestingModule({
21 | providers: [
22 | Service,
23 | mockedEventManagerServiceProvider,
24 | {
25 | provide: CacheService,
26 | useValue: {},
27 | },
28 | ...(providers || []),
29 | ],
30 | imports: [
31 | MockedDatabaseModule,
32 | ConfigModule.forRoot({
33 | isGlobal: true,
34 | envFilePath: ['.env.test', '.env'],
35 | }),
36 | ...(imports || []),
37 | ],
38 | }).compile()
39 | await app.init()
40 |
41 | const service = app.get(Service)
42 | proxy.service = service
43 | proxy.app = app
44 | })
45 | return proxy
46 | }
47 |
--------------------------------------------------------------------------------
/apps/core/src/common/decorators/api-controller.decorator.ts:
--------------------------------------------------------------------------------
1 | import { API_VERSION } from '@core/app.config'
2 | import { isDev, isTest } from '@core/global/env.global'
3 | import {
4 | Controller,
5 | type ControllerOptions,
6 | applyDecorators,
7 | } from '@nestjs/common'
8 |
9 | import { Auth } from './auth.decorator'
10 |
11 | export const apiRoutePrefix = isDev || isTest ? '' : `/api/v${API_VERSION}`
12 | export const ApiController: (
13 | optionOrString?: string | string[] | undefined | ControllerOptions,
14 | ) => ReturnType = (...rest) => {
15 | const [controller, ...args] = rest
16 | if (!controller) {
17 | return Controller(apiRoutePrefix)
18 | }
19 |
20 | const transformPath = (path: string) =>
21 | `${apiRoutePrefix}/${path.replace(/^\/*/, '')}`
22 |
23 | if (typeof controller === 'string') {
24 | return Controller(transformPath(controller), ...args)
25 | } else if (Array.isArray(controller)) {
26 | return Controller(
27 | controller.map((path) => transformPath(path)),
28 | ...args,
29 | )
30 | } else {
31 | const path = controller.path || ''
32 |
33 | return Controller(
34 | Array.isArray(path)
35 | ? path.map((i) => transformPath(i))
36 | : transformPath(path),
37 | ...args,
38 | )
39 | }
40 | }
41 |
42 | export const AdminApiController = (path: string) => {
43 | return applyDecorators(
44 | ApiController(`/admin/${path.replace(/^\/*/, '')}`),
45 | Auth(),
46 | )
47 | }
48 |
--------------------------------------------------------------------------------
/apps/core/src/modules/auth/auth.config.ts:
--------------------------------------------------------------------------------
1 | import { API_VERSION, AUTH } from '@core/app.config'
2 | import { isDev } from '@core/global/env.global'
3 | import { db } from '@core/processors/database/database.service'
4 |
5 | import { authjs, DrizzleAdapter } from '@packages/compiled'
6 | import {
7 | accounts,
8 | authenticators,
9 | sessions,
10 | users,
11 | verificationTokens,
12 | } from '@packages/drizzle/schema'
13 | import type { ServerAuthConfig } from './auth.implement'
14 |
15 | const {
16 | providers: { github: GitHub },
17 | } = authjs
18 |
19 | export const authConfig: ServerAuthConfig = {
20 | basePath: isDev ? '/auth' : `/api/v${API_VERSION}/auth`,
21 | secret: AUTH.secret,
22 | callbacks: {
23 | redirect({ url }) {
24 | return url
25 | },
26 | },
27 | providers: [
28 | GitHub({
29 | clientId: AUTH.github.clientId,
30 | clientSecret: AUTH.github.clientSecret,
31 | profile(profile) {
32 | return {
33 | id: profile.id.toString(),
34 |
35 | email: profile.email,
36 | name: profile.name || profile.login,
37 | handle: profile.login,
38 | image: profile.avatar_url,
39 | }
40 | },
41 | }),
42 | ],
43 | trustHost: true,
44 | adapter: DrizzleAdapter(db, {
45 | usersTable: users,
46 | accountsTable: accounts,
47 | sessionsTable: sessions,
48 | verificationTokensTable: verificationTokens,
49 | authenticatorsTable: authenticators,
50 | }),
51 | }
52 |
--------------------------------------------------------------------------------
/test/vitest.config.mts:
--------------------------------------------------------------------------------
1 | import { resolve } from 'node:path'
2 | import swc from 'unplugin-swc'
3 | import tsconfigPath from 'vite-tsconfig-paths'
4 | import { defineConfig } from 'vitest/config'
5 |
6 | export default defineConfig({
7 | root: './',
8 | test: {
9 | include: ['**/*.spec.ts', '**/*.e2e-spec.ts'],
10 |
11 | globals: true,
12 | setupFiles: [resolve(__dirname, './setup-file.ts')],
13 | environment: 'node',
14 | includeSource: [resolve(__dirname, '.')],
15 | },
16 | optimizeDeps: {
17 | needsInterop: ['lodash'],
18 | },
19 | resolve: {
20 | alias: [
21 | {
22 | find: '@core/app.config',
23 | replacement: resolve(
24 | __dirname,
25 | '../apps/core/src/app.config.testing.ts',
26 | ),
27 | },
28 | {
29 | find: /^@core\/(.+)/,
30 | replacement: resolve(__dirname, '../apps/core/src/$1'),
31 | },
32 | {
33 | find: '@packages/drizzle',
34 | replacement: resolve(__dirname, '../drizzle/index.ts'),
35 | },
36 |
37 | {
38 | find: '@packages/utils',
39 | replacement: resolve(__dirname, '../packages/utils/index.ts'),
40 | },
41 | ],
42 | },
43 |
44 | // esbuild can not emit ts metadata
45 | esbuild: false,
46 |
47 | plugins: [
48 | swc.vite(),
49 | tsconfigPath({
50 | projects: [
51 | resolve(__dirname, './tsconfig.json'),
52 | // resolve(__dirname, './tsconfig.json'),
53 | ],
54 | }),
55 | ],
56 | })
57 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/time.util.ts:
--------------------------------------------------------------------------------
1 | import dayjs from 'dayjs'
2 |
3 | /** Get Time, format `12:00:00` */
4 | export const getShortTime = (date: Date) => {
5 | return Intl.DateTimeFormat('en-US', {
6 | timeStyle: 'medium',
7 | hour12: false,
8 | }).format(date)
9 | }
10 |
11 | export const getShortDate = (date: Date) => {
12 | return Intl.DateTimeFormat('en-US', {
13 | dateStyle: 'short',
14 | })
15 | .format(date)
16 | .replaceAll('/', '-')
17 | }
18 | /** 2-12-22, 21:31:42 */
19 | export const getShortDateTime = (date: Date) => {
20 | return Intl.DateTimeFormat('en-US', {
21 | dateStyle: 'short',
22 | timeStyle: 'medium',
23 | hour12: false,
24 | })
25 | .format(date)
26 | .replaceAll('/', '-')
27 | }
28 | /** YYYY-MM-DD_HH:mm:ss */
29 | export const getMediumDateTime = (date: Date) => {
30 | return dayjs(date).format('YYYY-MM-DD_HH:mm:ss')
31 | }
32 | export const getTodayEarly = (today: Date) =>
33 | dayjs(today).set('hour', 0).set('minute', 0).set('millisecond', 0).toDate()
34 |
35 | export const getWeekStart = (today: Date) =>
36 | dayjs(today)
37 | .set('day', 0)
38 | .set('hour', 0)
39 | .set('millisecond', 0)
40 | .set('minute', 0)
41 | .toDate()
42 |
43 | export const getMonthStart = (today: Date) =>
44 | dayjs(today)
45 | .set('date', 1)
46 | .set('hour', 0)
47 | .set('minute', 0)
48 | .set('millisecond', 0)
49 | .toDate()
50 |
51 | export function getMonthLength(month: number, year: number) {
52 | return new Date(year, month, 0).getDate()
53 | }
54 |
--------------------------------------------------------------------------------
/apps/core/src/common/interceptors/allow-all-cors.interceptor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | type CallHandler,
3 | type ExecutionContext,
4 | type NestInterceptor,
5 | RequestMethod,
6 | } from '@nestjs/common'
7 | import type { FastifyReply, FastifyRequest } from 'fastify'
8 |
9 | declare module 'fastify' {
10 | // @ts-ignore
11 | interface FastifyRequest {
12 | cors?: boolean
13 | }
14 | }
15 |
16 | export class AllowAllCorsInterceptor implements NestInterceptor {
17 | intercept(context: ExecutionContext, next: CallHandler) {
18 | const handle = next.handle()
19 | const request = context.switchToHttp().getRequest() as FastifyRequest
20 | const response: FastifyReply = context.switchToHttp().getResponse()
21 | const allowedMethods = [
22 | RequestMethod.GET,
23 | RequestMethod.HEAD,
24 | RequestMethod.PUT,
25 | RequestMethod.PATCH,
26 | RequestMethod.POST,
27 | RequestMethod.DELETE,
28 | ]
29 | const allowedHeaders = [
30 | 'Authorization',
31 | 'Origin',
32 | 'No-Cache',
33 | 'X-Requested-With',
34 | 'If-Modified-Since',
35 | 'Last-Modified',
36 | 'Cache-Control',
37 | 'Expires',
38 | 'Content-Type',
39 | ]
40 | response.headers({
41 | 'Access-Control-Allow-Origin': '*',
42 | 'Access-Control-Allow-Headers': allowedHeaders.join(','),
43 | 'Access-Control-Allow-Methods': allowedMethods.join(','),
44 | 'Access-Control-Max-Age': '86400',
45 | })
46 | request.cors = true
47 | return handle
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/apps/web/src/providers/stable-router-provider.tsx:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect } from 'react'
2 | import {
3 | useLocation,
4 | useNavigate,
5 | useParams,
6 | useSearchParams,
7 | } from 'react-router-dom'
8 | import type { NavigateFunction } from 'react-router-dom'
9 |
10 | import { setNavigate, setRoute } from '~/atoms/route'
11 |
12 | declare global {
13 | export const router: {
14 | navigate: NavigateFunction
15 | }
16 | interface Window {
17 | router: typeof router
18 | }
19 | }
20 | window.router = {
21 | navigate() {},
22 | }
23 |
24 | /**
25 | * Why this.
26 | * Remix router always update immutable object when the router has any changes, lead to the component which uses router hooks re-render.
27 | * This provider is hold a empty component, to store the router hooks value.
28 | * And use our router hooks will not re-render the component when the router has any changes.
29 | * Also it can access values outside of the component and provide a value selector
30 | */
31 | export const StableRouterProvider = () => {
32 | const [searchParams] = useSearchParams()
33 | const params = useParams()
34 | const nav = useNavigate()
35 | const location = useLocation()
36 |
37 | // NOTE: This is a hack to expose the navigate function to the window object, avoid to import `router` circular issue.
38 | useLayoutEffect(() => {
39 | window.router.navigate = nav
40 |
41 | setRoute({
42 | params,
43 | searchParams,
44 | location,
45 | })
46 | setNavigate({ fn: nav })
47 | }, [searchParams, params, location, nav])
48 |
49 | return null
50 | }
51 |
--------------------------------------------------------------------------------
/apps/core/src/common/interceptors/logging.interceptor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Logging interceptor.
3 | * @file 日志拦截器
4 | * @module interceptor/logging
5 | * @author Surmon
6 | * @author Innei
7 | */
8 | import { Observable } from 'rxjs'
9 | import { tap } from 'rxjs/operators'
10 |
11 | import { HTTP_REQUEST_TIME } from '@core/constants/meta.constant'
12 | import {
13 | CallHandler,
14 | ExecutionContext,
15 | Injectable,
16 | Logger,
17 | NestInterceptor,
18 | SetMetadata,
19 | } from '@nestjs/common'
20 | import { isDev } from '@core/global/env.global'
21 |
22 | @Injectable()
23 | export class LoggingInterceptor implements NestInterceptor {
24 | private logger: Logger
25 |
26 | constructor() {
27 | this.logger = new Logger(LoggingInterceptor.name)
28 | }
29 | intercept(
30 | context: ExecutionContext,
31 | next: CallHandler,
32 | ): Observable {
33 | const call$ = next.handle()
34 | if (!isDev) {
35 | return call$
36 | }
37 | const request = this.getRequest(context)
38 | const content = `${request.method} -> ${request.url}`
39 | Logger.debug(`+++ Request:${content}`, LoggingInterceptor.name)
40 | const now = Date.now()
41 | SetMetadata(HTTP_REQUEST_TIME, now)(this.getRequest(context))
42 |
43 | return call$.pipe(
44 | tap(() =>
45 | this.logger.debug(`--- Response:${content} +${Date.now() - now}ms`),
46 | ),
47 | )
48 | }
49 |
50 | getRequest(context: ExecutionContext) {
51 | const req = context.switchToHttp().getRequest()
52 | if (req) {
53 | return req
54 | }
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.8'
2 |
3 | services:
4 | app:
5 | container_name: meta-muse
6 | image: innei/nest-drizzle:latest
7 | command: /bin/sh -c "./node_modules/.bin/prisma migrate deploy; node apps/core/dist/main.js --redis_host=redis --allowed_origins=${ALLOWED_ORIGINS} --jwt_secret=${JWT_SECRET} --color --cluster"
8 | env_file:
9 | - .env
10 | environment:
11 | - TZ=Asia/Shanghai
12 | - NODE_ENV=production
13 | - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/nest-drizzle?schema=public
14 | restart: on-failure
15 | volumes:
16 | - ./data/nest:/root/.nest
17 |
18 | ports:
19 | - '3333:3333'
20 | depends_on:
21 | - postgres
22 | - redis
23 | links:
24 | - postgres
25 | - redis
26 | networks:
27 | - app-network
28 |
29 | postgres:
30 | image: postgres:16
31 | container_name: postgres
32 | restart: always
33 | ports:
34 | - '15432:5432'
35 | env_file:
36 | - .env
37 | volumes:
38 | - nest-postgres:/var/lib/postgresql/data
39 | environment:
40 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
41 | - POSTGRES_USER=${POSTGRES_USER}
42 | healthcheck:
43 | test: [CMD-SHELL, pg_isready -U meta-muse]
44 | interval: 30s
45 | timeout: 10s
46 | retries: 5
47 | networks:
48 | - app-network
49 |
50 | redis:
51 | image: redis
52 | container_name: redis
53 |
54 | ports:
55 | - '6560:6379'
56 | networks:
57 | - app-network
58 | networks:
59 | app-network:
60 | driver: bridge
61 |
62 | volumes:
63 | nest-postgres:
64 | name: nest-postgres-db
65 |
--------------------------------------------------------------------------------
/apps/core/src/app.config.testing.ts:
--------------------------------------------------------------------------------
1 | import { program } from 'commander'
2 |
3 | import type { AxiosRequestConfig } from 'axios'
4 | import { isDev } from './global/env.global'
5 |
6 | program
7 | .option('-p, --port [port]', 'port to listen')
8 | .option('--disable_cache', 'disable cache')
9 | .option('--redis_host [host]', 'redis host')
10 | .option('--redis_port [port]', 'redis port')
11 | .option('--redis_password [password]', 'redis password')
12 | .option('--jwtSecret [secret]', 'jwt secret')
13 |
14 | program.parse(process.argv)
15 |
16 | const argv = program.opts()
17 | export const PORT = argv.port || 3333
18 | export const CROSS_DOMAIN = {
19 | allowedOrigins: [
20 | 'innei.in',
21 | 'shizuri.net',
22 | 'localhost:9528',
23 | 'localhost:2323',
24 | '127.0.0.1',
25 | 'mbp.cc',
26 | 'local.innei.test',
27 | '22333322.xyz',
28 | ],
29 | allowedReferer: 'innei.in',
30 | }
31 |
32 | export const DATABASE = {
33 | url: mergeArgv('database_url'),
34 | }
35 |
36 | export const REDIS = {
37 | host: argv.redis_host || 'localhost',
38 | port: argv.redis_port || 6379,
39 | password: argv.redis_password || null,
40 | ttl: null,
41 | httpCacheTTL: 5,
42 | max: 5,
43 | disableApiCache:
44 | (isDev || argv.disable_cache) && !process.env.ENABLE_CACHE_DEBUG,
45 | }
46 | export const SECURITY = {
47 | jwtSecret: argv.jwtSecret || 'asjhczxiucipoiopiqm2376',
48 | jwtExpire: '7d',
49 | }
50 |
51 | export const AXIOS_CONFIG: AxiosRequestConfig = {
52 | timeout: 10000,
53 | }
54 |
55 | function mergeArgv(key: string) {
56 | const env = process.env
57 | const toUpperCase = (key: string) => key.toUpperCase()
58 | return argv[key] ?? env[toUpperCase(key)]
59 | }
60 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3 |
4 | name: Node.js Test CI
5 |
6 | on:
7 | push:
8 | branches: [main]
9 | pull_request:
10 | branches: [main]
11 |
12 | jobs:
13 | test:
14 | runs-on: ubuntu-latest
15 |
16 | services:
17 | postgres:
18 | image: postgres
19 |
20 | env:
21 | POSTGRES_PASSWORD: postgres
22 | POSTGRES_USER: postgres
23 | POSTGRES_DB: postgres
24 |
25 | options: >-
26 | --health-cmd pg_isready
27 | --health-interval 10s
28 | --health-timeout 5s
29 | --health-retries 5
30 | ports:
31 | - 5432:5432
32 |
33 | strategy:
34 | matrix:
35 | node-version: [20.x]
36 |
37 | steps:
38 | - uses: actions/checkout@v4
39 | - name: Start Redis
40 | uses: supercharge/redis-github-action@1.7.0
41 | with:
42 | redis-version: 6
43 |
44 | - uses: pnpm/action-setup@v4.0.0
45 |
46 | - name: Use Node.js ${{ matrix.node-version }}
47 | uses: actions/setup-node@v4
48 | with:
49 | node-version: ${{ matrix.node-version }}
50 | cache: pnpm
51 |
52 | - name: Install dependencies
53 | run: pnpm i
54 | - name: Run Test
55 | run: |
56 | pnpm run lint
57 | pnpm run test
58 | env:
59 | CI: true
60 | DATABASE_URL: 'postgres://postgres:postgres@localhost:5432/postgres?schema=public'
61 |
--------------------------------------------------------------------------------
/apps/core/src/constants/error-code.constant.ts:
--------------------------------------------------------------------------------
1 | export enum ErrorCodeEnum {
2 | NoContentCanBeModified = 1000,
3 |
4 | PostNotFound = 10000,
5 | PostExist = 10001,
6 | CategoryNotFound = 10002,
7 | CategoryCannotDeleted = 10003,
8 | CategoryAlreadyExists = 10004,
9 | PostNotPublished = 10005,
10 |
11 | AuthFailUserNotExist = 20000,
12 | AuthFail = 20001,
13 |
14 | UserNotFound = 30000,
15 | UserExist = 30001,
16 | }
17 |
18 | export const ErrorCode = Object.freeze<
19 | Record
20 | >({
21 | [ErrorCodeEnum.NoContentCanBeModified]: [
22 | 'no content can be modified',
23 | '没有内容可以被修改',
24 | 400,
25 | ],
26 | [ErrorCodeEnum.PostNotFound]: ['post not found', '文章不存在', 404],
27 | [ErrorCodeEnum.PostNotPublished]: ['post not found', '文章不存在', 404],
28 | [ErrorCodeEnum.PostExist]: ['post already exist', '文章已存在', 400],
29 | [ErrorCodeEnum.CategoryNotFound]: [
30 | 'category not found',
31 | '该分类未找到 (。•́︿•̀。)',
32 | 404,
33 | ],
34 | [ErrorCodeEnum.CategoryCannotDeleted]: [
35 | 'there are other posts in this category, cannot be deleted',
36 | '该分类中有其他文章,无法被删除',
37 | 400,
38 | ],
39 | [ErrorCodeEnum.CategoryAlreadyExists]: [
40 | 'category already exists',
41 | '分类已存在',
42 | 400,
43 | ],
44 | [ErrorCodeEnum.AuthFailUserNotExist]: [
45 | 'auth failed, user not exist',
46 | '认证失败,用户不存在',
47 | 400,
48 | ],
49 | [ErrorCodeEnum.AuthFail]: [
50 | 'auth failed, please check your username and password',
51 | '认证失败,请检查用户名和密码',
52 | 400,
53 | ],
54 | [ErrorCodeEnum.UserNotFound]: ['user not found', '用户不存在', 404],
55 | [ErrorCodeEnum.UserExist]: ['user already exist', '用户已存在', 400],
56 | })
57 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/common/useInputComposition.ts:
--------------------------------------------------------------------------------
1 | import { useCallback, useRef } from 'react'
2 | import type { CompositionEventHandler } from 'react'
3 |
4 | export const useInputComposition = (
5 | props: Pick<
6 | | React.DetailedHTMLProps<
7 | React.InputHTMLAttributes,
8 | HTMLInputElement
9 | >
10 | | React.DetailedHTMLProps<
11 | React.TextareaHTMLAttributes,
12 | HTMLTextAreaElement
13 | >,
14 | 'onKeyDown' | 'onCompositionEnd' | 'onCompositionStart'
15 | >,
16 | ) => {
17 | const { onKeyDown, onCompositionStart, onCompositionEnd } = props
18 |
19 | const isCompositionRef = useRef(false)
20 |
21 | const handleCompositionStart: CompositionEventHandler = useCallback(
22 | (e) => {
23 | isCompositionRef.current = true
24 | onCompositionStart?.(e)
25 | },
26 | [onCompositionStart],
27 | )
28 |
29 | const handleCompositionEnd: CompositionEventHandler = useCallback(
30 | (e) => {
31 | isCompositionRef.current = false
32 | onCompositionEnd?.(e)
33 | },
34 | [onCompositionEnd],
35 | )
36 |
37 | const handleKeyDown: React.KeyboardEventHandler = useCallback(
38 | (e) => {
39 | onKeyDown?.(e)
40 |
41 | if (isCompositionRef.current) {
42 | e.stopPropagation()
43 | return
44 | }
45 |
46 | if (e.key === 'Escape') {
47 | e.currentTarget.blur()
48 | e.preventDefault()
49 | e.stopPropagation()
50 | }
51 | },
52 | [onKeyDown],
53 | )
54 |
55 | return {
56 | onCompositionEnd: handleCompositionEnd,
57 | onCompositionStart: handleCompositionStart,
58 | onKeyDown: handleKeyDown,
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/apps/core/src/common/interceptors/response.interceptor.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 对响应体进行转换结构
3 | * @author Innei
4 | */
5 | import { isArrayLike, omit } from 'lodash'
6 | import { Observable } from 'rxjs'
7 | import { map } from 'rxjs/operators'
8 |
9 | import * as SYSTEM from '@core/constants/system.constant'
10 | import {
11 | CallHandler,
12 | ExecutionContext,
13 | Injectable,
14 | NestInterceptor,
15 | } from '@nestjs/common'
16 | import { Reflector } from '@nestjs/core'
17 |
18 | export interface Response {
19 | data: T
20 | }
21 |
22 | @Injectable()
23 | export class ResponseInterceptor implements NestInterceptor> {
24 | constructor(private readonly reflector: Reflector) {}
25 | intercept(
26 | context: ExecutionContext,
27 | next: CallHandler,
28 | ): Observable> {
29 | if (!context.switchToHttp().getRequest()) {
30 | return next.handle()
31 | }
32 | const handler = context.getHandler()
33 |
34 | // 跳过 bypass 装饰的请求
35 | const bypass = this.reflector.get(
36 | SYSTEM.RESPONSE_PASSTHROUGH_METADATA,
37 | handler,
38 | )
39 | if (bypass) {
40 | return next.handle()
41 | }
42 |
43 | const omitKeys = this.reflector.getAllAndOverride(
44 | SYSTEM.OMIT_RESPONSE_PROTECT_KEYS,
45 | [handler, context.getClass()],
46 | )
47 |
48 | return next.handle().pipe(
49 | map((data) => {
50 | if (typeof data === 'undefined') {
51 | context.switchToHttp().getResponse().status(204)
52 | return data
53 | }
54 |
55 | if (Array.isArray(omitKeys)) {
56 | data = omit(data, omitKeys)
57 | }
58 |
59 | return isArrayLike(data) ? { data } : data
60 | }),
61 | )
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/apps/core/src/processors/cache/cache.service.ts:
--------------------------------------------------------------------------------
1 | import { Cache } from 'cache-manager'
2 | import { Redis } from 'ioredis'
3 |
4 | import { RedisIoAdapterKey } from '@core/common/adapter/io.adapter'
5 | import { CACHE_MANAGER } from '@nestjs/cache-manager'
6 | import { Inject, Injectable, Logger } from '@nestjs/common'
7 | import { Emitter } from '@socket.io/redis-emitter'
8 |
9 | // Cache 客户端管理器
10 |
11 | // 获取器
12 | export type TCacheKey = string
13 | export type TCacheResult = Promise
14 |
15 | /**
16 | * @class CacheService
17 | * @classdesc 承载缓存服务
18 | * @example CacheService.get(CacheKey).then()
19 | * @example CacheService.set(CacheKey).then()
20 | */
21 | @Injectable()
22 | export class CacheService {
23 | private cache!: Cache
24 | private logger = new Logger(CacheService.name)
25 |
26 | constructor(@Inject(CACHE_MANAGER) cache: Cache) {
27 | this.cache = cache
28 |
29 | this.redisClient.on('ready', () => {
30 | this.logger.log('Redis is ready!')
31 | })
32 | }
33 |
34 | private get redisClient(): Redis {
35 | // @ts-expect-error
36 | return this.cache.store.getClient()
37 | }
38 |
39 | public get(key: TCacheKey): TCacheResult {
40 | return this.cache.get(key)
41 | }
42 |
43 | public set(key: TCacheKey, value: any, ttl?: number | undefined) {
44 | return this.cache.set(key, value, ttl || 0)
45 | }
46 |
47 | public getClient() {
48 | return this.redisClient
49 | }
50 |
51 | private _emitter: Emitter
52 |
53 | public get emitter(): Emitter {
54 | if (this._emitter) {
55 | return this._emitter
56 | }
57 | this._emitter = new Emitter(this.redisClient, {
58 | key: RedisIoAdapterKey,
59 | })
60 |
61 | return this._emitter
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/test/helper/setup-e2e.ts:
--------------------------------------------------------------------------------
1 | import { fastifyApp } from '@core/common/adapter/fastify.adapter'
2 | import { JSONTransformerInterceptor } from '@core/common/interceptors/json-transformer.interceptor'
3 | import { ResponseInterceptor } from '@core/common/interceptors/response.interceptor'
4 | import { ZodValidationPipe } from '@core/common/pipes/zod-validation.pipe'
5 | import { LoggerModule } from '@core/processors/logger/logger.module'
6 | import { MyLogger } from '@core/processors/logger/logger.service'
7 | import { APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
8 | import { Test } from '@nestjs/testing'
9 | import type { ModuleMetadata } from '@nestjs/common'
10 | import type { NestFastifyApplication } from '@nestjs/platform-fastify'
11 |
12 | const interceptorProviders = [JSONTransformerInterceptor, ResponseInterceptor]
13 | export const setupE2EApp = async (module: ModuleMetadata) => {
14 | const nextModule: ModuleMetadata = {
15 | exports: module.exports || [],
16 | imports: module.imports || [],
17 | providers: module.providers || [],
18 | controllers: module.controllers || [],
19 | }
20 |
21 | nextModule.imports!.unshift(LoggerModule)
22 | nextModule.providers!.unshift({
23 | provide: APP_PIPE,
24 | useClass: ZodValidationPipe,
25 | })
26 | nextModule.providers!.unshift(
27 | ...interceptorProviders.map((interceptor) => ({
28 | provide: APP_INTERCEPTOR,
29 | useClass: interceptor,
30 | })),
31 | )
32 | const testingModule = await Test.createTestingModule(nextModule).compile()
33 |
34 | const app = testingModule.createNestApplication(
35 | fastifyApp,
36 | { logger: ['log', 'warn', 'error', 'debug'] },
37 | )
38 |
39 | await app.init()
40 | app.useLogger(app.get(MyLogger))
41 | await app.getHttpAdapter().getInstance().ready()
42 |
43 | return app
44 | }
45 |
--------------------------------------------------------------------------------
/test/helper/redis-mock.helper.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-var-requires */
2 | import IORedis, { type Redis } from 'ioredis'
3 |
4 | import { CacheService } from '@core/processors/cache/cache.service'
5 | import { Global, Module } from '@nestjs/common'
6 |
7 | export class MockCacheService {
8 | private client: Redis
9 | constructor(port: number, host: string) {
10 | this.client = new IORedis(port, host)
11 | }
12 |
13 | private get redisClient() {
14 | return this.client
15 | }
16 |
17 | public get(key) {
18 | return this.client.get(key)
19 | }
20 |
21 | public set(key, value: any) {
22 | return this.client.set(key, value)
23 | }
24 |
25 | public getClient() {
26 | return this.redisClient
27 | }
28 | }
29 |
30 | const createMockRedis = async () => {
31 | let redisPort = 6379
32 | let redisHost = 'localhost'
33 | let redisServer: any
34 | if (process.env.CI) {
35 | // Skip
36 | } else {
37 | const RedisMemoryServer = require('redis-memory-server').default
38 | redisServer = new RedisMemoryServer({})
39 |
40 | redisHost = await redisServer.getHost()
41 | redisPort = await redisServer.getPort()
42 | }
43 | const cacheService = new MockCacheService(redisPort, redisHost)
44 |
45 | const provide = {
46 | provide: CacheService,
47 | useValue: cacheService,
48 | }
49 | @Module({
50 | providers: [provide],
51 | exports: [provide],
52 | })
53 | @Global()
54 | class CacheModule {}
55 |
56 | return {
57 | connect: () => null,
58 | CacheService: cacheService,
59 | token: CacheService,
60 | CacheModule,
61 |
62 | async close() {
63 | await cacheService.getClient().flushall()
64 | await cacheService.getClient().quit()
65 | if (!process.env.CI) {
66 | await redisServer?.stop()
67 | }
68 | },
69 | }
70 | }
71 |
72 | export const redisHelper = createMockRedis()
73 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vite-react-tailwind-template",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://"
8 | },
9 | "scripts": {
10 | "prepare": "simple-git-hooks",
11 | "dev": "vite",
12 | "build": "tsc && vite build",
13 | "serve": "vite preview",
14 | "format": "prettier --write \"src/**/*.ts\" ",
15 | "lint": "eslint --fix"
16 | },
17 | "dependencies": {
18 | "@auth/core": "0.37.4",
19 | "@headlessui/react": "2.2.0",
20 | "@packages/drizzle": "workspace:*",
21 | "@radix-ui/react-avatar": "1.1.1",
22 | "@tanstack/react-query": "5.62.2",
23 | "clsx": "2.1.1",
24 | "framer-motion": "11.13.1",
25 | "immer": "10.1.1",
26 | "jotai": "2.10.3",
27 | "lodash-es": "4.17.21",
28 | "next-themes": "0.4.4",
29 | "ofetch": "1.4.1",
30 | "react": "19.0.0",
31 | "react-dom": "19.0.0",
32 | "react-router-dom": "7.0.2",
33 | "sonner": "1.7.0",
34 | "tailwind-merge": "2.5.5"
35 | },
36 | "devDependencies": {
37 | "@egoist/tailwindcss-icons": "1.8.1",
38 | "@iconify-json/mingcute": "1.2.1",
39 | "@tailwindcss/container-queries": "0.1.1",
40 | "@tailwindcss/typography": "0.5.15",
41 | "@types/lodash-es": "4.17.12",
42 | "@types/node": "20.17.9",
43 | "@types/react": "18.3.14",
44 | "@types/react-dom": "18.3.2",
45 | "@vitejs/plugin-react": "^4.3.4",
46 | "autoprefixer": "10.4.20",
47 | "click-to-react-component": "1.1.2",
48 | "daisyui": "4.12.14",
49 | "postcss": "8.4.49",
50 | "postcss-import": "16.1.0",
51 | "postcss-js": "4.0.1",
52 | "tailwind-scrollbar": "3.1.0",
53 | "tailwind-variants": "0.3.0",
54 | "tailwindcss": "3.4.16",
55 | "tailwindcss-animated": "1.1.2",
56 | "vite": "6.0.3",
57 | "vite-plugin-checker": "0.8.0",
58 | "vite-tsconfig-paths": "5.1.3"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - 'v*'
9 |
10 | jobs:
11 | docker:
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 | - name: Docker meta
17 | id: meta
18 | uses: docker/metadata-action@v5
19 | with:
20 | # list of Docker images to use as base name for tags
21 | images: |
22 | nest/nest-http
23 | # generate Docker tags based on the following events/attributes
24 | tags: |
25 | type=ref,event=branch
26 | type=semver,pattern={{version}}
27 | type=semver,pattern={{major}}.{{minor}}
28 | type=semver,pattern={{major}}
29 | type=sha
30 | - name: Set up QEMU
31 | uses: docker/setup-qemu-action@v3
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v3
34 | - name: Login to DockerHub
35 | uses: docker/login-action@v3
36 | with:
37 | username: ${{ secrets.DOCKERHUB_USERNAME }}
38 | password: ${{ secrets.DOCKERHUB_TOKEN }}
39 | - name: Build and export to Docker
40 | uses: docker/build-push-action@v5
41 | with:
42 | context: .
43 | load: true
44 | tags: ${{ steps.meta.outputs.tags }},nest/nest-http:latest
45 | labels: ${{ steps.meta.outputs.labels }}
46 | - name: Test
47 | run: |
48 | bash ./scripts/workflow/test-docker.sh
49 | - name: Build and push
50 | id: docker_build
51 | uses: docker/build-push-action@v5
52 | with:
53 | context: .
54 | # push: ${{ startsWith(github.ref, 'refs/tags/v') }}
55 | push: false
56 | tags: ${{ steps.meta.outputs.tags }},nest/nest-http:latest
57 | labels: ${{ steps.meta.outputs.labels }}
58 |
--------------------------------------------------------------------------------
/apps/core/src/modules/auth/auth.implement.ts:
--------------------------------------------------------------------------------
1 | import { Auth, setEnvDefaults, type AuthConfig } from '@packages/compiled'
2 |
3 | import { getRequest } from './req.transformer'
4 | import type { IncomingMessage, ServerResponse } from 'node:http'
5 |
6 | export type ServerAuthConfig = Omit & {
7 | basePath: string
8 | }
9 |
10 | export function CreateAuth(config: ServerAuthConfig) {
11 | return async (req: IncomingMessage, res: ServerResponse) => {
12 | try {
13 | setEnvDefaults(process.env, config)
14 |
15 | const auth = await Auth(await toWebRequest(req), config)
16 |
17 | await toServerResponse(req, auth, res)
18 | } catch (error) {
19 | console.error(error)
20 | // throw error
21 | res.end(error.message)
22 | }
23 | }
24 | }
25 |
26 | async function toWebRequest(req: IncomingMessage) {
27 | const host = req.headers.host || 'localhost'
28 | const protocol = req.headers['x-forwarded-proto'] || 'http'
29 | const base = `${protocol}://${host}`
30 |
31 | return getRequest(base, req)
32 | }
33 |
34 | async function toServerResponse(
35 | req: IncomingMessage,
36 | response: Response,
37 | res: ServerResponse,
38 | ) {
39 | response.headers.forEach((value, key) => {
40 | if (!value) {
41 | return
42 | }
43 | if (res.hasHeader(key)) {
44 | res.appendHeader(key, value)
45 | } else {
46 | res.setHeader(key, value)
47 | }
48 | })
49 | res.setHeader('Content-Type', response.headers.get('content-type') || '')
50 | res.setHeader('access-control-allow-methods', 'GET, POST')
51 | res.setHeader('access-control-allow-headers', 'content-type')
52 | res.setHeader(
53 | 'access-control-allow-origin',
54 | req.headers.origin || req.headers.referer || req.headers.host || '*',
55 | )
56 | res.setHeader('access-control-allow-credentials', 'true')
57 |
58 | const text = await response.text()
59 | res.writeHead(response.status, response.statusText)
60 | res.end(text)
61 | }
62 |
--------------------------------------------------------------------------------
/apps/web/src/styles/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 | @tailwind base;
5 | @tailwind components;
6 | @tailwind utilities;
7 |
8 | html {
9 | font-size: 14px;
10 | line-height: 1.5;
11 |
12 | @apply font-sans;
13 | }
14 |
15 | html body {
16 | @apply max-w-screen overflow-x-hidden;
17 | }
18 |
19 | @media print {
20 | html {
21 | font-size: 12px;
22 | }
23 | }
24 |
25 | /* @media (min-width: 2160px) {
26 | html {
27 | font-size: 15px;
28 | }
29 | } */
30 |
31 | .prose {
32 | max-width: 100% !important;
33 | font-size: 1.1rem;
34 |
35 | p {
36 | @apply break-words;
37 | }
38 |
39 | figure img {
40 | @apply mb-0 mt-0;
41 | }
42 | }
43 |
44 | *:focus {
45 | outline: none;
46 | }
47 |
48 | *:not(input):not(textarea):not([contenteditable='true']):focus-visible {
49 | outline: 0 !important;
50 | box-shadow: theme(colors.accent) 0px 0px 0px 1px;
51 | }
52 | * {
53 | tab-size: 2;
54 |
55 | &:hover {
56 | scrollbar-color: auto;
57 | }
58 | }
59 |
60 | .animate-ping {
61 | animation: ping 2s cubic-bezier(0, 0, 0.2, 1) infinite;
62 | }
63 |
64 | @keyframes ping {
65 | 75%,
66 | 100% {
67 | transform: scale(1.4);
68 | opacity: 0;
69 | }
70 | }
71 |
72 | /* input,
73 | textarea {
74 | font-size: max(16px, 1rem);
75 | } */
76 |
77 | a {
78 | @apply break-all;
79 | }
80 |
81 | @screen lg {
82 | input,
83 | textarea {
84 | font-size: 1rem;
85 | }
86 | }
87 |
88 | .prose p:last-child {
89 | margin-bottom: 0;
90 | }
91 |
92 | .prose
93 | :where(blockquote):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
94 | @apply relative border-0;
95 |
96 | &::before {
97 | content: '';
98 | display: block;
99 | width: 3px;
100 | height: 100%;
101 | position: absolute;
102 | left: 0;
103 | top: 0;
104 | border-radius: 1em;
105 | background-color: theme(colors.accent);
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/apps/web/src/lib/dev.tsx:
--------------------------------------------------------------------------------
1 | declare const APP_DEV_CWD: string
2 | export const attachOpenInEditor = (stack: string) => {
3 | const lines = stack.split('\n')
4 | return lines.map((line) => {
5 | // A line like this: at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
6 | // Find the `localhost` part and open the file in the editor
7 | if (!line.includes('at ')) {
8 | return line
9 | }
10 | const match = line.match(/(http:\/\/localhost:\d+\/[^:]+):(\d+):(\d+)/)
11 |
12 | if (match) {
13 | const [o] = match
14 |
15 | // Find `@fs/`
16 | // Like: `http://localhost:5173/@fs/Users/innei/git/work/rss3/follow/node_modules/.vite/deps/chunk-RPCDYKBN.js?v=757920f2:11548:26`
17 | const realFsPath = o.split('@fs')[1]
18 |
19 | if (realFsPath) {
20 | return (
21 | // Delete `v=` hash, like `v=757920f2`
22 |
30 | {line}
31 |
32 | )
33 | } else {
34 | // at App (http://localhost:5173/src/App.tsx?t=1720527056591:41:9)
35 | const srcFsPath = o.split('/src')[1]
36 |
37 | if (srcFsPath) {
38 | const fs = srcFsPath.replace(/\?t=[\da-f]+/, '')
39 |
40 | return (
41 |
49 | {line}
50 |
51 | )
52 | }
53 | }
54 | }
55 |
56 | return line
57 | })
58 | }
59 | // http://localhost:5173/src/App.tsx?t=1720527056591:41:9
60 | const openInEditor = (file: string) => {
61 | fetch(`/__open-in-editor?file=${encodeURIComponent(`${file}`)}`)
62 | }
63 |
--------------------------------------------------------------------------------
/test/helper/create-e2e-app.ts:
--------------------------------------------------------------------------------
1 | import { AllExceptionsFilter } from '@core/common/filters/all-exception.filter'
2 | import { JSONTransformerInterceptor } from '@core/common/interceptors/json-transformer.interceptor'
3 | import { ResponseInterceptor } from '@core/common/interceptors/response.interceptor'
4 | import { AuthModule } from '@core/modules/auth/auth.module'
5 | import { UserModule } from '@core/modules/user/user.module'
6 | import { ConfigModule } from '@nestjs/config'
7 | import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'
8 | import { MockedHelperModule } from '@test/mock/helper/helper.module'
9 | import { MockedDatabaseModule } from '@test/mock/processors/database/database.module'
10 |
11 | import { redisHelper } from './redis-mock.helper'
12 | import { setupE2EApp } from './setup-e2e'
13 | import type { NestFastifyApplication } from '@nestjs/platform-fastify'
14 | import type { ModuleMetadata } from '@nestjs/common'
15 |
16 | export const createE2EApp = (module: ModuleMetadata) => {
17 | const proxy: {
18 | app: NestFastifyApplication
19 | } = {} as any
20 |
21 | beforeAll(async () => {
22 | const { CacheService, token, CacheModule } = await redisHelper
23 | const { ...nestModule } = module
24 | nestModule.imports ||= []
25 | nestModule.imports.push(
26 | MockedDatabaseModule,
27 | ConfigModule.forRoot({
28 | isGlobal: true,
29 | }),
30 | CacheModule,
31 | MockedHelperModule,
32 | AuthModule,
33 | UserModule,
34 | )
35 | nestModule.providers ||= []
36 |
37 | nestModule.providers.push(
38 | {
39 | provide: APP_INTERCEPTOR,
40 | useClass: JSONTransformerInterceptor, // 2
41 | },
42 |
43 | {
44 | provide: APP_INTERCEPTOR,
45 | useClass: ResponseInterceptor, // 1
46 | },
47 | {
48 | provide: APP_FILTER,
49 | useClass: AllExceptionsFilter,
50 | },
51 | { provide: token, useValue: CacheService },
52 | )
53 | const app = await setupE2EApp(nestModule)
54 |
55 | proxy.app = app
56 | })
57 |
58 | return proxy
59 | }
60 |
--------------------------------------------------------------------------------
/apps/core/src/modules/auth/auth.service.ts:
--------------------------------------------------------------------------------
1 | import { IncomingMessage } from 'node:http'
2 | import { Injectable } from '@nestjs/common'
3 |
4 | import {
5 | Auth,
6 | createActionURL,
7 | setEnvDefaults,
8 | type Session,
9 | } from '@packages/compiled'
10 | import { ServerAuthConfig } from './auth.implement'
11 | import type { users } from '@packages/drizzle/schema'
12 |
13 | export interface SessionUser {
14 | sessionToken: string
15 | userId: string
16 | expires: string
17 | user: typeof users.$inferSelect
18 | }
19 | @Injectable()
20 | export class AuthService {
21 | constructor(private readonly authConfig: ServerAuthConfig) {}
22 |
23 | private async getSessionBase(req: IncomingMessage, config: ServerAuthConfig) {
24 | setEnvDefaults(process.env, config)
25 |
26 | const protocol = (req.headers['x-forwarded-proto'] || 'http') as string
27 | const url = createActionURL(
28 | 'session',
29 | protocol,
30 | // @ts-expect-error
31 |
32 | new Headers(req.headers),
33 | process.env,
34 | config,
35 | )
36 |
37 | const response = await Auth(
38 | new Request(url, { headers: { cookie: req.headers.cookie ?? '' } }),
39 | config,
40 | )
41 |
42 | const { status = 200 } = response
43 |
44 | const data = await response.json()
45 |
46 | if (!data || !Object.keys(data).length) return null
47 | if (status === 200) return data
48 | }
49 |
50 | getSessionUser(req: IncomingMessage) {
51 | const { authConfig } = this
52 | return new Promise((resolve) => {
53 | this.getSessionBase(req, {
54 | ...authConfig,
55 | callbacks: {
56 | ...authConfig.callbacks,
57 | async session(...args) {
58 | resolve(args[0].session as any as SessionUser)
59 |
60 | const session =
61 | (await authConfig.callbacks?.session?.(...args)) ??
62 | args[0].session
63 | const user = args[0].user ?? args[0].token
64 | return { user, ...session } satisfies Session
65 | },
66 | },
67 | }).then((session) => {
68 | if (!session) {
69 | resolve(null)
70 | }
71 | })
72 | })
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/apps/web/src/hooks/common/useBizQuery.ts:
--------------------------------------------------------------------------------
1 | import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
2 | import type {
3 | InfiniteData,
4 | QueryKey,
5 | UseInfiniteQueryOptions,
6 | UseInfiniteQueryResult,
7 | UseQueryOptions,
8 | UseQueryResult,
9 | } from '@tanstack/react-query'
10 | import type { DefinedQuery } from '~/lib/defineQuery'
11 | import type { FetchError } from 'ofetch'
12 |
13 | // TODO split normal define query and infinite define query for better type checking
14 | export type SafeReturnType = T extends (...args: any[]) => infer R
15 | ? R
16 | : never
17 |
18 | export type CombinedObject = T & U
19 | export function useAuthQuery<
20 | TQuery extends DefinedQuery,
21 | TError = FetchError,
22 | TQueryFnData = Awaited>,
23 | TData = TQueryFnData,
24 | >(
25 | query: TQuery,
26 | options: Omit<
27 | UseQueryOptions,
28 | 'queryKey' | 'queryFn'
29 | > = {},
30 | ): CombinedObject<
31 | UseQueryResult,
32 | { key: TQuery['key']; fn: TQuery['fn'] }
33 | > {
34 | // @ts-expect-error
35 | return Object.assign(
36 | {},
37 | useQuery({
38 | queryKey: query.key,
39 | queryFn: query.fn,
40 | enabled: options.enabled !== false,
41 | ...options,
42 | }),
43 | {
44 | key: query.key,
45 | fn: query.fn,
46 | },
47 | )
48 | }
49 |
50 | export function useAuthInfiniteQuery<
51 | T extends DefinedQuery,
52 | E = FetchError,
53 | FNR = Awaited>,
54 | R = FNR,
55 | >(
56 | query: T,
57 | options: Omit, 'queryKey' | 'queryFn'>,
58 | ): CombinedObject<
59 | UseInfiniteQueryResult, FetchError>,
60 | { key: T['key']; fn: T['fn'] }
61 | > {
62 | // @ts-expect-error
63 | return Object.assign(
64 | {},
65 | // @ts-expect-error
66 | useInfiniteQuery({
67 | queryFn: query.fn,
68 | queryKey: query.key,
69 | enabled: options.enabled !== false,
70 | ...options,
71 | }),
72 | {
73 | key: query.key,
74 | fn: query.fn,
75 | },
76 | )
77 | }
78 |
79 | /**
80 | * @deprecated use `useAuthQuery` instead
81 | */
82 | export const useBizQuery = useAuthQuery
83 |
--------------------------------------------------------------------------------
/apps/core/src/utils/redis-sub-pub.util.ts:
--------------------------------------------------------------------------------
1 | import IORedis, { type Redis, type RedisOptions } from 'ioredis'
2 |
3 | import { REDIS } from '@core/app.config'
4 | import { Logger } from '@nestjs/common'
5 |
6 | import { isTest } from '../global/env.global'
7 |
8 | class RedisSubPub {
9 | public pubClient: Redis
10 | public subClient: Redis
11 | constructor(private channelPrefix: string = 'meta-channel#') {
12 | if (!isTest) {
13 | this.init()
14 | }
15 | }
16 |
17 | public init() {
18 | const redisOptions: RedisOptions = {
19 | host: REDIS.host,
20 | port: REDIS.port,
21 | }
22 |
23 | if (REDIS.password) {
24 | redisOptions.password = REDIS.password
25 | }
26 |
27 | const pubClient = new IORedis(redisOptions)
28 | const subClient = pubClient.duplicate()
29 | this.pubClient = pubClient
30 | this.subClient = subClient
31 | }
32 |
33 | public async publish(event: string, data: any) {
34 | const channel = this.channelPrefix + event
35 | const _data = JSON.stringify(data)
36 | if (event !== 'log') {
37 | Logger.debug(`发布事件:${channel} <- ${_data}`, RedisSubPub.name)
38 | }
39 | await this.pubClient.publish(channel, _data)
40 | }
41 |
42 | private ctc = new WeakMap()
43 |
44 | public async subscribe(event: string, callback: (data: any) => void) {
45 | const myChannel = this.channelPrefix + event
46 | this.subClient.subscribe(myChannel)
47 |
48 | const cb = (channel, message) => {
49 | if (channel === myChannel) {
50 | if (event !== 'log') {
51 | Logger.debug(`接收事件:${channel} -> ${message}`, RedisSubPub.name)
52 | }
53 | callback(JSON.parse(message))
54 | }
55 | }
56 |
57 | this.ctc.set(callback, cb)
58 | this.subClient.on('message', cb)
59 | }
60 |
61 | public async unsubscribe(event: string, callback: (data: any) => void) {
62 | const channel = this.channelPrefix + event
63 | this.subClient.unsubscribe(channel)
64 | const cb = this.ctc.get(callback)
65 | if (cb) {
66 | this.subClient.off('message', cb)
67 |
68 | this.ctc.delete(callback)
69 | }
70 | }
71 | }
72 |
73 | export const redisSubPub = new RedisSubPub()
74 |
75 | type Callback = (channel: string, message: string) => void
76 |
77 | export type { RedisSubPub }
78 |
--------------------------------------------------------------------------------
/apps/web/src/styles/layer.css:
--------------------------------------------------------------------------------
1 | /* This CSS File do not import anywhere, just write atom class for tailwindcss. The tailwindcss intellisense will be work. */
2 |
3 | @tailwind components;
4 |
5 | @layer components {
6 | .drag-region {
7 | -webkit-app-region: drag;
8 | }
9 |
10 | .no-drag-region {
11 | -webkit-app-region: no-drag;
12 | }
13 | .mask-squircle {
14 | mask-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJz48cGF0aCBkPSdNMTAwIDBDMjAgMCAwIDIwIDAgMTAwczIwIDEwMCAxMDAgMTAwIDEwMC0yMCAxMDAtMTAwUzE4MCAwIDEwMCAwWicvPjwvc3ZnPg==);
15 | }
16 | .mask {
17 | mask-size: contain;
18 | mask-repeat: no-repeat;
19 | mask-position: center;
20 | }
21 |
22 | .center {
23 | @apply flex items-center justify-center;
24 | }
25 |
26 | .shadow-perfect {
27 | /* https://codepen.io/jh3y/pen/yLWgjpd */
28 | --tint: 214;
29 | --alpha: 3;
30 | --base: hsl(var(--tint, 214) 80% 27% / calc(var(--alpha, 4) * 1%));
31 | /**
32 | * Use relative syntax to get to: hsl(221 25% 22% / 40%)
33 | */
34 | --shade: hsl(from var(--base) calc(h + 8) 25 calc(l - 5));
35 | --perfect-shadow: 0 0 0 1px var(--base), 0 1px 1px -0.5px var(--shade),
36 | 0 3px 3px -1.5px var(--shade), 0 6px 6px -3px var(--shade),
37 | 0 12px 12px -6px var(--base), 0 24px 24px -12px var(--base);
38 | box-shadow: var(--perfect-shadow);
39 | }
40 |
41 | .perfect-sm {
42 | --alpha: 1;
43 | }
44 |
45 | .perfect-md {
46 | --alpha: 2;
47 | }
48 |
49 | [data-theme='dark'] .shadow-perfect {
50 | --tint: 221;
51 | }
52 |
53 | .shadow-modal {
54 | @apply shadow-2xl shadow-stone-300 dark:shadow-stone-900;
55 | }
56 | /* Utils */
57 | .no-animation {
58 | --btn-focus-scale: 1;
59 | --animation-btn: 0;
60 | --animation-input: 0;
61 | }
62 | }
63 |
64 | /* Context menu */
65 | @layer components {
66 | .shadow-context-menu {
67 | box-shadow:
68 | rgba(0, 0, 0, 0.067) 0px 3px 8px,
69 | rgba(0, 0, 0, 0.067) 0px 2px 5px,
70 | rgba(0, 0, 0, 0.067) 0px 1px 1px;
71 | }
72 | }
73 |
74 | @layer base {
75 | .border-border {
76 | border-width: 1px;
77 | border-color: #e5e7eb;
78 | }
79 |
80 | [data-theme='dark'] .border-border {
81 | border-color: #374151;
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/apps/core/src/bootstrap.ts:
--------------------------------------------------------------------------------
1 | import chalk from 'chalk'
2 | import { Logger } from 'nestjs-pretty-logger'
3 |
4 | import { NestFactory } from '@nestjs/core'
5 | import { patchNestjsSwagger } from '@wahyubucil/nestjs-zod-openapi' // <-- add this. Import the patch for NestJS Swagger
6 |
7 | import { CROSS_DOMAIN, PORT } from './app.config'
8 | import { AppModule } from './app.module'
9 | import { fastifyApp } from './common/adapter/fastify.adapter'
10 | import { SpiderGuard } from './common/guards/spider.guard'
11 | import { LoggingInterceptor } from './common/interceptors/logging.interceptor'
12 | import { consola, logger } from './global/consola.global'
13 | import type { NestFastifyApplication } from '@nestjs/platform-fastify'
14 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
15 | import { isDev } from './global/env.global'
16 |
17 | // const APIVersion = 1
18 | const Origin = CROSS_DOMAIN.allowedOrigins
19 |
20 | declare const module: any
21 |
22 | export async function bootstrap() {
23 | const app = await NestFactory.create(
24 | AppModule,
25 | fastifyApp,
26 | { logger: ['error', 'debug'] },
27 | )
28 |
29 | const hosts = Origin.map((host) => new RegExp(host, 'i'))
30 |
31 | app.enableCors({
32 | origin: (origin, callback) => {
33 | const allow = hosts.some((host) => host.test(origin))
34 |
35 | callback(null, allow)
36 | },
37 | credentials: true,
38 | })
39 |
40 | if (isDev) {
41 | app.useGlobalInterceptors(new LoggingInterceptor())
42 | }
43 | app.useGlobalGuards(new SpiderGuard())
44 |
45 | const config = new DocumentBuilder()
46 | .setTitle('App API document')
47 | .setVersion('1.0')
48 | .build()
49 | patchNestjsSwagger({ schemasSort: 'alpha' })
50 | const document = SwaggerModule.createDocument(app, config)
51 | SwaggerModule.setup('docs', app, document)
52 |
53 | await app.listen(+PORT, '0.0.0.0', async () => {
54 | app.useLogger(app.get(Logger))
55 | consola.info('ENV:', process.env.NODE_ENV)
56 | const url = await app.getUrl()
57 | const pid = process.pid
58 |
59 | const prefix = 'P'
60 | consola.success(`[${prefix + pid}] Server listen on: ${url}`)
61 |
62 | logger.info(`Server is up. ${chalk.yellow(`+${performance.now() | 0}ms`)}`)
63 | })
64 | if (module.hot) {
65 | module.hot.accept()
66 | module.hot.dispose(() => app.close())
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/drizzle/0000_ancient_masque.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "account" (
2 | "userId" text NOT NULL,
3 | "type" text NOT NULL,
4 | "provider" text NOT NULL,
5 | "providerAccountId" text NOT NULL,
6 | "refresh_token" text,
7 | "access_token" text,
8 | "expires_at" integer,
9 | "token_type" text,
10 | "scope" text,
11 | "id_token" text,
12 | "session_state" text,
13 | CONSTRAINT "account_provider_providerAccountId_pk" PRIMARY KEY("provider","providerAccountId")
14 | );
15 | --> statement-breakpoint
16 | CREATE TABLE IF NOT EXISTS "authenticator" (
17 | "credentialID" text NOT NULL,
18 | "userId" text NOT NULL,
19 | "providerAccountId" text NOT NULL,
20 | "credentialPublicKey" text NOT NULL,
21 | "counter" integer NOT NULL,
22 | "credentialDeviceType" text NOT NULL,
23 | "credentialBackedUp" boolean NOT NULL,
24 | "transports" text,
25 | CONSTRAINT "authenticator_userId_credentialID_pk" PRIMARY KEY("userId","credentialID"),
26 | CONSTRAINT "authenticator_credentialID_unique" UNIQUE("credentialID")
27 | );
28 | --> statement-breakpoint
29 | CREATE TABLE IF NOT EXISTS "session" (
30 | "sessionToken" text PRIMARY KEY NOT NULL,
31 | "userId" text NOT NULL,
32 | "expires" timestamp NOT NULL
33 | );
34 | --> statement-breakpoint
35 | CREATE TABLE IF NOT EXISTS "user" (
36 | "id" text PRIMARY KEY NOT NULL,
37 | "name" text,
38 | "email" text NOT NULL,
39 | "emailVerified" timestamp,
40 | "image" text
41 | );
42 | --> statement-breakpoint
43 | CREATE TABLE IF NOT EXISTS "verificationToken" (
44 | "identifier" text NOT NULL,
45 | "token" text NOT NULL,
46 | "expires" timestamp NOT NULL,
47 | CONSTRAINT "verificationToken_identifier_token_pk" PRIMARY KEY("identifier","token")
48 | );
49 | --> statement-breakpoint
50 | DO $$ BEGIN
51 | ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
52 | EXCEPTION
53 | WHEN duplicate_object THEN null;
54 | END $$;
55 | --> statement-breakpoint
56 | DO $$ BEGIN
57 | ALTER TABLE "authenticator" ADD CONSTRAINT "authenticator_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
58 | EXCEPTION
59 | WHEN duplicate_object THEN null;
60 | END $$;
61 | --> statement-breakpoint
62 | DO $$ BEGIN
63 | ALTER TABLE "session" ADD CONSTRAINT "session_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
64 | EXCEPTION
65 | WHEN duplicate_object THEN null;
66 | END $$;
67 |
--------------------------------------------------------------------------------
/apps/core/src/common/contexts/request.context.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable dot-notation */
2 | // @reference https://github.com/ever-co/ever-gauzy/blob/d36b4f40b1446f3c33d02e0ba00b53a83109d950/packages/core/src/core/context/request-context.ts
3 | import * as cls from 'cls-hooked'
4 | import { UnauthorizedException } from '@nestjs/common'
5 | import type { IncomingMessage, ServerResponse } from 'node:http'
6 |
7 | type UserDocument = {}
8 |
9 | type Nullable = T | null
10 | export class RequestContext {
11 | readonly id: number
12 | request: IncomingMessage
13 | response: ServerResponse
14 |
15 | constructor(request: IncomingMessage, response: ServerResponse) {
16 | this.id = Math.random()
17 | this.request = request
18 | this.response = response
19 | }
20 |
21 | static currentRequestContext(): Nullable {
22 | const session = cls.getNamespace(RequestContext.name)
23 | if (session && session.active) {
24 | return session.get(RequestContext.name)
25 | }
26 |
27 | return null
28 | }
29 |
30 | static currentRequest(): Nullable {
31 | const requestContext = RequestContext.currentRequestContext()
32 |
33 | if (requestContext) {
34 | return requestContext.request
35 | }
36 |
37 | return null
38 | }
39 |
40 | static currentUser(throwError?: boolean): Nullable {
41 | const requestContext = RequestContext.currentRequestContext()
42 |
43 | if (requestContext) {
44 | const user: UserDocument = requestContext.request['user']
45 |
46 | if (user) {
47 | return user
48 | }
49 | }
50 |
51 | if (throwError) {
52 | throw new UnauthorizedException()
53 | }
54 |
55 | return null
56 | }
57 |
58 | static currentSession() {
59 | const requestContext = RequestContext.currentRequestContext()
60 | const session = requestContext?.request['session']
61 | if (!session) {
62 | throw new UnauthorizedException()
63 | }
64 | return session as {
65 | expires: string
66 | user: {
67 | name: string
68 | email: string
69 | image: string
70 | }
71 | }
72 | }
73 |
74 | static currentIsAuthenticated() {
75 | const requestContext = RequestContext.currentRequestContext()
76 |
77 | if (requestContext) {
78 | const isAuthenticated =
79 | requestContext.request['isAuthenticated'] ||
80 | requestContext.request['isAuthenticated']
81 |
82 | return !!isAuthenticated
83 | }
84 |
85 | return false
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/apps/web/src/modules/main-layout/MainLayoutHeader.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Menu,
3 | MenuButton,
4 | MenuItem,
5 | MenuItems,
6 | MenuSeparator,
7 | } from '@headlessui/react'
8 | import * as Avatar from '@radix-ui/react-avatar'
9 |
10 | import { useSession } from '~/hooks/biz/useSession'
11 | import { signOut } from '~/lib/auth'
12 |
13 | export const MainLayoutHeader = () => {
14 | const session = useSession()
15 | if (!session) return null
16 | return (
17 |
21 | )
22 | }
23 |
24 | const MainAvatar = () => {
25 | const session = useSession()!
26 | return (
27 |
73 | )
74 | }
75 |
--------------------------------------------------------------------------------
/apps/core/src/app.module.ts:
--------------------------------------------------------------------------------
1 | import { LoggerModule } from 'nestjs-pretty-logger'
2 |
3 | import {
4 | type MiddlewareConsumer,
5 | Module,
6 | type NestModule,
7 | Type,
8 | } from '@nestjs/common'
9 | import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'
10 | import { ThrottlerGuard } from '@nestjs/throttler'
11 |
12 | import { AppController } from './app.controller'
13 | import { AllExceptionsFilter } from './common/filters/all-exception.filter'
14 |
15 | import { HttpCacheInterceptor } from './common/interceptors/cache.interceptor'
16 | import { IdempotenceInterceptor } from './common/interceptors/idempotence.interceptor'
17 | import { JSONTransformerInterceptor } from './common/interceptors/json-transformer.interceptor'
18 | import { ResponseInterceptor } from './common/interceptors/response.interceptor'
19 | import { ZodValidationPipe } from './common/pipes/zod-validation.pipe'
20 | import { AuthModule } from './modules/auth/auth.module'
21 | import { CacheModule } from './processors/cache/cache.module'
22 | import { DatabaseModule } from './processors/database/database.module'
23 | import { GatewayModule } from './processors/gateway/gateway.module'
24 | import { HelperModule } from './processors/helper/helper.module'
25 | import { RequestContextMiddleware } from './common/middlewares/request-context.middleware'
26 | import { UserModule } from './modules/user/user.module'
27 | import { authConfig } from './modules/auth/auth.config'
28 |
29 | // Request ----->
30 | // Response <-----
31 | const appInterceptors: Type[] = [
32 | IdempotenceInterceptor,
33 | HttpCacheInterceptor,
34 | JSONTransformerInterceptor,
35 |
36 | ResponseInterceptor,
37 | ]
38 | @Module({
39 | imports: [
40 | // processors
41 | CacheModule,
42 | DatabaseModule,
43 | HelperModule,
44 | LoggerModule,
45 | GatewayModule,
46 |
47 | // BIZ
48 | AuthModule.forRoot(authConfig),
49 | UserModule,
50 | ],
51 | controllers: [AppController],
52 | providers: [
53 | ...appInterceptors.map((interceptor) => ({
54 | provide: APP_INTERCEPTOR,
55 | useClass: interceptor,
56 | })),
57 |
58 | {
59 | provide: APP_PIPE,
60 | useClass: ZodValidationPipe,
61 | },
62 |
63 | {
64 | provide: APP_GUARD,
65 | useClass: ThrottlerGuard,
66 | },
67 |
68 | {
69 | provide: APP_FILTER,
70 | useClass: AllExceptionsFilter,
71 | },
72 | ],
73 | })
74 | export class AppModule implements NestModule {
75 | configure(consumer: MiddlewareConsumer) {
76 | consumer.apply(RequestContextMiddleware).forRoutes('(.*)')
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/tool.utils.ts:
--------------------------------------------------------------------------------
1 | export const md5 = (text: string) =>
2 | require('node:crypto').createHash('md5').update(text).digest('hex') as string
3 |
4 | export function getAvatar(mail: string | undefined) {
5 | if (!mail) {
6 | return ''
7 | }
8 | return `https://cravatar.cn/avatar/${md5(mail)}?d=retro`
9 | }
10 |
11 | export function sleep(ms: number) {
12 | return new Promise((resolve) => setTimeout(resolve, ms))
13 | }
14 |
15 | export const safeJSONParse = (p: any) => {
16 | try {
17 | return JSON.parse(p)
18 | } catch {
19 | return null
20 | }
21 | }
22 |
23 | /**
24 | * hash string
25 | */
26 | export const hashString = function (str, seed = 0) {
27 | let h1 = 0xdeadbeef ^ seed,
28 | h2 = 0x41c6ce57 ^ seed
29 | for (let i = 0, ch; i < str.length; i++) {
30 | ch = str.charCodeAt(i)
31 | h1 = Math.imul(h1 ^ ch, 2654435761)
32 | h2 = Math.imul(h2 ^ ch, 1597334677)
33 | }
34 | h1 =
35 | Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
36 | Math.imul(h2 ^ (h2 >>> 13), 3266489909)
37 | h2 =
38 | Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
39 | Math.imul(h1 ^ (h1 >>> 13), 3266489909)
40 | return 4294967296 * (2097151 & h2) + (h1 >>> 0)
41 | }
42 |
43 | export async function* asyncPool(
44 | concurrency: number,
45 | iterable: T[],
46 | iteratorFn: (item: T, arr: T[]) => any,
47 | ) {
48 | const executing = new Set>()
49 | async function consume() {
50 | const [promise, value] = await Promise.race(executing)
51 | executing.delete(promise)
52 | return value
53 | }
54 | for (const item of iterable) {
55 | // Wrap iteratorFn() in an async fn to ensure we get a promise.
56 | // Then expose such promise, so it's possible to later reference and
57 | // remove it from the executing pool.
58 | const promise = (async () => await iteratorFn(item, iterable))().then(
59 | (value) => [promise, value],
60 | )
61 | executing.add(promise)
62 | if (executing.size >= concurrency) {
63 | yield await consume()
64 | }
65 | }
66 | while (executing.size) {
67 | yield await consume()
68 | }
69 | }
70 |
71 | export const camelcaseKey = (key: string) =>
72 | key.replaceAll(/_(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))
73 |
74 | export const camelcaseKeys = (obj: any) => {
75 | if (typeof obj !== 'object' || obj === null) {
76 | return obj
77 | }
78 | if (Array.isArray(obj)) {
79 | return obj.map(camelcaseKeys)
80 | }
81 | const n: any = {}
82 | Object.keys(obj).forEach((k) => {
83 | n[camelcaseKey(k)] = camelcaseKeys(obj[k])
84 | })
85 | return n
86 | }
87 |
--------------------------------------------------------------------------------
/apps/core/src/modules/auth/req.transformer.ts:
--------------------------------------------------------------------------------
1 | import { PayloadTooLargeException } from '@nestjs/common'
2 | import type { IncomingMessage } from 'node:http'
3 |
4 | /**
5 | * @param {import('http').IncomingMessage} req
6 |
7 | */
8 | function get_raw_body(req) {
9 | const h = req.headers
10 |
11 | if (!h['content-type']) {
12 | return null
13 | }
14 |
15 | const content_length = Number(h['content-length'])
16 |
17 | // check if no request body
18 | if (
19 | (req.httpVersionMajor === 1 &&
20 | Number.isNaN(content_length) &&
21 | h['transfer-encoding'] == null) ||
22 | content_length === 0
23 | ) {
24 | return null
25 | }
26 |
27 | if (req.destroyed) {
28 | const readable = new ReadableStream()
29 | readable.cancel()
30 | return readable
31 | }
32 |
33 | let size = 0
34 | let cancelled = false
35 |
36 | return new ReadableStream({
37 | start(controller) {
38 | req.on('error', (error) => {
39 | cancelled = true
40 | controller.error(error)
41 | })
42 |
43 | req.on('end', () => {
44 | if (cancelled) return
45 | controller.close()
46 | })
47 |
48 | req.on('data', (chunk) => {
49 | if (cancelled) return
50 |
51 | size += chunk.length
52 | if (size > content_length) {
53 | cancelled = true
54 |
55 | const constraint = content_length
56 | ? 'content-length'
57 | : 'BODY_SIZE_LIMIT'
58 | const message = `request body size exceeded ${constraint} of ${content_length}`
59 |
60 | const error = new PayloadTooLargeException(message)
61 | controller.error(error)
62 |
63 | return
64 | }
65 |
66 | controller.enqueue(chunk)
67 |
68 | if (controller.desiredSize === null || controller.desiredSize <= 0) {
69 | req.pause()
70 | }
71 | })
72 | },
73 |
74 | pull() {
75 | req.resume()
76 | },
77 |
78 | cancel(reason) {
79 | cancelled = true
80 | req.destroy(reason)
81 | },
82 | })
83 | }
84 |
85 | export async function getRequest(
86 | base: string,
87 | req: IncomingMessage,
88 | ): Promise {
89 | const headers = req.headers as Record
90 |
91 | // @ts-expect-error
92 | const request = new Request(base + req.originalUrl, {
93 | method: req.method,
94 | headers,
95 | body: get_raw_body(req),
96 | credentials: 'include',
97 | // @ts-expect-error
98 | duplex: 'half',
99 | })
100 | return request
101 | }
102 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nest-drizzle",
3 | "private": true,
4 | "packageManager": "pnpm@9.14.4",
5 | "description": "",
6 | "type": "module",
7 | "license": "MIT",
8 | "author": "Innei ",
9 | "scripts": {
10 | "build": "npm run build:packages && pnpm -C \"apps/core\" run build",
11 | "build:packages": "sh ./scripts/pre-build.sh",
12 | "db:generate": "drizzle-kit generate",
13 | "db:migrate": "drizzle-kit migrate",
14 | "db:studio": "drizzle-kit studio",
15 | "dev": "pnpm -C \"apps/core\" run start",
16 | "dev:web": "pnpm -C \"apps/web\" run dev",
17 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
18 | "lint": "eslint --fix",
19 | "prebuild": "rimraf dist",
20 | "predev": "npm run build:packages",
21 | "prepare": "if [ \"$NODE_ENV\" = \"production\" ]; then echo 'skip prepare in production' ;else corepack enable && simple-git-hooks; fi",
22 | "pretest": "npm run predev",
23 | "test": "pnpm -C \"test\" run test"
24 | },
25 | "dependencies": {
26 | "cross-env": "7.0.3",
27 | "lodash": "4.17.21"
28 | },
29 | "devDependencies": {
30 | "@eslint-react/eslint-plugin": "1.17.3",
31 | "@innei/bump-version": "^1.5.10",
32 | "@innei/prettier": "^0.15.0",
33 | "@nestjs/cli": "10.4.8",
34 | "@nestjs/schematics": "10.2.3",
35 | "@sxzz/eslint-config": "4.5.1",
36 | "concurrently": "9.1.0",
37 | "dotenv-cli": "7.4.4",
38 | "drizzle-kit": "0.29.1",
39 | "eslint": "9.16.0",
40 | "eslint-plugin-react-hooks": "5.1.0",
41 | "eslint-plugin-simple-import-sort": "^12.1.1",
42 | "fastify": "^4.29.0",
43 | "husky": "9.1.7",
44 | "lint-staged": "15.2.10",
45 | "prettier": "3.4.2",
46 | "rimraf": "6.0.1",
47 | "simple-git-hooks": "2.11.1",
48 | "ts-loader": "9.5.1",
49 | "tsconfig-paths": "4.2.0",
50 | "tsup": "8.3.5",
51 | "tsx": "4.19.2",
52 | "typescript": "^5.7.2",
53 | "zx": "8.2.4"
54 | },
55 | "resolutions": {
56 | "*>lodash": "4.17.21",
57 | "*>typescript": "^5.2.2",
58 | "pino": "./external/pino"
59 | },
60 | "simple-git-hooks": {
61 | "pre-commit": "pnpm exec lint-staged"
62 | },
63 | "lint-staged": {
64 | "*.{js,jsx,ts,tsx}": [
65 | "prettier --ignore-path ./.prettierignore --write "
66 | ],
67 | "*.{js,ts,cjs,mjs,jsx,tsx,json}": [
68 | "eslint --fix"
69 | ]
70 | },
71 | "bump": {
72 | "before": [
73 | "git pull --rebase"
74 | ]
75 | },
76 | "redisMemoryServer": {
77 | "downloadDir": "./tmp/redis/binaries",
78 | "version": "6.0.10",
79 | "disablePostinstall": "1",
80 | "systemBinary": "/opt/homebrew/bin/redis-server"
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/apps/web/src/components/ui/button/StyledButton.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom'
2 | import { clsx } from 'clsx'
3 | import { tv } from 'tailwind-variants'
4 | import type { FC, PropsWithChildren } from 'react'
5 |
6 | import { MotionButtonBase } from './MotionButton'
7 |
8 | const variantStyles = tv({
9 | base: 'inline-flex select-none cursor-default items-center gap-2 justify-center rounded-lg py-2 px-3 text-sm outline-offset-2 transition active:transition-none',
10 | variants: {
11 | variant: {
12 | primary: clsx(
13 | 'bg-accent text-zinc-100',
14 | 'hover:contrast-[1.10] active:contrast-125',
15 | 'font-semibold',
16 | 'disabled:cursor-not-allowed disabled:bg-accent/40 disabled:opacity-80 disabled:dark:text-zinc-50',
17 | 'dark:text-neutral-800',
18 | ),
19 | secondary: clsx(
20 | 'group rounded-full bg-gradient-to-b from-zinc-50/50 to-white/90 px-3 py-2 shadow-lg shadow-zinc-800/5 ring-1 ring-zinc-900/5 backdrop-blur transition dark:from-zinc-900/50 dark:to-zinc-800/90 dark:ring-white/10 dark:hover:ring-white/20',
21 | 'disabled:cursor-not-allowed disabled:bg-gray-400 disabled:opacity-80 disabled:dark:bg-gray-800 disabled:dark:text-zinc-50',
22 | ),
23 | },
24 | },
25 | })
26 | type NativeButtonProps = React.ButtonHTMLAttributes & {
27 | href?: string
28 | }
29 | type NativeLinkProps = React.AnchorHTMLAttributes
30 | type SharedProps = {
31 | variant?: 'primary' | 'secondary'
32 | className?: string
33 | isLoading?: boolean
34 | }
35 | type ButtonProps = SharedProps & (NativeButtonProps | NativeLinkProps)
36 |
37 | export const StyledButton: FC = ({
38 | variant = 'primary',
39 | className,
40 | isLoading,
41 | href,
42 |
43 | ...props
44 | }) => {
45 | const Wrapper = isLoading ? LoadingButtonWrapper : 'div'
46 | return (
47 |
48 | {href ? (
49 |
57 | ) : (
58 |
65 | )}
66 |
67 | )
68 | }
69 |
70 | const LoadingButtonWrapper: FC = ({ children }) => {
71 | return (
72 |
73 | {children}
74 |
75 |
78 |
79 | )
80 | }
81 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/schedule.util.ts:
--------------------------------------------------------------------------------
1 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
2 |
3 | export function scheduleMicrotask(callback: () => void) {
4 | sleep(0).then(callback)
5 | }
6 |
7 | // TYPES
8 |
9 | type NotifyCallback = () => void
10 |
11 | type NotifyFunction = (callback: () => void) => void
12 |
13 | type BatchNotifyFunction = (callback: () => void) => void
14 |
15 | export function createNotifyManager() {
16 | let queue: NotifyCallback[] = []
17 | let transactions = 0
18 | let notifyFn: NotifyFunction = (callback) => {
19 | callback()
20 | }
21 | let batchNotifyFn: BatchNotifyFunction = (callback: () => void) => {
22 | callback()
23 | }
24 |
25 | const batch = (callback: () => T): T => {
26 | let result
27 | transactions++
28 | try {
29 | result = callback()
30 | } finally {
31 | transactions--
32 | if (!transactions) {
33 | flush()
34 | }
35 | }
36 | return result
37 | }
38 |
39 | const schedule = (callback: NotifyCallback): void => {
40 | if (transactions) {
41 | queue.push(callback)
42 | } else {
43 | scheduleMicrotask(() => {
44 | notifyFn(callback)
45 | })
46 | }
47 | }
48 |
49 | /**
50 | * All calls to the wrapped function will be batched.
51 | */
52 | const batchCalls = (callback: T): T => {
53 | return ((...args: any[]) => {
54 | schedule(() => {
55 | callback(...args)
56 | })
57 | }) as any
58 | }
59 |
60 | const flush = (): void => {
61 | const originalQueue = queue
62 | queue = []
63 | if (originalQueue.length) {
64 | scheduleMicrotask(() => {
65 | batchNotifyFn(() => {
66 | originalQueue.forEach((callback) => {
67 | notifyFn(callback)
68 | })
69 | })
70 | })
71 | }
72 | }
73 |
74 | /**
75 | * Use this method to set a custom notify function.
76 | * This can be used to for example wrap notifications with `React.act` while running tests.
77 | */
78 | const setNotifyFunction = (fn: NotifyFunction) => {
79 | notifyFn = fn
80 | }
81 |
82 | /**
83 | * Use this method to set a custom function to batch notifications together into a single tick.
84 | * By default React Query will use the batch function provided by ReactDOM or React Native.
85 | */
86 | const setBatchNotifyFunction = (fn: BatchNotifyFunction) => {
87 | batchNotifyFn = fn
88 | }
89 |
90 | return {
91 | batch,
92 | batchCalls,
93 | schedule,
94 | setNotifyFunction,
95 | setBatchNotifyFunction,
96 | } as const
97 | }
98 |
99 | // SINGLETON
100 | export const scheduleManager = createNotifyManager()
101 |
--------------------------------------------------------------------------------
/apps/core/src/app.config.ts:
--------------------------------------------------------------------------------
1 | import { program } from 'commander'
2 | import { parseBooleanishValue } from './constants/parser.utilt'
3 | import { machineIdSync } from './shared/utils/machine.util'
4 | import 'dotenv-expand/config'
5 |
6 | import type { AxiosRequestConfig } from 'axios'
7 | import { isDev } from './global/env.global'
8 |
9 | export const API_VERSION = 1
10 |
11 | program
12 | .option('-p, --port ', 'port to listen')
13 | .option('--disable_cache', 'disable cache')
14 | .option('--redis_host ', 'redis host')
15 | .option('--redis_port ', 'redis port')
16 | .option('--redis_password ', 'redis password')
17 | .option('--jwtSecret ', 'jwt secret')
18 |
19 | program.parse(process.argv)
20 | const argv = program.opts()
21 |
22 | export const PORT = mergeArgv('port') || 3333
23 |
24 | export const CROSS_DOMAIN = {
25 | allowedOrigins: [
26 | 'innei.in',
27 | 'shizuri.net',
28 | 'localhost',
29 | '127.0.0.1',
30 | 'mbp.cc',
31 | 'local.innei.test',
32 | '22333322.xyz',
33 | ],
34 | allowedReferer: 'innei.in',
35 | }
36 |
37 | export const REDIS = {
38 | host: mergeArgv('redis_host') || 'localhost',
39 | port: mergeArgv('redis_port') || 6379,
40 | password: mergeArgv('redis_password') || null,
41 | ttl: null,
42 | max: 5,
43 | disableApiCache:
44 | (isDev || argv.disable_cache) && !process.env.ENABLE_CACHE_DEBUG,
45 | }
46 |
47 | export const HTTP_CACHE = {
48 | ttl: 15, // s
49 | enableCDNHeader:
50 | parseBooleanishValue(argv.http_cache_enable_cdn_header) ?? true, // s-maxage
51 | enableForceCacheHeader:
52 | parseBooleanishValue(argv.http_cache_enable_force_cache_header) ?? false, // cache-control: max-age
53 | }
54 |
55 | export const DATABASE = {
56 | url: mergeArgv('database_url'),
57 | }
58 |
59 | export const SECURITY = {
60 | jwtSecret: mergeArgv('jwtSecret') || 'asjhczxiucipoiopiqm2376',
61 | jwtExpire: '7d',
62 | }
63 |
64 | export const AXIOS_CONFIG: AxiosRequestConfig = {
65 | timeout: 10000,
66 | }
67 |
68 | export const CLUSTER = {
69 | enable: mergeArgv('cluster') ?? false,
70 | workers: mergeArgv('cluster_workers'),
71 | }
72 |
73 | const ENCRYPT_KEY = mergeArgv('encrypt_key') || mergeArgv('mx_encrypt_key')
74 |
75 | export const ENCRYPT = {
76 | key: ENCRYPT_KEY || machineIdSync(),
77 | enable: parseBooleanishValue(mergeArgv('encrypt_enable'))
78 | ? !!ENCRYPT_KEY
79 | : false,
80 | algorithm: mergeArgv('encrypt_algorithm') || 'aes-256-ecb',
81 | }
82 |
83 | export const AUTH = {
84 | github: {
85 | clientId: mergeArgv('github_client_id'),
86 | clientSecret: mergeArgv('github_client_secret'),
87 | },
88 | secret: mergeArgv('auth_secret'),
89 | }
90 |
91 | function mergeArgv(key: string) {
92 | const env = process.env
93 | const toUpperCase = (key: string) => key.toUpperCase()
94 | return argv[key] ?? env[toUpperCase(key)]
95 | }
96 |
--------------------------------------------------------------------------------
/apps/core/src/shared/utils/machine.util.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-prototype-builtins */
2 | import { exec, execSync } from 'node:child_process'
3 | import { createHash } from 'node:crypto'
4 |
5 | const { platform }: NodeJS.Process = process
6 | const win32RegBinPath: Record = {
7 | native: String.raw`%windir%\System32`,
8 | mixed: String.raw`%windir%\sysnative\cmd.exe /c %windir%\System32`,
9 | }
10 |
11 | const guid: Record = {
12 | darwin: 'ioreg -rd1 -c IOPlatformExpertDevice',
13 | win32: `${
14 | win32RegBinPath[isWindowsProcessMixedOrNativeArchitecture()]
15 | }\\REG.exe ${String.raw`QUERY HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Cryptography `}/v MachineGuid`,
16 | linux:
17 | '( cat /var/lib/dbus/machine-id /etc/machine-id 2> /dev/null || hostname ) | head -n 1 || :',
18 | freebsd: 'kenv -q smbios.system.uuid || sysctl -n kern.hostuuid',
19 | }
20 |
21 | function isWindowsProcessMixedOrNativeArchitecture(): string {
22 | // detect if the node binary is the same arch as the Windows OS.
23 | // or if this is 32 bit node on 64 bit windows.
24 | if (process.platform !== 'win32') {
25 | return ''
26 | }
27 | if (
28 | process.arch === 'ia32' &&
29 | process.env.hasOwnProperty('PROCESSOR_ARCHITEW6432')
30 | ) {
31 | return 'mixed'
32 | }
33 | return 'native'
34 | }
35 |
36 | function hash(guid: string): string {
37 | return createHash('sha256').update(guid).digest('hex')
38 | }
39 |
40 | function expose(result: string): string {
41 | switch (platform) {
42 | case 'darwin':
43 | return result
44 | .split('IOPlatformUUID')[1]
45 | .split('\n')[0]
46 | .replaceAll(/=|\s+|"/gi, '')
47 | .toLowerCase()
48 | case 'win32':
49 | return result
50 | .toString()
51 | .split('REG_SZ')[1]
52 | .replaceAll(/\r+|\n+|\s+/gi, '')
53 | .toLowerCase()
54 | case 'linux':
55 | return result
56 | .toString()
57 | .replaceAll(/\r+|\n+|\s+/gi, '')
58 | .toLowerCase()
59 | case 'freebsd':
60 | return result
61 | .toString()
62 | .replaceAll(/\r+|\n+|\s+/gi, '')
63 | .toLowerCase()
64 | default:
65 | throw new Error(`Unsupported platform: ${process.platform}`)
66 | }
67 | }
68 |
69 | export function machineIdSync(original?: boolean): string {
70 | const id: string = expose(execSync(guid[platform]).toString())
71 | return original ? id : hash(id)
72 | }
73 |
74 | export function machineId(original?: boolean): Promise {
75 | return new Promise((resolve, reject) => {
76 | return exec(guid[platform], {}, (err, stdout) => {
77 | if (err) {
78 | return reject(
79 | new Error(`Error while obtaining machine id: ${err.stack}`),
80 | )
81 | }
82 | const id: string = expose(stdout.toString())
83 | return resolve(original ? id : hash(id))
84 | })
85 | })
86 | }
87 |
--------------------------------------------------------------------------------
/drizzle/schema.ts:
--------------------------------------------------------------------------------
1 | import { snowflake } from '@packages/utils/snowflake'
2 |
3 | import {
4 | boolean,
5 | integer,
6 | pgTable,
7 | primaryKey,
8 | text,
9 | timestamp,
10 | } from 'drizzle-orm/pg-core'
11 | import type { AdapterAccountType } from '@packages/compiled'
12 |
13 | export const users = pgTable('user', {
14 | id: text('id')
15 | .primaryKey()
16 | .$defaultFn(() => snowflake.nextId().toString()),
17 | name: text('name'),
18 | email: text('email').notNull(),
19 | emailVerified: timestamp('emailVerified', { mode: 'date' }),
20 | image: text('image'),
21 |
22 | handle: text('handle'),
23 | })
24 |
25 | export const accounts = pgTable(
26 | 'account',
27 | {
28 | userId: text('userId')
29 | .notNull()
30 | .references(() => users.id, { onDelete: 'cascade' }),
31 | type: text('type').$type().notNull(),
32 | provider: text('provider').notNull(),
33 | providerAccountId: text('providerAccountId').notNull(),
34 | refresh_token: text('refresh_token'),
35 | access_token: text('access_token'),
36 | expires_at: integer('expires_at'),
37 | token_type: text('token_type'),
38 | scope: text('scope'),
39 | id_token: text('id_token'),
40 | session_state: text('session_state'),
41 | },
42 | (account) => ({
43 | compoundKey: primaryKey({
44 | columns: [account.provider, account.providerAccountId],
45 | }),
46 | }),
47 | )
48 |
49 | export const sessions = pgTable('session', {
50 | sessionToken: text('sessionToken').primaryKey(),
51 | userId: text('userId')
52 | .notNull()
53 | .references(() => users.id, { onDelete: 'cascade' }),
54 | expires: timestamp('expires', { mode: 'date' }).notNull(),
55 | })
56 |
57 | export const verificationTokens = pgTable(
58 | 'verificationToken',
59 | {
60 | identifier: text('identifier').notNull(),
61 | token: text('token').notNull(),
62 | expires: timestamp('expires', { mode: 'date' }).notNull(),
63 | },
64 | (verificationToken) => ({
65 | compositePk: primaryKey({
66 | columns: [verificationToken.identifier, verificationToken.token],
67 | }),
68 | }),
69 | )
70 |
71 | export const authenticators = pgTable(
72 | 'authenticator',
73 | {
74 | credentialID: text('credentialID').notNull().unique(),
75 | userId: text('userId')
76 | .notNull()
77 | .references(() => users.id, { onDelete: 'cascade' }),
78 | providerAccountId: text('providerAccountId').notNull(),
79 | credentialPublicKey: text('credentialPublicKey').notNull(),
80 | counter: integer('counter').notNull(),
81 | credentialDeviceType: text('credentialDeviceType').notNull(),
82 | credentialBackedUp: boolean('credentialBackedUp').notNull(),
83 | transports: text('transports'),
84 | },
85 | (authenticator) => ({
86 | compositePK: primaryKey({
87 | columns: [authenticator.userId, authenticator.credentialID],
88 | }),
89 | }),
90 | )
91 |
--------------------------------------------------------------------------------
/apps/web/src/components/common/ErrorElement.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react'
2 | import { isRouteErrorResponse, useRouteError } from 'react-router-dom'
3 | import { repository } from '@pkg'
4 |
5 | import { attachOpenInEditor } from '~/lib/dev'
6 |
7 | import { StyledButton } from '../ui'
8 |
9 | export function ErrorElement() {
10 | const error = useRouteError()
11 | const message = isRouteErrorResponse(error)
12 | ? `${error.status} ${error.statusText}`
13 | : error instanceof Error
14 | ? error.message
15 | : JSON.stringify(error)
16 | const stack = error instanceof Error ? error.stack : null
17 |
18 | useEffect(() => {
19 | console.error('Error handled by React Router default ErrorBoundary:', error)
20 | }, [error])
21 |
22 | const reloadRef = useRef(false)
23 | if (
24 | message.startsWith('Failed to fetch dynamically imported module') &&
25 | window.sessionStorage.getItem('reload') !== '1'
26 | ) {
27 | if (reloadRef.current) return null
28 | window.sessionStorage.setItem('reload', '1')
29 | window.location.reload()
30 | reloadRef.current = true
31 | return null
32 | }
33 |
34 | return (
35 |
36 |
37 |
38 |
39 |
40 | Sorry, the app has encountered an error
41 |
42 |
43 |
{message}
44 | {import.meta.env.DEV && stack ? (
45 |
46 | {attachOpenInEditor(stack)}
47 |
48 | ) : null}
49 |
50 |
51 | The App has a temporary problem, click the button below to try reloading
52 | the app or another solution?
53 |
54 |
55 |
56 | (window.location.href = '/')}>
57 | Reload
58 |
59 |
60 |
61 |
62 | Still having this issue? Please give feedback in Github, thanks!
63 |
73 | Submit Issue
74 |
75 |
76 |
77 |
78 | )
79 | }
80 |
--------------------------------------------------------------------------------
/apps/core/src/processors/gateway/web/events.gateway.ts:
--------------------------------------------------------------------------------
1 | import SocketIO from 'socket.io'
2 |
3 | import { BusinessEvents } from '@core/constants/business-event.constant'
4 | import { RedisKeys } from '@core/constants/cache.constant'
5 | import { CacheService } from '@core/processors/cache/cache.service'
6 | import { getRedisKey } from '@core/shared/utils/redis.util'
7 | import { scheduleManager } from '@core/shared/utils/schedule.util'
8 | import { getShortDate } from '@core/shared/utils/time.util'
9 | import {
10 | GatewayMetadata,
11 | OnGatewayConnection,
12 | OnGatewayDisconnect,
13 | WebSocketGateway,
14 | WebSocketServer,
15 | } from '@nestjs/websockets'
16 |
17 | import { BroadcastBaseGateway } from '../base.gateway'
18 |
19 | const namespace = 'web'
20 | @WebSocketGateway({
21 | namespace,
22 | })
23 | export class WebEventsGateway
24 | extends BroadcastBaseGateway
25 | implements OnGatewayConnection, OnGatewayDisconnect
26 | {
27 | constructor(private readonly cacheService: CacheService) {
28 | super()
29 | }
30 |
31 | @WebSocketServer()
32 | private namespace: SocketIO.Namespace
33 |
34 | async sendOnlineNumber() {
35 | return {
36 | online: await this.getCurrentClientCount(),
37 | timestamp: new Date().toISOString(),
38 | }
39 | }
40 |
41 | async getCurrentClientCount() {
42 | const server = this.namespace.server
43 | const sockets = await server.of(`/${namespace}`).adapter.sockets(new Set())
44 | return sockets.size
45 | }
46 |
47 | async handleConnection(socket: SocketIO.Socket) {
48 | this.broadcast(BusinessEvents.VISITOR_ONLINE, await this.sendOnlineNumber())
49 |
50 | scheduleManager.schedule(async () => {
51 | const redisClient = this.cacheService.getClient()
52 | const dateFormat = getShortDate(new Date())
53 |
54 | // get and store max_online_count
55 | const maxOnlineCount =
56 | +(await redisClient.hget(
57 | getRedisKey(RedisKeys.MaxOnlineCount),
58 | dateFormat,
59 | ))! || 0
60 | await redisClient.hset(
61 | getRedisKey(RedisKeys.MaxOnlineCount),
62 | dateFormat,
63 | Math.max(maxOnlineCount, await this.getCurrentClientCount()),
64 | )
65 | const key = getRedisKey(RedisKeys.MaxOnlineCount, 'total')
66 |
67 | const totalCount = +(await redisClient.hget(key, dateFormat))! || 0
68 | await redisClient.hset(key, dateFormat, totalCount + 1)
69 | })
70 |
71 | super.handleConnect(socket)
72 | }
73 |
74 | async handleDisconnect(client: SocketIO.Socket) {
75 | super.handleDisconnect(client)
76 | this.broadcast(
77 | BusinessEvents.VISITOR_OFFLINE,
78 | await this.sendOnlineNumber(),
79 | )
80 | }
81 |
82 | override broadcast(event: BusinessEvents, data: any) {
83 | const emitter = this.cacheService.emitter
84 |
85 | emitter
86 | .of(`/${namespace}`)
87 | .emit('message', this.gatewayMessageFormat(event, data))
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import react from '@eslint-react/eslint-plugin'
2 | import { sxzz } from '@sxzz/eslint-config'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 |
5 | export default sxzz(
6 | [
7 | {
8 | files: ['apps/web/**/*.{ts,tsx}'],
9 | ...react.configs.recommended,
10 | },
11 | {
12 | languageOptions: {
13 | parserOptions: {
14 | ecmaFeatures: {
15 | jsx: true,
16 | },
17 | },
18 | },
19 | files: ['apps/web/**/*.{ts,tsx}'],
20 | plugins: {
21 | 'react-hooks': reactHooks,
22 | },
23 | rules: {
24 | 'react-hooks/rules-of-hooks': 'error',
25 | 'react-hooks/exhaustive-deps': 'warn',
26 | },
27 | },
28 | {
29 | files: ['apps/core/src/**/*.{ts,tsx}'],
30 | languageOptions: {
31 | parserOptions: {
32 | emitDecoratorMetadata: true,
33 | experimentalDecorators: true,
34 | },
35 | },
36 | rules: {
37 | '@typescript-eslint/no-var-requires': 0,
38 | },
39 | },
40 | {
41 | ignores: [
42 | 'external/**/*',
43 | 'test/**/*.{ts,tsx}',
44 | 'drizzle/**/*.js',
45 | 'drizzle/meta/**',
46 | 'drizzle.config.ts',
47 | 'apps/web/cssAsPlugin.js',
48 | ],
49 | },
50 | {
51 | files: [
52 | 'packages/**/*.{ts,tsx}',
53 | 'drizzle/**/*.{ts,tsx}',
54 | 'apps/**/*.{ts,tsx}',
55 | ],
56 |
57 | rules: {
58 | 'import/order': 'off',
59 | 'import/first': 'error',
60 | 'import/newline-after-import': 'error',
61 | 'import/no-duplicates': 'error',
62 |
63 | eqeqeq: 'off',
64 |
65 | 'no-void': 0,
66 | '@typescript-eslint/consistent-type-imports': 'warn',
67 | '@typescript-eslint/consistent-type-assertions': 0,
68 | 'no-restricted-syntax': 0,
69 | 'unicorn/filename-case': 0,
70 | 'unicorn/prefer-math-trunc': 0,
71 |
72 | 'unused-imports/no-unused-imports': 'error',
73 |
74 | 'unused-imports/no-unused-vars': [
75 | 'error',
76 | {
77 | vars: 'all',
78 | varsIgnorePattern: '^_',
79 | args: 'after-used',
80 | argsIgnorePattern: '^_',
81 | ignoreRestSiblings: true,
82 | },
83 | ],
84 |
85 | // for node server runtime
86 | 'require-await': 0,
87 | 'unicorn/no-array-callback-reference': 0,
88 |
89 | 'node/prefer-global/process': 0,
90 | 'node/prefer-global/buffer': 'off',
91 | 'no-duplicate-imports': 'off',
92 | 'unicorn/explicit-length-check': 0,
93 | 'unicorn/prefer-top-level-await': 0,
94 | // readable push syntax
95 | 'unicorn/no-array-push-push': 0,
96 | 'unicorn/custom-error-definition': 0,
97 | },
98 | },
99 | ],
100 | {
101 | prettier: true,
102 | markdown: true,
103 | vue: false,
104 | unocss: false,
105 | },
106 | )
107 |
--------------------------------------------------------------------------------
/apps/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "App (Core)",
3 | "version": "0.0.1",
4 | "private": true,
5 | "packageManager": "pnpm@9.14.4",
6 | "description": "",
7 | "license": "MIT",
8 | "author": "Innei ",
9 | "scripts": {
10 | "build": "nest build --webpack",
11 | "dev": "npm run start",
12 | "start": "cross-env NODE_ENV=development nest start -w",
13 | "start:debug": "cross-env NODE_ENV=development nest start --debug --watch",
14 | "start:prod": "npm run prism:migrate:deploy && cross-env NODE_ENV=production node dist/main.js",
15 | "prod": "cross-env NODE_ENV=production pm2-runtime start ecosystem.config.js",
16 | "prod:pm2": "cross-env NODE_ENV=production pm2 restart ecosystem.config.js",
17 | "prod:stop": "pm2 stop ecosystem.config.js",
18 | "prod:debug": "cross-env NODE_ENV=production nest start --debug --watch"
19 | },
20 | "dependencies": {
21 | "@auth/drizzle-adapter": "1.7.4",
22 | "@fastify/static": "7.0.4",
23 | "@nestjs/cache-manager": "2.3.0",
24 | "@nestjs/common": "10.4.13",
25 | "@nestjs/config": "3.3.0",
26 | "@nestjs/core": "10.4.13",
27 | "@nestjs/event-emitter": "2.1.1",
28 | "@nestjs/jwt": "10.2.0",
29 | "@nestjs/passport": "10.0.3",
30 | "@nestjs/platform-fastify": "10.4.13",
31 | "@nestjs/platform-socket.io": "10.4.13",
32 | "@nestjs/schedule": "4.1.1",
33 | "@nestjs/swagger": "8.1.0",
34 | "@nestjs/throttler": "6.2.1",
35 | "@nestjs/websockets": "10.4.13",
36 | "@packages/compiled": "workspace:*",
37 | "@packages/drizzle": "workspace:*",
38 | "@packages/utils": "workspace:*",
39 | "@scalar/fastify-api-reference": "1.25.76",
40 | "@scalar/nestjs-api-reference": "0.3.172",
41 | "@socket.io/redis-adapter": "8.3.0",
42 | "@socket.io/redis-emitter": "5.1.0",
43 | "@wahyubucil/nestjs-zod-openapi": "0.1.2",
44 | "axios": "1.7.9",
45 | "cache-manager": "5.7.6",
46 | "cache-manager-ioredis": "2.1.0",
47 | "chalk": "^4.1.2",
48 | "cls-hooked": "4.2.2",
49 | "commander": "12.1.0",
50 | "consola": "^3.2.3",
51 | "cron": "^3.2.1",
52 | "cross-env": "7.0.3",
53 | "dayjs": "1.11.13",
54 | "dotenv": "16.4.7",
55 | "dotenv-expand": "12.0.1",
56 | "drizzle-zod": "0.5.1",
57 | "lodash": "4.17.21",
58 | "nestjs-pretty-logger": "0.3.1",
59 | "redis": "4.7.0",
60 | "reflect-metadata": "0.2.2",
61 | "rxjs": "7.8.1",
62 | "slugify": "1.6.6",
63 | "snakecase-keys": "8.0.1",
64 | "zod": "3.23.8"
65 | },
66 | "devDependencies": {
67 | "@nestjs/cli": "10.4.8",
68 | "@nestjs/schematics": "10.2.3",
69 | "@types/cache-manager": "4.0.6",
70 | "@types/lodash": "4.17.13",
71 | "@types/supertest": "6.0.2",
72 | "@types/ua-parser-js": "0.7.39",
73 | "fastify": "^4.29.0",
74 | "ioredis": "^5.4.1"
75 | },
76 | "bump": {
77 | "before": [
78 | "git pull --rebase"
79 | ]
80 | },
81 | "redisMemoryServer": {
82 | "downloadDir": "./tmp/redis/binaries",
83 | "version": "6.0.10",
84 | "disablePostinstall": "1",
85 | "systemBinary": "/opt/homebrew/bin/redis-server"
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/apps/web/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import daisyui from 'daisyui'
2 | import { withTV } from 'tailwind-variants/transformer'
3 | import type { Config } from 'tailwindcss'
4 |
5 | import { getIconCollections, iconsPlugin } from '@egoist/tailwindcss-icons'
6 | import typography from '@tailwindcss/typography'
7 |
8 | require('./cssAsPlugin')
9 |
10 | const twConfig: Config = {
11 | content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
12 | darkMode: ['class', '[data-theme="dark"]'],
13 | safelist: [],
14 | theme: {
15 | extend: {
16 | fontFamily: {
17 | sans: 'system-ui,-apple-system,PingFang SC,"Microsoft YaHei",Segoe UI,Roboto,Helvetica,noto sans sc,hiragino sans gb,"sans-serif",Apple Color Emoji,Segoe UI Emoji,Not Color Emoji',
18 | serif:
19 | '"Noto Serif CJK SC","Noto Serif SC",var(--font-serif),"Source Han Serif SC","Source Han Serif",source-han-serif-sc,SongTi SC,SimSum,"Hiragino Sans GB",system-ui,-apple-system,Segoe UI,Roboto,Helvetica,"Microsoft YaHei","WenQuanYi Micro Hei",sans-serif',
20 | mono: `"OperatorMonoSSmLig Nerd Font","Cascadia Code PL","FantasqueSansMono Nerd Font","operator mono",JetBrainsMono,"Fira code Retina","Fira code","Consolas", Monaco, "Hannotate SC", monospace, -apple-system`,
21 | },
22 | screens: {
23 | 'light-mode': { raw: '(prefers-color-scheme: light)' },
24 | 'dark-mode': { raw: '(prefers-color-scheme: dark)' },
25 |
26 | 'w-screen': '100vw',
27 | 'h-screen': '100vh',
28 | },
29 | maxWidth: {
30 | screen: '100vw',
31 | },
32 | width: {
33 | screen: '100vw',
34 | },
35 | height: {
36 | screen: '100vh',
37 | },
38 | maxHeight: {
39 | screen: '100vh',
40 | },
41 |
42 | colors: {
43 | themed: {
44 | bg_opacity: 'var(--bg-opacity)',
45 | },
46 | },
47 | },
48 | },
49 |
50 | daisyui: {
51 | logs: false,
52 | themes: [
53 | {
54 | light: {
55 | 'color-scheme': 'light',
56 | primary: '#33A6B8',
57 | secondary: '#A8D8B9',
58 | accent: '#33A6B8',
59 | 'accent-content': '#fafafa',
60 | neutral: '#C7C7CC',
61 | 'base-100': '#fff',
62 | 'base-content': '#000',
63 | info: '#007AFF',
64 | success: '#34C759',
65 | warning: '#FF9500',
66 | error: '#FF3B30',
67 | '--rounded-btn': '1.9rem',
68 | '--tab-border': '2px',
69 | '--tab-radius': '.5rem',
70 | },
71 | },
72 | {
73 | dark: {
74 | 'color-scheme': 'dark',
75 | primary: '#F596AA',
76 | secondary: '#FB966E',
77 | accent: '#F596AA',
78 | neutral: '#48484A',
79 | 'base-100': '#1C1C1E',
80 | 'base-content': '#FFF',
81 | info: '#0A84FF',
82 | success: '#30D158',
83 | warning: '#FF9F0A',
84 | error: '#FF453A',
85 | '--rounded-btn': '1.9rem',
86 | '--tab-border': '2px',
87 | '--tab-radius': '.5rem',
88 | },
89 | },
90 | ],
91 | darkTheme: 'dark',
92 | },
93 |
94 | plugins: [
95 | iconsPlugin({
96 | collections: {
97 | ...getIconCollections(['mingcute']),
98 | },
99 | }),
100 |
101 | typography,
102 | daisyui,
103 |
104 | require('tailwind-scrollbar'),
105 | require('@tailwindcss/container-queries'),
106 | require('tailwindcss-animated'),
107 |
108 | require('./src/styles/theme.css'),
109 | require('./src/styles/layer.css'),
110 | ],
111 | }
112 |
113 | export default withTV(twConfig)
114 |
--------------------------------------------------------------------------------
/packages/utils/snowflake.ts:
--------------------------------------------------------------------------------
1 | import os from 'node:os'
2 |
3 | class Snowflake {
4 | private static readonly epoch = 1617235200000n // 自定义起始时间(以毫秒为单位)
5 | private static readonly workerIdBits = 5n
6 | private static readonly datacenterIdBits = 5n
7 | private static readonly sequenceBits = 12n
8 |
9 | private static readonly maxWorkerId = (1n << Snowflake.workerIdBits) - 1n
10 | private static readonly maxDatacenterId =
11 | (1n << Snowflake.datacenterIdBits) - 1n
12 | private static readonly maxSequence = (1n << Snowflake.sequenceBits) - 1n
13 |
14 | private static readonly workerIdShift = Snowflake.sequenceBits
15 | private static readonly datacenterIdShift =
16 | Snowflake.sequenceBits + Snowflake.workerIdBits
17 | private static readonly timestampLeftShift =
18 | Snowflake.sequenceBits + Snowflake.workerIdBits + Snowflake.datacenterIdBits
19 |
20 | private workerId: bigint
21 | private datacenterId: bigint
22 | private sequence: bigint = 0n
23 | private lastTimestamp: bigint = -1n
24 |
25 | constructor(workerId: bigint, datacenterId: bigint) {
26 | if (workerId > Snowflake.maxWorkerId || workerId < 0n) {
27 | throw new Error(
28 | `worker Id can't be greater than ${Snowflake.maxWorkerId} or less than 0`,
29 | )
30 | }
31 | if (datacenterId > Snowflake.maxDatacenterId || datacenterId < 0n) {
32 | throw new Error(
33 | `datacenter Id can't be greater than ${Snowflake.maxDatacenterId} or less than 0`,
34 | )
35 | }
36 | this.workerId = workerId
37 | this.datacenterId = datacenterId
38 | }
39 |
40 | private timeGen(): bigint {
41 | return BigInt(Date.now())
42 | }
43 |
44 | private tilNextMillis(lastTimestamp: bigint): bigint {
45 | let timestamp = this.timeGen()
46 | while (timestamp <= lastTimestamp) {
47 | timestamp = this.timeGen()
48 | }
49 | return timestamp
50 | }
51 |
52 | public nextId(): string {
53 | let timestamp = this.timeGen()
54 |
55 | if (timestamp < this.lastTimestamp) {
56 | throw new Error(
57 | `Clock moved backwards. Refusing to generate id for ${
58 | this.lastTimestamp - timestamp
59 | } milliseconds`,
60 | )
61 | }
62 |
63 | if (this.lastTimestamp === timestamp) {
64 | this.sequence = (this.sequence + 1n) & Snowflake.maxSequence
65 | if (this.sequence === 0n) {
66 | timestamp = this.tilNextMillis(this.lastTimestamp)
67 | }
68 | } else {
69 | this.sequence = 0n
70 | }
71 |
72 | this.lastTimestamp = timestamp
73 |
74 | return (
75 | ((timestamp - Snowflake.epoch) << Snowflake.timestampLeftShift) |
76 | (this.datacenterId << Snowflake.datacenterIdShift) |
77 | (this.workerId << Snowflake.workerIdShift) |
78 | this.sequence
79 | ).toString()
80 | }
81 | }
82 |
83 | function getWorkerAndDatacenterId(): [number, number] {
84 | const interfaces = os.networkInterfaces()
85 | const addresses: string[] = []
86 |
87 | for (const k in interfaces) {
88 | for (const k2 in interfaces[k]) {
89 | const address = interfaces[k]?.[k2]
90 | if (address.family === 'IPv4' && !address.internal) {
91 | addresses.push(address.address)
92 | }
93 | }
94 | }
95 |
96 | // 取第一个非内部 IPv4 地址作为示例
97 | const ip = addresses[0]
98 |
99 | // 将 IP 地址转换为一个数字,然后提取低 5 位作为 workerId,高 5 位作为 datacenterId
100 | const ipParts = ip.split('.').map((part) => Number.parseInt(part, 10))
101 | const ipNumber =
102 | (ipParts[0] << 24) + (ipParts[1] << 16) + (ipParts[2] << 8) + ipParts[3]
103 | const workerId = ipNumber & 0x1f // 取低 5 位
104 | const datacenterId = (ipNumber >> 5) & 0x1f // 取接下来的 5 位
105 |
106 | return [workerId, datacenterId]
107 | }
108 |
109 | // 使用这个函数来初始化 Snowflake
110 | const [workerId, datacenterId] = getWorkerAndDatacenterId()
111 |
112 | export const snowflake = new Snowflake(BigInt(workerId), BigInt(datacenterId))
113 |
--------------------------------------------------------------------------------
/apps/core/src/common/filters/all-exception.filter.ts:
--------------------------------------------------------------------------------
1 | import fs, { WriteStream } from 'node:fs'
2 | import { resolve } from 'node:path'
3 | import chalk from 'chalk'
4 | import { FastifyReply, FastifyRequest } from 'fastify'
5 |
6 | import { HTTP_REQUEST_TIME } from '@core/constants/meta.constant'
7 | import { LOG_DIR } from '@core/constants/path.constant'
8 | import { REFLECTOR } from '@core/constants/system.constant'
9 | import { isDev, isTest } from '@core/global/env.global'
10 | import {
11 | ArgumentsHost,
12 | Catch,
13 | ExceptionFilter,
14 | HttpException,
15 | HttpStatus,
16 | Inject,
17 | Logger,
18 | } from '@nestjs/common'
19 | import { Reflector } from '@nestjs/core'
20 |
21 | import { getIp } from '../../shared/utils/ip.util'
22 | import { BizException } from '../exceptions/biz.exception'
23 | import { LoggingInterceptor } from '../interceptors/logging.interceptor'
24 |
25 | type myError = {
26 | readonly status: number
27 | readonly statusCode?: number
28 |
29 | readonly message?: string
30 | }
31 |
32 | @Catch()
33 | export class AllExceptionsFilter implements ExceptionFilter {
34 | private readonly logger = new Logger(AllExceptionsFilter.name)
35 | private errorLogPipe: WriteStream
36 | constructor(@Inject(REFLECTOR) private reflector: Reflector) {}
37 | catch(exception: unknown, host: ArgumentsHost) {
38 | const ctx = host.switchToHttp()
39 | const response = ctx.getResponse()
40 | const request = ctx.getRequest()
41 |
42 | if (request.method === 'OPTIONS') {
43 | return response.status(HttpStatus.OK).send()
44 | }
45 |
46 | const status =
47 | exception instanceof HttpException
48 | ? exception.getStatus()
49 | : (exception as myError)?.status ||
50 | (exception as myError)?.statusCode ||
51 | HttpStatus.INTERNAL_SERVER_ERROR
52 |
53 | const message =
54 | (exception as any)?.response?.message ||
55 | (exception as myError)?.message ||
56 | ''
57 |
58 | const bizCode = (exception as BizException).code
59 |
60 | const url = request.raw.url!
61 | if (status === HttpStatus.INTERNAL_SERVER_ERROR) {
62 | Logger.error(exception, undefined, 'Catch')
63 | console.error(exception)
64 |
65 | if (!isDev) {
66 | this.errorLogPipe =
67 | this.errorLogPipe ??
68 | fs.createWriteStream(resolve(LOG_DIR, 'error.log'), {
69 | flags: 'a+',
70 | encoding: 'utf-8',
71 | })
72 |
73 | this.errorLogPipe.write(
74 | `[${new Date().toISOString()}] ${decodeURI(url)}: ${
75 | (exception as any)?.response?.message ||
76 | (exception as myError)?.message
77 | }\n${(exception as Error).stack}\n`,
78 | )
79 | }
80 | } else {
81 | const ip = getIp(request)
82 | const logMessage = `IP: ${ip} Error Info: (${status}${
83 | bizCode ? ` ,B${bizCode}` : ''
84 | }) ${message} Path: ${decodeURI(url)}`
85 | if (isTest) console.log(logMessage)
86 | this.logger.warn(logMessage)
87 | }
88 | // @ts-ignore
89 | const prevRequestTs = this.reflector.get(HTTP_REQUEST_TIME, request as any)
90 |
91 | if (prevRequestTs) {
92 | const content = `${request.method} -> ${request.url}`
93 | Logger.debug(
94 | `--- ResponseError:${content}${chalk.yellow(
95 | ` +${Date.now() - prevRequestTs}ms`,
96 | )}`,
97 | LoggingInterceptor.name,
98 | )
99 | }
100 | const res = (exception as any).response
101 | response
102 | .status(status)
103 | .type('application/json')
104 | .send({
105 | ok: 0,
106 | code: res?.code || status,
107 | chMessage: res?.chMessage,
108 | message:
109 | (exception as any)?.response?.message ||
110 | (exception as any)?.message ||
111 | 'Unknown Error',
112 | })
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | on:
2 | push:
3 | tags:
4 | - 'v*'
5 |
6 | name: Release
7 |
8 | jobs:
9 | build:
10 | name: Upload Release Asset
11 | strategy:
12 | matrix:
13 | os: [ubuntu-latest]
14 | runs-on: ${{ matrix.os }}
15 | outputs:
16 | release_url: ${{ steps.create_release.outputs.upload_url }}
17 | steps:
18 | - name: Checkout code
19 | uses: actions/checkout@v4
20 |
21 | - uses: actions/setup-node@v4
22 | with:
23 | node-version: 20.x
24 | - name: Start MongoDB
25 | uses: supercharge/mongodb-github-action@v1.10.0
26 | with:
27 | mongodb-version: 4.4
28 | - name: Start Redis
29 | uses: supercharge/redis-github-action@1.7.0
30 | with:
31 | redis-version: 6
32 | - uses: pnpm/action-setup@v4.0.0
33 | with:
34 | run_install: true
35 | - name: Test
36 | run: |
37 | npm run lint
38 | npm run test
39 | npm run test:e2e
40 |
41 | - name: Build project
42 | run: |
43 | pnpm run bundle
44 | - name: Test Bundle Server
45 | run: |
46 | bash scripts/workflow/test-server.sh
47 | # - name: Zip Assets
48 | # run: |
49 | # sh scripts/zip-asset.sh
50 | - name: Create Release
51 | id: create_release
52 | uses: actions/create-release@v1
53 | env:
54 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
55 | with:
56 | tag_name: ${{ github.ref }}
57 | release_name: Release ${{ github.ref }}
58 | draft: false
59 | prerelease: false
60 | - name: Upload Release Asset
61 | id: upload-release-asset
62 | uses: actions/upload-release-asset@v1
63 | env:
64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65 | with:
66 | upload_url: ${{ steps.create_release.outputs.upload_url }}
67 | asset_path: ./release.zip
68 | asset_name: release-${{ matrix.os }}.zip
69 | asset_content_type: application/zip
70 | # deploy:
71 | # name: Deploy To Remote Server
72 | # runs-on: ubuntu-latest
73 | # needs: [build]
74 | # steps:
75 | # - name: Exec deploy script with SSH
76 | # uses: appleboy/ssh-action@master
77 | # env:
78 | # JWTSECRET: ${{ secrets.JWTSECRET }}
79 | # with:
80 | # command_timeout: 10m
81 | # host: ${{ secrets.HOST }}
82 | # username: ${{ secrets.USER }}
83 | # password: ${{ secrets.PASSWORD }}
84 | # envs: JWTSECRET
85 | # script_stop: true
86 | # script: |
87 | # whoami
88 | # cd
89 | # source ~/.zshrc
90 | # cd mx
91 | # ls -a
92 | # node server-deploy.js --jwtSecret=$JWTSECRET
93 |
94 | build_other_platform:
95 | name: Build Other Platform
96 | strategy:
97 | matrix:
98 | os: [macos-latest]
99 | runs-on: ${{ matrix.os }}
100 | needs: [build]
101 | steps:
102 | - name: Checkout code
103 | uses: actions/checkout@v4
104 | - name: Cache pnpm modules
105 | uses: actions/cache@v3
106 | env:
107 | cache-name: cache-pnpm-modules
108 | with:
109 | path: ~/.pnpm-store
110 | key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-${{ hashFiles('**/pnpm-lock.yaml') }}
111 | restore-keys: |
112 | ${{ runner.os }}-build-${{ env.cache-name }}-${{ matrix.node-version }}-
113 | - uses: pnpm/action-setup@v4.0.0
114 | with:
115 | run_install: true
116 | - name: Build project
117 | run: |
118 | pnpm run bundle
119 | - name: Zip Assets
120 | run: |
121 | sh scripts/zip-asset.sh
122 | - name: Upload Release Asset
123 | id: upload-release-asset
124 | uses: actions/upload-release-asset@v1
125 | env:
126 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
127 | with:
128 | upload_url: ${{ needs.build.outputs.release_url }}
129 | asset_path: ./release.zip
130 | asset_name: release-${{ matrix.os }}.zip
131 | asset_content_type: application/zip
132 |
--------------------------------------------------------------------------------
/apps/core/src/common/interceptors/idempotence.interceptor.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest } from 'fastify'
2 | import { catchError, tap } from 'rxjs'
3 |
4 | import {
5 | HTTP_IDEMPOTENCE_KEY,
6 | HTTP_IDEMPOTENCE_OPTIONS,
7 | } from '@core/constants/meta.constant'
8 | import { REFLECTOR } from '@core/constants/system.constant'
9 | import { CacheService } from '@core/processors/cache/cache.service'
10 | import { getIp } from '@core/shared/utils/ip.util'
11 | import { getRedisKey } from '@core/shared/utils/redis.util'
12 | import { hashString } from '@core/shared/utils/tool.utils'
13 | import {
14 | CallHandler,
15 | ConflictException,
16 | ExecutionContext,
17 | Inject,
18 | Injectable,
19 | NestInterceptor,
20 | SetMetadata,
21 | } from '@nestjs/common'
22 | import { Reflector } from '@nestjs/core'
23 |
24 | const IdempotenceHeaderKey = 'x-idempotence'
25 |
26 | export type IdempotenceOption = {
27 | errorMessage?: string
28 | pendingMessage?: string
29 |
30 | /**
31 | * 如果重复请求的话,手动处理异常
32 | */
33 | handler?: (req: FastifyRequest) => any
34 |
35 | /**
36 | * 记录重复请求的时间
37 | * @default 60
38 | */
39 | expired?: number
40 |
41 | /**
42 | * 如果 header 没有幂等 key,根据 request 生成 key,如何生成这个 key 的方法
43 | */
44 | generateKey?: (req: FastifyRequest) => string
45 |
46 | /**
47 | * 仅读取 header 的 key,不自动生成
48 | * @default false
49 | */
50 | disableGenerateKey?: boolean
51 | }
52 |
53 | @Injectable()
54 | export class IdempotenceInterceptor implements NestInterceptor {
55 | constructor(
56 | private readonly cacheService: CacheService,
57 | @Inject(REFLECTOR) private readonly reflector: Reflector,
58 | ) {}
59 |
60 | async intercept(context: ExecutionContext, next: CallHandler) {
61 | const request = context.switchToHttp().getRequest()
62 |
63 | // skip Get 请求
64 | if (request.method.toUpperCase() === 'GET') {
65 | return next.handle()
66 | }
67 |
68 | const handler = context.getHandler()
69 | const options: IdempotenceOption | undefined = this.reflector.get(
70 | HTTP_IDEMPOTENCE_OPTIONS,
71 | handler,
72 | )
73 |
74 | if (!options) {
75 | return next.handle()
76 | }
77 |
78 | const {
79 | errorMessage = '相同请求成功后在 60 秒内只能发送一次',
80 | pendingMessage = '相同请求正在处理中...',
81 | handler: errorHandler,
82 | expired = 60,
83 | disableGenerateKey = false,
84 | } = options
85 | const redis = this.cacheService.getClient()
86 |
87 | const idempotence = request.headers[IdempotenceHeaderKey] as string
88 | const key = disableGenerateKey
89 | ? undefined
90 | : options.generateKey
91 | ? options.generateKey(request)
92 | : this.generateKey(request)
93 |
94 | const idempotenceKey =
95 | !!(idempotence || key) && getRedisKey(`idempotence:${idempotence || key}`)
96 |
97 | SetMetadata(HTTP_IDEMPOTENCE_KEY, idempotenceKey)(handler)
98 |
99 | if (idempotenceKey) {
100 | const resultValue: '0' | '1' | null = (await redis.get(
101 | idempotenceKey,
102 | )) as any
103 | if (resultValue !== null) {
104 | if (errorHandler) {
105 | return await errorHandler(request)
106 | }
107 |
108 | const message = {
109 | 1: errorMessage,
110 | 0: pendingMessage,
111 | }[resultValue]
112 | throw new ConflictException(message)
113 | } else {
114 | await redis.set(idempotenceKey, '0', 'EX', expired)
115 | }
116 | }
117 | return next.handle().pipe(
118 | tap(async () => {
119 | idempotenceKey && (await redis.set(idempotenceKey, '1', 'KEEPTTL'))
120 | }),
121 | catchError(async (err) => {
122 | if (idempotenceKey) {
123 | await redis.del(idempotenceKey)
124 | }
125 | throw err
126 | }),
127 | )
128 | }
129 |
130 | private generateKey(req: FastifyRequest) {
131 | const { body, params, query = {}, headers, url } = req
132 |
133 | const obj = { body, url, params, query } as any
134 |
135 | const uuid = headers['x-uuid']
136 | if (uuid) {
137 | obj.uuid = uuid
138 | } else {
139 | const ua = headers['user-agent']
140 | const ip = getIp(req)
141 |
142 | if (!ua && !ip) {
143 | return undefined
144 | }
145 | Object.assign(obj, { ua, ip })
146 | }
147 |
148 | return hashString(JSON.stringify(obj))
149 | }
150 | }
151 |
--------------------------------------------------------------------------------