diff --git a/.github/deployment/self-host/compose.yaml b/.github/deployment/self-host/compose.yaml index 1baa556f48..fa95f5bd32 100644 --- a/.github/deployment/self-host/compose.yaml +++ b/.github/deployment/self-host/compose.yaml @@ -27,9 +27,7 @@ services: - AFFINE_CONFIG_PATH=/root/.affine/config - REDIS_SERVER_HOST=redis - DATABASE_URL=postgres://affine:affine@postgres:5432/affine - - DISABLE_TELEMETRY=true - NODE_ENV=production - - SERVER_FLAVOR=selfhosted - AFFINE_ADMIN_EMAIL=${AFFINE_ADMIN_EMAIL} - AFFINE_ADMIN_PASSWORD=${AFFINE_ADMIN_PASSWORD} redis: diff --git a/.github/helm/affine/charts/graphql/templates/deployment.yaml b/.github/helm/affine/charts/graphql/templates/deployment.yaml index 4459bfeefa..8a4de5f5e3 100644 --- a/.github/helm/affine/charts/graphql/templates/deployment.yaml +++ b/.github/helm/affine/charts/graphql/templates/deployment.yaml @@ -39,6 +39,8 @@ spec: value: "--max-old-space-size=4096" - name: NO_COLOR value: "1" + - name: DEPLOYMENT_TYPE + value: "affine" - name: SERVER_FLAVOR value: "graphql" - name: AFFINE_ENV diff --git a/.github/helm/affine/charts/sync/templates/deployment.yaml b/.github/helm/affine/charts/sync/templates/deployment.yaml index f7e06cce7b..1952c8b8cf 100644 --- a/.github/helm/affine/charts/sync/templates/deployment.yaml +++ b/.github/helm/affine/charts/sync/templates/deployment.yaml @@ -36,6 +36,8 @@ spec: value: "{{ .Values.env }}" - name: NO_COLOR value: "1" + - name: DEPLOYMENT_TYPE + value: "affine" - name: SERVER_FLAVOR value: "sync" - name: NEXTAUTH_URL diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index 676b82b761..df596e5828 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -19,7 +19,7 @@ env: MACOSX_DEPLOYMENT_TARGET: '10.13' NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright - DISABLE_TELEMETRY: true + DEPLOYMENT_TYPE: affine concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -291,6 +291,7 @@ jobs: runs-on: ubuntu-latest needs: build-storage env: + NODE_ENV: test DISTRIBUTION: browser services: postgres: diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index 22b4a6fbe7..4fefebd0e7 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -162,7 +162,6 @@ "env": { "TS_NODE_TRANSPILE_ONLY": true, "TS_NODE_PROJECT": "./tsconfig.json", - "NODE_ENV": "development", "DEBUG": "affine:*", "FORCE_COLOR": true, "DEBUG_COLORS": true diff --git a/packages/backend/server/scripts/self-host-predeploy.js b/packages/backend/server/scripts/self-host-predeploy.js index bcd618a80f..2e71f738d7 100644 --- a/packages/backend/server/scripts/self-host-predeploy.js +++ b/packages/backend/server/scripts/self-host-predeploy.js @@ -13,7 +13,10 @@ const configFiles = [ ]; function configCleaner(content) { - return content.replace(/(\/\/#.*$)|(\/\/\s+TODO.*$)/gm, ''); + return content.replace( + /(^\/\/#.*$)|(^\/\/\s+TODO.*$)|("use\sstrict";?)|(^.*eslint-disable.*$)/gm, + '' + ); } function prepare() { diff --git a/packages/backend/server/src/app.controller.ts b/packages/backend/server/src/app.controller.ts index 0e3a4abbf2..6a217ef3eb 100644 --- a/packages/backend/server/src/app.controller.ts +++ b/packages/backend/server/src/app.controller.ts @@ -11,6 +11,7 @@ export class AppController { return { compatibility: this.config.version, message: `AFFiNE ${this.config.version} Server`, + type: this.config.type, flavor: this.config.flavor, }; } diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index f763a09286..8c243dd75c 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -109,7 +109,7 @@ export class AppModuleBuilder { }, ], imports: this.modules, - controllers: this.config.flavor.selfhosted ? [] : [AppController], + controllers: this.config.isSelfhosted ? [] : [AppController], }) class AppModule {} @@ -132,9 +132,9 @@ function buildAppModule() { // sync server only .useIf(config => config.flavor.sync, SyncModule) - // main server only + // graphql server only .useIf( - config => config.flavor.main, + config => config.flavor.graphql, ServerConfigModule, WebSocketModule, GqlModule, @@ -147,7 +147,7 @@ function buildAppModule() { // self hosted server only .useIf( - config => config.flavor.selfhosted, + config => config.isSelfhosted, ServeStaticModule.forRoot({ rootPath: join('/app', 'static'), }) diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index 16d5521bf2..452bc119d7 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -3,8 +3,7 @@ AFFiNE.ENV_MAP = { AFFINE_SERVER_PORT: ['port', 'int'], AFFINE_SERVER_HOST: 'host', AFFINE_SERVER_SUB_PATH: 'path', - AFFIHE_SERVER_HTTPS: ['https', 'boolean'], - AFFINE_ENV: 'affineEnv', + AFFINE_SERVER_HTTPS: ['https', 'boolean'], DATABASE_URL: 'db.url', ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'], CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'], @@ -28,7 +27,7 @@ AFFiNE.ENV_MAP = { REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'], DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'], DOC_MERGE_USE_JWST_CODEC: [ - 'doc.manager.experimentalMergeWithJwstCodec', + 'doc.manager.experimentalMergeWithYOcto', 'boolean', ], ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'], @@ -36,5 +35,3 @@ AFFiNE.ENV_MAP = { STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey', FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'], }; - -export default AFFiNE; diff --git a/packages/backend/server/src/config/affine.self.ts b/packages/backend/server/src/config/affine.self.ts new file mode 100644 index 0000000000..e877d1a220 --- /dev/null +++ b/packages/backend/server/src/config/affine.self.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +// Custom configurations for AFFiNE Cloud +// ==================================================================================== +// Q: WHY THIS FILE EXISTS? +// A: AFFiNE deployment environment may have a lot of custom environment variables, +// which are not suitable to be put in the `affine.ts` file. +// For example, AFFiNE Cloud Clusters are deployed on Google Cloud Platform. +// We need to enable the `gcloud` plugin to make sure the nodes working well, +// but the default selfhost version may not require it. +// So it's not a good idea to put such logic in the common `affine.ts` file. +// +// ``` +// if (AFFiNE.deploy) { +// AFFiNE.plugins.use('gcloud'); +// } +// ``` +// ==================================================================================== +const env = process.env; + +AFFiNE.metrics.enabled = !AFFiNE.node.test; + +if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) { + AFFiNE.storage.providers.r2 = { + accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID, + credentials: { + accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!, + secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!, + }, + }; + AFFiNE.storage.storages.avatar.provider = 'r2'; + AFFiNE.storage.storages.avatar.bucket = 'account-avatar'; + AFFiNE.storage.storages.avatar.publicLinkFactory = key => + `https://avatar.affineassets.com/${key}`; + + AFFiNE.storage.storages.blob.provider = 'r2'; + AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${ + AFFiNE.affine.canary ? 'canary' : 'prod' + }`; +} + +AFFiNE.plugins.use('redis'); +AFFiNE.plugins.use('payment'); + +if (AFFiNE.deploy) { + AFFiNE.plugins.use('gcloud'); +} diff --git a/packages/backend/server/src/config/affine.ts b/packages/backend/server/src/config/affine.ts index 3ff3211b28..74536d8d9f 100644 --- a/packages/backend/server/src/config/affine.ts +++ b/packages/backend/server/src/config/affine.ts @@ -1,39 +1,94 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ -// Custom configurations -const env = process.env; - -// TODO(@forehalo): detail explained -// Storage -if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) { - AFFiNE.storage.providers.r2 = { - accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID, - credentials: { - accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!, - secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!, - }, - }; - AFFiNE.storage.storages.avatar.provider = 'r2'; - AFFiNE.storage.storages.avatar.bucket = 'account-avatar'; - AFFiNE.storage.storages.avatar.publicLinkFactory = key => - `https://avatar.affineassets.com/${key}`; - - AFFiNE.storage.storages.blob.provider = 'r2'; - AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${ - AFFiNE.affine.canary ? 'canary' : 'prod' - }`; -} - -// Metrics -AFFiNE.metrics.enabled = true; - -// Plugins Section Start -AFFiNE.plugins.use('payment', { - stripe: { - keys: {}, - apiVersion: '2023-10-16', - }, +// +// ############################################################### +// ## AFFiNE Configuration System ## +// ############################################################### +// Here is the file of all AFFiNE configurations that will affect runtime behavior. +// Override any configuration here and it will be merged when starting the server. +// Any changes in this file won't take effect before server restarted. +// +// +// > Configurations merge order +// 1. load environment variables (`.env` if provided, and from system) +// 2. load `src/fundamentals/config/default.ts` for all default settings +// 3. apply `./affine.env.ts` patches +// 4. apply `./affine.ts` patches (this file) +// +// +// ############################################################### +// ## General settings ## +// ############################################################### +// +// /* The unique identity of the server */ +// AFFiNE.serverId = 'some-randome-uuid'; +// +// /* The name of AFFiNE Server, may show on the UI */ +// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud'; +// +// /* Whether the server is deployed behind a HTTPS proxied environment */ +AFFiNE.https = false; +// /* Domain of your server that your server will be available at */ +AFFiNE.host = 'localhost'; +// /* The local port of your server that will listen on */ +AFFiNE.port = 3010; +// /* The sub path of your server */ +// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */ +// AFFiNE.path = '/affine'; +// +// +// ############################################################### +// ## Database settings ## +// ############################################################### +// +// /* The URL of the database where most of AFFiNE server data will be stored in */ +// AFFiNE.db.url = 'postgres://user:passsword@localhost:5432/affine'; +// +// +// ############################################################### +// ## Server Function settings ## +// ############################################################### +// +// /* Whether enable metrics and tracing while running the server */ +// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */ +// AFFiNE.metrics.enabled = true; +// +// /* GraphQL configurations that control the behavior of the Apollo Server behind */ +// /* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server */ +// AFFiNE.graphql = { +// /* Path to mount GraphQL API */ +// path: '/graphql', +// buildSchemaOptions: { +// numberScalarMode: 'integer', +// }, +// /* Whether allow client to query the schema introspection */ +// introspection: true, +// /* Whether enable GraphQL Playground UI */ +// playground: true, +// } +// +// /* Doc Store & Collaberation */ +// /* How long the buffer time of creating a new history snapshot when doc get updated */ +// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes +// +// /* Use `y-octo` to merge updates at the same time when merging using Yjs */ +// AFFiNE.doc.manager.experimentalMergeWithYOcto = true; +// +// /* How often the manager will start a new turn of merging pending updates into doc snapshot */ +// AFFiNE.doc.manager.updatePollInterval = 1000 * 3; +// +// +// ############################################################### +// ## Plugins settings ## +// ############################################################### +// +// /* Redis Plugin */ +// /* Provide caching and session storing backed by Redis. */ +// /* Useful when you deploy AFFiNE server in a cluster. */ +AFFiNE.plugins.use('redis', { + /* override options */ }); -AFFiNE.plugins.use('redis'); -// Plugins Section end - -export default AFFiNE; +// /* Payment Plugin */ +AFFiNE.plugins.use('payment', { + stripe: { keys: {}, apiVersion: '2023-10-16' }, +}); +// diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index 75d9890bde..26f863d1f7 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -136,7 +136,7 @@ export class AuthService { return ( !!outcome.success && // skip hostname check in dev mode - (this.config.affineEnv === 'dev' || outcome.hostname === this.config.host) + (this.config.node.dev || outcome.hostname === this.config.host) ); } diff --git a/packages/backend/server/src/core/config.ts b/packages/backend/server/src/core/config.ts index 45dfe2462e..c6e7549058 100644 --- a/packages/backend/server/src/core/config.ts +++ b/packages/backend/server/src/core/config.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common'; import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql'; +import { DeploymentType } from '../fundamentals'; + export enum ServerFeature { Payment = 'payment', } @@ -9,6 +11,10 @@ registerEnumType(ServerFeature, { name: 'ServerFeature', }); +registerEnumType(DeploymentType, { + name: 'ServerDeploymentType', +}); + const ENABLED_FEATURES: ServerFeature[] = []; export function ADD_ENABLED_FEATURES(feature: ServerFeature) { ENABLED_FEATURES.push(feature); @@ -28,6 +34,9 @@ export class ServerConfigType { @Field({ description: 'server base url' }) baseUrl!: string; + @Field(() => DeploymentType, { description: 'server type' }) + type!: DeploymentType; + /** * @deprecated */ @@ -46,7 +55,11 @@ export class ServerConfigResolver { name: AFFiNE.serverName, version: AFFiNE.version, baseUrl: AFFiNE.baseUrl, - flavor: AFFiNE.flavor.type, + type: AFFiNE.type, + // BACKWARD COMPATIBILITY + // the old flavors contains `selfhosted` but it actually not flavor but deployment type + // this field should be removed after frontend feature flags implemented + flavor: AFFiNE.type, features: ENABLED_FEATURES, }; } diff --git a/packages/backend/server/src/core/doc/manager.ts b/packages/backend/server/src/core/doc/manager.ts index 74daacd3df..5034d81537 100644 --- a/packages/backend/server/src/core/doc/manager.ts +++ b/packages/backend/server/src/core/doc/manager.ts @@ -125,11 +125,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy { const doc = await this.recoverDoc(...updates); // test jwst codec - if ( - this.config.affine.canary && - this.config.doc.manager.experimentalMergeWithJwstCodec && - updates.length < 100 /* avoid overloading */ - ) { + if (this.config.doc.manager.experimentalMergeWithYOcto) { metrics.jwst.counter('codec_merge_counter').add(1); const yjsResult = Buffer.from(encodeStateAsUpdate(doc)); let log = false; @@ -180,7 +176,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy { }, this.config.doc.manager.updatePollInterval); this.logger.log('Automation started'); - if (this.config.doc.manager.experimentalMergeWithJwstCodec) { + if (this.config.doc.manager.experimentalMergeWithYOcto) { this.logger.warn( 'Experimental feature enabled: merge updates with jwst codec is enabled' ); diff --git a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts index 17b8ebb720..c0bd3493fd 100644 --- a/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts +++ b/packages/backend/server/src/data/migrations/1605053000403-self-host-admin.ts @@ -8,7 +8,7 @@ export class SelfHostAdmin1605053000403 { // do the migration static async up(db: PrismaClient, ref: ModuleRef) { const config = ref.get(Config, { strict: false }); - if (config.flavor.selfhosted) { + if (config.isSelfhosted) { if ( !process.env.AFFINE_ADMIN_EMAIL || !process.env.AFFINE_ADMIN_PASSWORD diff --git a/packages/backend/server/src/fundamentals/config/def.ts b/packages/backend/server/src/fundamentals/config/def.ts index 21e1c7e33e..cdfa6155d1 100644 --- a/packages/backend/server/src/fundamentals/config/def.ts +++ b/packages/backend/server/src/fundamentals/config/def.ts @@ -18,18 +18,22 @@ export enum ExternalAccount { firebase = 'firebase', } -export type ServerFlavor = - | 'allinone' - | 'main' - // @deprecated - | 'graphql' - | 'sync' - | 'selfhosted'; +export type ServerFlavor = 'allinone' | 'graphql' | 'sync'; +export type AFFINE_ENV = 'dev' | 'beta' | 'production'; +export type NODE_ENV = 'development' | 'test' | 'production'; + +export enum DeploymentType { + Affine = 'affine', + Selfhosted = 'selfhosted', +} + export type ConfigPaths = LeafPaths< Omit< AFFiNEConfig, | 'ENV_MAP' | 'version' + | 'type' + | 'isSelfhosted' | 'flavor' | 'env' | 'affine' @@ -63,27 +67,36 @@ export interface AFFiNEConfig { */ readonly version: string; + /** + * Deployment type, AFFiNE Cloud, or Selfhosted + */ + get type(): DeploymentType; + + /** + * Fast detect whether currently deployed in a selfhosted environment + */ + get isSelfhosted(): boolean; + /** * Server flavor */ get flavor(): { type: string; - main: boolean; + graphql: boolean; sync: boolean; - selfhosted: boolean; }; /** * Deployment environment */ - readonly affineEnv: 'dev' | 'beta' | 'production'; + readonly AFFINE_ENV: AFFINE_ENV; /** * alias to `process.env.NODE_ENV` * - * @default 'production' + * @default 'development' * @env NODE_ENV */ - readonly env: string; + readonly NODE_ENV: NODE_ENV; /** * fast AFFiNE environment judge @@ -101,6 +114,7 @@ export interface AFFiNEConfig { dev: boolean; test: boolean; }; + get deploy(): boolean; /** @@ -302,11 +316,11 @@ export interface AFFiNEConfig { updatePollInterval: number; /** - * Use JwstCodec to merge updates at the same time when merging using Yjs. + * Use `y-octo` to merge updates at the same time when merging using Yjs. * * This is an experimental feature, and aimed to check the correctness of JwstCodec. */ - experimentalMergeWithJwstCodec: boolean; + experimentalMergeWithYOcto: boolean; }; history: { /** diff --git a/packages/backend/server/src/fundamentals/config/default.ts b/packages/backend/server/src/fundamentals/config/default.ts index c5b90c229a..f7712629f0 100644 --- a/packages/backend/server/src/fundamentals/config/default.ts +++ b/packages/backend/server/src/fundamentals/config/default.ts @@ -6,7 +6,14 @@ import { merge } from 'lodash-es'; import parse from 'parse-duration'; import pkg from '../../../package.json' assert { type: 'json' }; -import type { AFFiNEConfig, ServerFlavor } from './def'; +import { + type AFFINE_ENV, + AFFiNEConfig, + DeploymentType, + type NODE_ENV, + type ServerFlavor, +} from './def'; +import { readEnv } from './env'; import { getDefaultAFFiNEStorageConfig } from './storage'; // Don't use this in production @@ -46,40 +53,62 @@ const jwtKeyPair = (function () { })(); export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { - let isHttps: boolean | null = null; - let flavor = (process.env.SERVER_FLAVOR ?? 'allinone') as ServerFlavor; + const NODE_ENV = readEnv('NODE_ENV', 'development', [ + 'development', + 'test', + 'production', + ]); + const AFFINE_ENV = readEnv('AFFINE_ENV', 'dev', [ + 'dev', + 'beta', + 'production', + ]); + const flavor = readEnv('SERVER_FLAVOR', 'allinone', [ + 'allinone', + 'graphql', + 'sync', + ]); + const deploymentType = readEnv( + 'DEPLOYMENT_TYPE', + NODE_ENV === 'development' + ? DeploymentType.Affine + : DeploymentType.Selfhosted, + Object.values(DeploymentType) + ); + const isSelfhosted = deploymentType === DeploymentType.Selfhosted; + const defaultConfig = { serverId: 'affine-nestjs-server', - serverName: flavor === 'selfhosted' ? 'Self-Host Cloud' : 'AFFiNE Cloud', + serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud', version: pkg.version, + get type() { + return deploymentType; + }, + get isSelfhosted() { + return isSelfhosted; + }, get flavor() { - if (flavor === 'graphql') { - flavor = 'main'; - } return { type: flavor, - main: flavor === 'main' || flavor === 'allinone', + graphql: flavor === 'graphql' || flavor === 'allinone', sync: flavor === 'sync' || flavor === 'allinone', - selfhosted: flavor === 'selfhosted', }; }, ENV_MAP: {}, - affineEnv: 'dev', + AFFINE_ENV, get affine() { - const env = this.affineEnv; return { - canary: env === 'dev', - beta: env === 'beta', - stable: env === 'production', + canary: AFFINE_ENV === 'dev', + beta: AFFINE_ENV === 'beta', + stable: AFFINE_ENV === 'production', }; }, - env: process.env.NODE_ENV ?? 'development', + NODE_ENV, get node() { - const env = this.env; return { - prod: env === 'production', - dev: env === 'development', - test: env === 'test', + prod: NODE_ENV === 'production', + dev: NODE_ENV === 'development', + test: NODE_ENV === 'test', }; }, get deploy() { @@ -88,12 +117,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { featureFlags: { earlyAccessPreview: false, }, - get https() { - return isHttps ?? !this.node.dev; - }, - set https(value: boolean) { - isHttps = value; - }, + https: false, host: 'localhost', port: 3010, path: '', @@ -160,7 +184,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => { manager: { enableUpdateAutoMerging: flavor !== 'sync', updatePollInterval: 3000, - experimentalMergeWithJwstCodec: false, + experimentalMergeWithYOcto: false, }, history: { interval: 1000 * 60 * 10 /* 10 mins */, diff --git a/packages/backend/server/src/fundamentals/config/env.ts b/packages/backend/server/src/fundamentals/config/env.ts index 50554bc462..21c16c4738 100644 --- a/packages/backend/server/src/fundamentals/config/env.ts +++ b/packages/backend/server/src/fundamentals/config/env.ts @@ -48,3 +48,24 @@ export function applyEnvToConfig(rawConfig: AFFiNEConfig) { } } } + +export function readEnv( + env: string, + defaultValue: T, + availableValues?: T[] +) { + const value = process.env[env]; + if (value === undefined) { + return defaultValue; + } + + if (availableValues && !availableValues.includes(value as any)) { + throw new Error( + `Invalid value '${value}' for environment variable ${env}, expected one of [${availableValues.join( + ', ' + )}]` + ); + } + + return value as T; +} diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index 35fc1db0c5..9b77c08e08 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -9,6 +9,7 @@ export { applyEnvToConfig, Config, type ConfigPaths, + DeploymentType, getDefaultAFFiNEStorageConfig, } from './config'; export * from './error'; diff --git a/packages/backend/server/src/fundamentals/metrics/index.ts b/packages/backend/server/src/fundamentals/metrics/index.ts index dc2b481daa..9685b58d49 100644 --- a/packages/backend/server/src/fundamentals/metrics/index.ts +++ b/packages/backend/server/src/fundamentals/metrics/index.ts @@ -1,28 +1,48 @@ -import { Global, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { + Global, + Module, + OnModuleDestroy, + OnModuleInit, + Provider, +} from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; import { NodeSDK } from '@opentelemetry/sdk-node'; -import { Config, parseEnvValue } from '../config'; -import { createSDK, registerCustomMetrics } from './opentelemetry'; +import { Config } from '../config'; +import { + LocalOpentelemetryFactory, + OpentelemetryFactory, + registerCustomMetrics, +} from './opentelemetry'; + +const factorProvider: Provider = { + provide: OpentelemetryFactory, + useFactory: (config: Config) => { + return config.metrics.enabled ? new LocalOpentelemetryFactory() : null; + }, + inject: [Config], +}; @Global() -@Module({}) +@Module({ + providers: [factorProvider], + exports: [factorProvider], +}) export class MetricsModule implements OnModuleInit, OnModuleDestroy { private sdk: NodeSDK | null = null; - constructor(private readonly config: Config) {} + constructor(private readonly ref: ModuleRef) {} onModuleInit() { - if ( - this.config.metrics.enabled && - !parseEnvValue(process.env.DISABLE_TELEMETRY, 'boolean') - ) { - this.sdk = createSDK(); + const factor = this.ref.get(OpentelemetryFactory, { strict: false }); + if (factor) { + this.sdk = factor.create(); this.sdk.start(); registerCustomMetrics(); } } async onModuleDestroy() { - if (this.config.metrics.enabled && this.sdk) { + if (this.sdk) { await this.sdk.shutdown(); } } @@ -30,3 +50,4 @@ export class MetricsModule implements OnModuleInit, OnModuleDestroy { export * from './metrics'; export * from './utils'; +export { OpentelemetryFactory }; diff --git a/packages/backend/server/src/fundamentals/metrics/opentelemetry.ts b/packages/backend/server/src/fundamentals/metrics/opentelemetry.ts index c7457276c2..64bc9b1b94 100644 --- a/packages/backend/server/src/fundamentals/metrics/opentelemetry.ts +++ b/packages/backend/server/src/fundamentals/metrics/opentelemetry.ts @@ -1,6 +1,4 @@ -import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter'; -import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter'; -import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util'; +import { OnModuleDestroy } from '@nestjs/common'; import { metrics } from '@opentelemetry/api'; import { CompositePropagator, @@ -18,16 +16,13 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core' import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io'; import { Resource } from '@opentelemetry/resources'; import { - ConsoleMetricExporter, type MeterProvider, MetricProducer, MetricReader, - PeriodicExportingMetricReader, } from '@opentelemetry/sdk-metrics'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { BatchSpanProcessor, - ConsoleSpanExporter, SpanExporter, TraceIdRatioBasedSampler, } from '@opentelemetry/sdk-trace-node'; @@ -38,7 +33,7 @@ import { PrismaMetricProducer } from './prisma'; const { PrismaInstrumentation } = prismaInstrument; -abstract class OpentelemetryFactor { +export abstract class OpentelemetryFactory { abstract getMetricReader(): MetricReader; abstract getSpanExporter(): SpanExporter; @@ -59,7 +54,7 @@ abstract class OpentelemetryFactor { getResource() { return new Resource({ - [SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.affineEnv, + [SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.AFFINE_ENV, [SemanticResourceAttributes.SERVICE_NAME]: AFFiNE.flavor.type, [SemanticResourceAttributes.SERVICE_VERSION]: AFFiNE.version, }); @@ -85,32 +80,20 @@ abstract class OpentelemetryFactor { } } -class GCloudOpentelemetryFactor extends OpentelemetryFactor { - override getResource(): Resource { - return super.getResource().merge(new GcpDetectorSync().detect()); +export class LocalOpentelemetryFactory + extends OpentelemetryFactory + implements OnModuleDestroy +{ + private readonly metricsExporter = new PrometheusExporter({ + metricProducers: this.getMetricsProducers(), + }); + + async onModuleDestroy() { + await this.metricsExporter.shutdown(); } override getMetricReader(): MetricReader { - return new PeriodicExportingMetricReader({ - exportIntervalMillis: 30000, - exportTimeoutMillis: 10000, - exporter: new MetricExporter({ - prefix: 'custom.googleapis.com', - }), - metricProducers: this.getMetricsProducers(), - }); - } - - override getSpanExporter(): SpanExporter { - return new TraceExporter(); - } -} - -class LocalOpentelemetryFactor extends OpentelemetryFactor { - override getMetricReader(): MetricReader { - return new PrometheusExporter({ - metricProducers: this.getMetricsProducers(), - }); + return this.metricsExporter; } override getSpanExporter(): SpanExporter { @@ -118,33 +101,6 @@ class LocalOpentelemetryFactor extends OpentelemetryFactor { } } -class DebugOpentelemetryFactor extends OpentelemetryFactor { - override getMetricReader(): MetricReader { - return new PeriodicExportingMetricReader({ - exporter: new ConsoleMetricExporter(), - metricProducers: this.getMetricsProducers(), - }); - } - - override getSpanExporter(): SpanExporter { - return new ConsoleSpanExporter(); - } -} - -// TODO(@forehalo): make it configurable -export function createSDK() { - let factor: OpentelemetryFactor | null = null; - if (process.env.NODE_ENV === 'production') { - factor = new GCloudOpentelemetryFactor(); - } else if (process.env.DEBUG_METRICS) { - factor = new DebugOpentelemetryFactor(); - } else { - factor = new LocalOpentelemetryFactor(); - } - - return factor?.create(); -} - function getMeterProvider() { return metrics.getMeterProvider(); } diff --git a/packages/backend/server/src/index.ts b/packages/backend/server/src/index.ts index 19a01d349d..1b13da97a0 100644 --- a/packages/backend/server/src/index.ts +++ b/packages/backend/server/src/index.ts @@ -1,14 +1,16 @@ /// -// keep the config import at the top -// eslint-disable-next-line simple-import-sort/imports import './prelude'; + +import { Logger } from '@nestjs/common'; + import { createApp } from './app'; const app = await createApp(); const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost'; await app.listen(AFFiNE.port, listeningHost); -console.log( - `AFFiNE Server has been started on http://${listeningHost}:${AFFiNE.port}.` -); -console.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`); +const logger = new Logger('App'); + +logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`); +logger.log(`Listening on http://${listeningHost}:${AFFiNE.port}`); +logger.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`); diff --git a/packages/backend/server/src/plugins/config.ts b/packages/backend/server/src/plugins/config.ts index 9d077ebff0..7f5acb1704 100644 --- a/packages/backend/server/src/plugins/config.ts +++ b/packages/backend/server/src/plugins/config.ts @@ -1,3 +1,4 @@ +import { GCloudConfig } from './gcloud/config'; import { PaymentConfig } from './payment'; import { RedisOptions } from './redis'; @@ -5,6 +6,7 @@ declare module '../fundamentals/config' { interface PluginsConfig { readonly payment: PaymentConfig; readonly redis: RedisOptions; + readonly gcloud: GCloudConfig; } export type AvailablePlugins = keyof PluginsConfig; diff --git a/packages/backend/server/src/plugins/gcloud/config.ts b/packages/backend/server/src/plugins/gcloud/config.ts new file mode 100644 index 0000000000..9bca1ceeb1 --- /dev/null +++ b/packages/backend/server/src/plugins/gcloud/config.ts @@ -0,0 +1 @@ +export interface GCloudConfig {} diff --git a/packages/backend/server/src/plugins/gcloud/index.ts b/packages/backend/server/src/plugins/gcloud/index.ts new file mode 100644 index 0000000000..ed8b238c16 --- /dev/null +++ b/packages/backend/server/src/plugins/gcloud/index.ts @@ -0,0 +1,10 @@ +import { Global } from '@nestjs/common'; + +import { OptionalModule } from '../../fundamentals'; +import { GCloudMetrics } from './metrics'; + +@Global() +@OptionalModule({ + imports: [GCloudMetrics], +}) +export class GCloudModule {} diff --git a/packages/backend/server/src/plugins/gcloud/metrics.ts b/packages/backend/server/src/plugins/gcloud/metrics.ts new file mode 100644 index 0000000000..93cef580aa --- /dev/null +++ b/packages/backend/server/src/plugins/gcloud/metrics.ts @@ -0,0 +1,46 @@ +import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter'; +import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter'; +import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util'; +import { Global, Provider } from '@nestjs/common'; +import { Resource } from '@opentelemetry/resources'; +import { + MetricReader, + PeriodicExportingMetricReader, +} from '@opentelemetry/sdk-metrics'; +import { SpanExporter } from '@opentelemetry/sdk-trace-node'; + +import { OptionalModule } from '../../fundamentals'; +import { OpentelemetryFactory } from '../../fundamentals/metrics'; + +export class GCloudOpentelemetryFactory extends OpentelemetryFactory { + override getResource(): Resource { + return super.getResource().merge(new GcpDetectorSync().detect()); + } + + override getMetricReader(): MetricReader { + return new PeriodicExportingMetricReader({ + exportIntervalMillis: 30000, + exportTimeoutMillis: 10000, + exporter: new MetricExporter({ + prefix: 'custom.googleapis.com', + }), + metricProducers: this.getMetricsProducers(), + }); + } + + override getSpanExporter(): SpanExporter { + return new TraceExporter(); + } +} + +const factorProvider: Provider = { + provide: OpentelemetryFactory, + useFactory: () => new GCloudOpentelemetryFactory(), +}; + +@Global() +@OptionalModule({ + if: config => config.metrics.enabled, + overrides: [factorProvider], +}) +export class GCloudMetrics {} diff --git a/packages/backend/server/src/plugins/index.ts b/packages/backend/server/src/plugins/index.ts index 291e2f06c2..1ed4039304 100644 --- a/packages/backend/server/src/plugins/index.ts +++ b/packages/backend/server/src/plugins/index.ts @@ -1,8 +1,10 @@ import type { AvailablePlugins } from '../fundamentals/config'; +import { GCloudModule } from './gcloud'; import { PaymentModule } from './payment'; import { RedisModule } from './redis'; export const pluginsMap = new Map([ ['payment', PaymentModule], ['redis', RedisModule], + ['gcloud', GCloudModule], ]); diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 46965f5a0b..34746757d3 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -321,7 +321,7 @@ export class UserSubscriptionResolver { // @FIXME(@forehalo): should not mock any api for selfhosted server // the frontend should avoid calling such api if feature is not enabled - if (this.config.flavor.selfhosted) { + if (this.config.isSelfhosted) { const start = new Date(); const end = new Date(); end.setFullYear(start.getFullYear() + 1); diff --git a/packages/backend/server/src/prelude.ts b/packages/backend/server/src/prelude.ts index f71602d779..095c768761 100644 --- a/packages/backend/server/src/prelude.ts +++ b/packages/backend/server/src/prelude.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { config } from 'dotenv'; +import { omit } from 'lodash-es'; import { applyEnvToConfig, @@ -49,8 +50,17 @@ async function load() { // 5. load `config/affine` to patch custom configs await loadRemote(AFFiNE_CONFIG_PATH, 'affine.js'); - if (process.env.NODE_ENV === 'development') { - console.log('AFFiNE Config:', JSON.stringify(globalThis.AFFiNE, null, 2)); + // 6. load `config/affine.self` to patch custom configs + // This is the file only take effect in [AFFiNE Cloud] + if (!AFFiNE.isSelfhosted) { + await loadRemote(AFFiNE_CONFIG_PATH, 'affine.self.js'); + } + + if (AFFiNE.node.dev) { + console.log( + 'AFFiNE Config:', + JSON.stringify(omit(globalThis.AFFiNE, 'ENV_MAP'), null, 2) + ); } } diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index beb0bbdce7..14a1d27036 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -240,10 +240,18 @@ type ServerConfigType { """server identical name could be shown as badge on user interface""" name: String! + """server type""" + type: ServerDeploymentType! + """server version""" version: String! } +enum ServerDeploymentType { + Affine + Selfhosted +} + enum ServerFeature { Payment } diff --git a/packages/backend/server/tests/config.spec.ts b/packages/backend/server/tests/config.spec.ts index 0081d8c8fa..ee5ab7dbb7 100644 --- a/packages/backend/server/tests/config.spec.ts +++ b/packages/backend/server/tests/config.spec.ts @@ -18,7 +18,7 @@ test.afterEach.always(async () => { test('should be able to get config', t => { t.true(typeof config.host === 'string'); - t.is(config.env, 'test'); + t.is(config.NODE_ENV, 'test'); }); test('should be able to override config', async t => { diff --git a/packages/frontend/core/src/hooks/affine/use-server-config.ts b/packages/frontend/core/src/hooks/affine/use-server-config.ts index 8238c56c2e..1c6f8afdc4 100644 --- a/packages/frontend/core/src/hooks/affine/use-server-config.ts +++ b/packages/frontend/core/src/hooks/affine/use-server-config.ts @@ -1,4 +1,4 @@ -import { serverConfigQuery } from '@affine/graphql'; +import { serverConfigQuery, ServerDeploymentType } from '@affine/graphql'; import type { BareFetcher, Middleware } from 'swr'; import { useQueryImmutable } from '../use-query'; @@ -25,20 +25,20 @@ const useServerConfig = () => { return config.serverConfig; }; -export const useServerFlavor = () => { +export const useServerType = () => { const config = useServerConfig(); if (!config) { return 'local'; } - return config.flavor; + return config.type; }; export const useSelfHosted = () => { - const serverFlavor = useServerFlavor(); + const serverType = useServerType(); - return ['local', 'selfhosted'].includes(serverFlavor); + return ['local', ServerDeploymentType.Selfhosted].includes(serverType); }; export const useServerBaseUrl = () => { diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 867b10d24e..272ce02ab4 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -677,7 +677,7 @@ query serverConfig { baseUrl name features - flavor + type } }`, }; diff --git a/packages/frontend/graphql/src/graphql/server-config.gql b/packages/frontend/graphql/src/graphql/server-config.gql index 2d42c3812b..b92710164f 100644 --- a/packages/frontend/graphql/src/graphql/server-config.gql +++ b/packages/frontend/graphql/src/graphql/server-config.gql @@ -4,6 +4,6 @@ query serverConfig { baseUrl name features - flavor + type } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 34cdb794a4..b92793f97f 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -71,6 +71,11 @@ export enum PublicPageMode { Page = 'Page', } +export enum ServerDeploymentType { + Affine = 'Affine', + Selfhosted = 'Selfhosted', +} + export enum ServerFeature { Payment = 'Payment', } @@ -673,7 +678,7 @@ export type ServerConfigQuery = { baseUrl: string; name: string; features: Array; - flavor: string; + type: ServerDeploymentType; }; }; diff --git a/tests/affine-cloud/playwright.config.ts b/tests/affine-cloud/playwright.config.ts index 743b44e01d..fcd64a0b00 100644 --- a/tests/affine-cloud/playwright.config.ts +++ b/tests/affine-cloud/playwright.config.ts @@ -55,8 +55,6 @@ const config: PlaywrightTestConfig = { OAUTH_EMAIL_SENDER: 'noreply@toeverything.info', OAUTH_EMAIL_LOGIN: 'noreply@toeverything.info', OAUTH_EMAIL_PASSWORD: 'affine', - STRIPE_API_KEY: '1', - STRIPE_WEBHOOK_KEY: '1', }, }, ],