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
28 changed files with 4191 additions and 17575 deletions

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;