feat(server): init nestjs server (#1997)

Co-authored-by: himself65 <himself65@outlook.com>
This commit is contained in:
LongYinan 2023-04-18 11:24:44 +08:00 committed by GitHub
parent a92d0fff4a
commit 91c3040db7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 4191 additions and 17575 deletions

View File

@ -6,6 +6,7 @@
"always",
[
"electron",
"server",
"web",
"docs",
"component",

View File

@ -1,8 +1,11 @@
module.exports = {
/**
* @type {import('eslint').Linter.Config}
*/
const config = {
root: true,
settings: {
react: {
version: '18',
version: 'detect',
},
next: {
rootDir: 'apps/web',
@ -65,4 +68,14 @@ module.exports = {
},
],
},
overrides: [
{
files: 'apps/server/**/*.ts',
rules: {
'@typescript-eslint/consistent-type-imports': 0,
},
},
],
};
module.exports = config;

View File

@ -3,7 +3,6 @@
# check lockfile is up to date
yarn install
cd ./apps/eletron && yarn install
# lint staged files
yarn exec lint-staged

View File

@ -5,10 +5,6 @@
"author": "affine",
"description": "AFFiNE App",
"homepage": "https://github.com/toeverything/AFFiNE",
"workspaces": [
"../../packages/*",
"../../tests/fixtures"
],
"scripts": {
"dev": "cross-env NODE_ENV=development node scripts/dev.mjs",
"prod": "cross-env NODE_ENV=production node scripts/dev.mjs",

File diff suppressed because it is too large Load Diff

1
apps/server/.env.example Normal file
View File

@ -0,0 +1 @@
DATABASE_URL="postgresql://affine@localhost:5432/affine"

2
apps/server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
src/schema.gql

79
apps/server/package.json Normal file
View File

@ -0,0 +1,79 @@
{
"name": "@affine/server",
"private": true,
"version": "0.0.0",
"description": "Affine Node.js server",
"type": "module",
"scripts": {
"dev": "nodemon ./src/index.ts",
"test": "ava"
},
"dependencies": {
"@apollo/server": "^4.6.0",
"@nestjs/apollo": "^11.0.5",
"@nestjs/common": "^9.4.0",
"@nestjs/core": "^9.4.0",
"@nestjs/graphql": "^11.0.5",
"@nestjs/platform-express": "^9.4.0",
"@prisma/client": "^4.12.0",
"dotenv": "^16.0.3",
"graphql": "^16.6.0",
"graphql-type-json": "^0.3.2",
"lodash-es": "^4.17.21",
"prisma": "^4.12.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0"
},
"devDependencies": {
"@nestjs/testing": "^9.4.0",
"@types/lodash-es": "^4.14.194",
"@types/node": "^18.15.11",
"ava": "^5.2.0",
"nodemon": "^2.0.22",
"ts-node": "^10.9.1",
"typescript": "^5.0.4"
},
"nodemonConfig": {
"exec": "node",
"script": "./src/index.ts",
"nodeArgs": [
"--loader",
"ts-node/esm.mjs",
"--es-module-specifier-resolution",
"node"
],
"ignore": [
"**/__tests__/**",
"**/dist/**"
],
"env": {
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "development",
"DEBUG": "affine:*",
"FORCE_COLOR": true,
"DEBUG_COLORS": true
},
"delay": 1000
},
"ava": {
"extensions": {
"ts": "module"
},
"nodeArguments": [
"--loader",
"ts-node/esm.mjs",
"--es-module-specifier-resolution",
"node"
],
"files": [
"src/**/*.spec.ts"
],
"require": [
"./src/prelude.ts"
],
"environmentVariables": {
"TS_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "test"
}
}
}

52
apps/server/schema.prisma Normal file
View File

@ -0,0 +1,52 @@
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model google_users {
id String @id @db.VarChar
user_id String @db.VarChar
google_id String @unique @db.VarChar
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
model permissions {
id String @id @db.VarChar
workspace_id String @db.VarChar
user_id String? @db.VarChar
user_email String?
type Int @db.SmallInt
accepted Boolean @default(false)
created_at DateTime? @default(now()) @db.Timestamptz(6)
users users? @relation(fields: [user_id], references: [id], onDelete: Cascade)
workspaces workspaces @relation(fields: [workspace_id], references: [id], onDelete: Cascade)
}
model seaql_migrations {
version String @id @db.VarChar
applied_at BigInt
}
model users {
id String @id @db.VarChar
name String @db.VarChar
email String @unique @db.VarChar
avatar_url String? @db.VarChar
token_nonce Int? @default(0) @db.SmallInt
password String? @db.VarChar
created_at DateTime? @default(now()) @db.Timestamptz(6)
google_users google_users[]
permissions permissions[]
}
model workspaces {
id String @id @db.VarChar
public Boolean
type Int @db.SmallInt
created_at DateTime? @default(now()) @db.Timestamptz(6)
permissions permissions[]
}

16
apps/server/src/app.ts Normal file
View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from './config';
import { GqlModule } from './graphql.module';
import { BusinessModules } from './modules';
import { PrismaModule } from './prisma';
@Module({
imports: [
PrismaModule,
GqlModule,
ConfigModule.forRoot(),
...BusinessModules,
],
})
export class AppModule {}

View File

@ -0,0 +1,30 @@
import { Test } from '@nestjs/testing';
import test from 'ava';
import { Config, ConfigModule } from '..';
let config: Config;
test.beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [ConfigModule.forRoot()],
}).compile();
config = module.get(Config);
});
test('should be able to get config', t => {
t.assert(typeof config.host === 'string');
t.is(config.env, 'test');
});
test('should be able to override config', async t => {
const module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
host: 'testing',
}),
],
}).compile();
const config = module.get(Config);
t.is(config.host, 'testing');
});

View File

@ -0,0 +1,202 @@
import type { ApolloDriverConfig } from '@nestjs/apollo';
import type { LeafPaths } from '../utils/types';
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace globalThis {
// eslint-disable-next-line no-var
var AFFiNE: AFFiNEConfig;
}
}
export const enum ExternalAccount {
github = 'github',
google = 'google',
firebase = 'firebase',
}
type EnvConfigType = 'string' | 'int' | 'float' | 'boolean';
type ConfigPaths = LeafPaths<
Omit<
AFFiNEConfig,
| 'ENV_MAP'
| 'version'
| 'baseUrl'
| 'origin'
| 'prod'
| 'dev'
| 'test'
| 'deploy'
>,
'',
'....'
>;
/**
* parse number value from environment variables
*/
function int(value: string) {
const n = parseInt(value);
return Number.isNaN(n) ? undefined : n;
}
function float(value: string) {
const n = parseFloat(value);
return Number.isNaN(n) ? undefined : n;
}
function boolean(value: string) {
return value === '1' || value.toLowerCase() === 'true';
}
export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
if (typeof value === 'undefined') {
return;
}
return type === 'int'
? int(value)
: type === 'float'
? float(value)
: type === 'boolean'
? boolean(value)
: value;
}
/**
* All Configurations that would control AFFiNE server behaviors
*
*/
export interface AFFiNEConfig {
ENV_MAP: Record<string, ConfigPaths | [ConfigPaths, EnvConfigType?]>;
/**
* System version
*/
readonly version: string;
/**
* alias to `process.env.NODE_ENV`
*
* @default 'production'
* @env NODE_ENV
*/
readonly env: string;
/**
* fast environment judge
*/
get prod(): boolean;
get dev(): boolean;
get test(): boolean;
get deploy(): boolean;
/**
* Whether the server is hosted on a ssl enabled domain
*/
https: boolean;
/**
* where the server get deployed.
*
* @default 'localhost'
* @env AFFINE_SERVER_HOST
*/
host: string;
/**
* which port the server will listen on
*
* @default 3000
* @env AFFINE_SERVER_PORT
*/
port: number;
/**
* subpath where the server get deployed if there is.
*
* @default '' // empty string
* @env AFFINE_SERVER_SUB_PATH
*/
path: string;
/**
* Readonly property `baseUrl` is the full url of the server consists of `https://HOST:PORT/PATH`.
*
* if `host` is not `localhost` then the port will be ignored
*/
get baseUrl(): string;
/**
* Readonly property `origin` is domain origin in the form of `https://HOST:PORT` without subpath.
*
* if `host` is not `localhost` then the port will be ignored
*/
get origin(): string;
/**
* the apollo driver config
*/
graphql: ApolloDriverConfig;
/**
* object storage Config
*
* all artifacts and logs will be stored on instance disk,
* and can not shared between instances if not configured
*/
objectStorage: {
/**
* whether use remote object storage
*/
enable: boolean;
/**
* used to store all uploaded builds and analysis reports
*
* the concrete type definition is not given here because different storage providers introduce
* significant differences in configuration
*
* @example
* {
* provider: 'aws',
* region: 'eu-west-1',
* aws_access_key_id: '',
* aws_secret_access_key: '',
* // other aws storage config...
* }
*/
config: Record<string, string>;
};
/**
* authentication config
*/
auth: {
/**
* whether allow user to signup with email directly
*/
enableSignup: boolean;
/**
* whether allow user to signup by oauth providers
*/
enableOauth: boolean;
/**
* all available oauth providers
*/
oauthProviders: Partial<
Record<
ExternalAccount,
{
clientId: string;
clientSecret: string;
/**
* uri to start oauth flow
*/
authorizationUri?: string;
/**
* uri to authenticate `access_token` when user is redirected back from oauth provider with `code`
*/
accessTokenUri?: string;
/**
* uri to get user info with authenticated `access_token`
*/
userInfoUri?: string;
args?: Record<string, any>;
}
>
>;
};
}

View File

@ -0,0 +1,51 @@
import pkg from '../../package.json' assert { type: 'json' };
import type { AFFiNEConfig } from './def';
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => ({
version: pkg.version,
ENV_MAP: {},
env: process.env.NODE_ENV ?? 'development',
get prod() {
return this.env === 'production';
},
get dev() {
return this.env === 'development';
},
get test() {
return this.env === 'test';
},
get deploy() {
return !this.dev && !this.test;
},
https: false,
host: 'localhost',
port: 3000,
path: '',
get origin() {
return this.dev
? 'http://localhost:8080'
: `${this.https ? 'https' : 'http'}://${this.host}${
this.host === 'localhost' ? `:${this.port}` : ''
}`;
},
get baseUrl() {
return `${this.origin}${this.path}`;
},
graphql: {
buildSchemaOptions: {
numberScalarMode: 'integer',
},
introspection: true,
playground: true,
debug: true,
},
auth: {
enableSignup: true,
enableOauth: false,
oauthProviders: {},
},
objectStorage: {
enable: false,
config: {},
},
});

View File

@ -0,0 +1,15 @@
import { set } from 'lodash-es';
import { parseEnvValue } from './def';
for (const env in AFFiNE.ENV_MAP) {
const config = AFFiNE.ENV_MAP[env];
const [path, value] =
typeof config === 'string'
? [config, process.env[env]]
: [config[0], parseEnvValue(process.env[env], config[1])];
if (typeof value !== 'undefined') {
set(globalThis.AFFiNE, path, process.env[env]);
}
}

View File

@ -0,0 +1,69 @@
import type { DynamicModule, FactoryProvider } from '@nestjs/common';
import { merge } from 'lodash-es';
import type { DeepPartial } from '../utils/types';
import type { AFFiNEConfig } from './def';
type ConstructorOf<T> = {
new (): T;
};
function ApplyType<T>(): ConstructorOf<T> {
// @ts-expect-error used to fake the type of config
return class Inner implements T {
constructor() {}
};
}
/**
* usage:
* ```
* import { Config } from '@affine/server'
*
* class TestConfig {
* constructor(private readonly config: Config) {}
* test() {
* return this.config.env
* }
* }
* ```
*/
export class Config extends ApplyType<AFFiNEConfig>() {}
function createConfigProvider(
override?: DeepPartial<Config>
): FactoryProvider<Config> {
return {
provide: Config,
useFactory: () => {
const wrapper = new Config();
const config = merge({}, AFFiNE, override);
const proxy: Config = new Proxy(wrapper, {
get: (_target, property: keyof Config) => {
const desc = Object.getOwnPropertyDescriptor(AFFiNE, property);
if (desc?.get) {
return desc.get.call(proxy);
}
return config[property];
},
});
return proxy;
},
};
}
export class ConfigModule {
static forRoot = (override?: DeepPartial<Config>): DynamicModule => {
const provider = createConfigProvider(override);
return {
global: true,
module: ConfigModule,
providers: [provider],
exports: [provider],
};
};
}
export { AFFiNEConfig } from './def';

View File

@ -0,0 +1,30 @@
import type { ApolloDriverConfig } from '@nestjs/apollo';
import { ApolloDriver } from '@nestjs/apollo';
import { Global, Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { join } from 'path';
import { fileURLToPath } from 'url';
import { Config } from './config';
@Global()
@Module({
imports: [
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
useFactory: (config: Config) => {
return {
...config.graphql,
path: `${config.path}/graphql`,
autoSchemaFile: join(
fileURLToPath(import.meta.url),
'..',
'schema.gql'
),
};
},
inject: [Config],
}),
],
})
export class GqlModule {}

20
apps/server/src/index.ts Normal file
View File

@ -0,0 +1,20 @@
import './prelude';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app';
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
cors: {
origin:
process.env.AFFINE_ENV === 'preview'
? ['https://affine-preview.vercel.app']
: ['http://localhost:8080'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: '*',
},
bodyParser: true,
});
await app.listen(process.env.PORT ?? 3000);

View File

@ -0,0 +1,3 @@
import { WorkspaceModule } from './workspaces';
export const BusinessModules = [WorkspaceModule];

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { WorkspaceResolver } from './resolver';
@Module({
providers: [WorkspaceResolver],
})
export class WorkspaceModule {}

View File

@ -0,0 +1,47 @@
import {
Args,
Field,
ObjectType,
Query,
registerEnumType,
Resolver,
} from '@nestjs/graphql';
import type { workspaces } from '@prisma/client';
import { PrismaService } from '../../prisma/service';
export enum WorkspaceType {
Private = 0,
Normal = 1,
}
registerEnumType(WorkspaceType, {
name: 'WorkspaceType',
});
@ObjectType()
export class Workspace implements workspaces {
@Field()
id!: string;
@Field({ description: 'is Public workspace' })
public!: boolean;
@Field(() => WorkspaceType, { description: 'Workspace type' })
type!: WorkspaceType;
@Field({ description: 'Workspace created date' })
created_at!: Date;
}
@Resolver(() => Workspace)
export class WorkspaceResolver {
constructor(private readonly prisma: PrismaService) {}
@Query(() => Workspace, {
name: 'workspace',
description: 'Get workspace by id',
})
async workspace(@Args('id') id: string) {
return this.prisma.workspaces.findUnique({
where: { id },
});
}
}

View File

@ -0,0 +1,6 @@
import 'reflect-metadata';
import 'dotenv/config';
import { getDefaultAFFiNEConfig } from './config/default';
globalThis.AFFiNE = getDefaultAFFiNEConfig();

View File

@ -0,0 +1,10 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@ -0,0 +1,16 @@
import type { INestApplication, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on('beforeExit', async () => {
await app.close();
});
}
}

View File

@ -0,0 +1,42 @@
export type DeepPartial<T> = T extends Array<infer U>
? DeepPartial<U>[]
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
type Join<Prefix, Suffixes> = Prefix extends string | number
? Suffixes extends string | number
? Prefix extends ''
? Suffixes
: `${Prefix}.${Suffixes}`
: never
: never;
export type PrimitiveType =
| string
| number
| boolean
| symbol
| null
| undefined;
export type LeafPaths<
T,
Path extends string = '',
MaxDepth extends string = '...',
Depth extends string = ''
> = Depth extends MaxDepth
? never
: T extends Record<string | number, any>
? {
[K in keyof T]-?: K extends string | number
? T[K] extends PrimitiveType
? K
: Join<K, LeafPaths<T[K], Path, MaxDepth, `${Depth}.`>>
: never;
}[keyof T]
: never;

22
apps/server/tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"isolatedModules": false,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "dist",
"noEmit": false
},
"include": ["src", "package.json"],
"exclude": ["dist", "node_modules"],
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
}

View File

@ -5,7 +5,7 @@
"author": "toeverything",
"license": "MPL-2.0",
"workspaces": [
"apps/!(electron)",
"apps/*",
"packages/*",
"tests/fixtures"
],

View File

@ -15,6 +15,7 @@
"jsx": "preserve",
"incremental": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"paths": {
"@affine/component": ["./packages/component/src/index"],

3565
yarn.lock

File diff suppressed because it is too large Load Diff