Compare commits
20 Commits
5b28ad52bb
...
7a216628d5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a216628d5 | |||
| 7f73607c32 | |||
| f81f9419e0 | |||
| a0be5d6b36 | |||
| 9d2a330f69 | |||
| 271e712804 | |||
| 1c386e0a80 | |||
| 34442663a3 | |||
| 5a2462a49e | |||
| 86ec39f7d2 | |||
| 71ac0f91c6 | |||
| 0f7b99c687 | |||
| b7d66f334a | |||
| b7c9e34738 | |||
| 07d8b20f57 | |||
| 1b568757cb | |||
| 7cf0c50921 | |||
| bccd942898 | |||
| 33d101af22 | |||
| 01090273c6 |
23
.env.prod.example
Normal file
23
.env.prod.example
Normal file
@@ -0,0 +1,23 @@
|
||||
# ---------- MySQL Center ----------
|
||||
MYSQL_CENTER_ROOT_PASSWORD=change-me-center-root
|
||||
MYSQL_CENTER_DATABASE=fabric_center
|
||||
MYSQL_CENTER_USER=fabric
|
||||
MYSQL_CENTER_PASSWORD=change-me-center-db
|
||||
|
||||
# ---------- MySQL Guild ----------
|
||||
MYSQL_GUILD_ROOT_PASSWORD=change-me-guild-root
|
||||
MYSQL_GUILD_DATABASE=fabric_guild
|
||||
MYSQL_GUILD_USER=fabric
|
||||
MYSQL_GUILD_PASSWORD=change-me-guild-db
|
||||
|
||||
# ---------- Center secrets ----------
|
||||
CENTER_SHARED_SECRET=change-me-center-shared-secret
|
||||
JWT_ACCESS_SECRET=change-me-jwt-access-secret
|
||||
JWT_REFRESH_SECRET=change-me-jwt-refresh-secret
|
||||
|
||||
# ---------- Guild auth ----------
|
||||
FABRIC_API_KEY=change-me-fabric-api-key
|
||||
|
||||
# ---------- Optional webhook ----------
|
||||
FABRIC_WEBHOOK_URL=
|
||||
FABRIC_WEBHOOK_SECRET=
|
||||
89
Fabric.Backend.Center/package-lock.json
generated
89
Fabric.Backend.Center/package-lock.json
generated
@@ -27,6 +27,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
@@ -1036,6 +1037,17 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -1047,6 +1059,16 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -1075,6 +1097,38 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1116,6 +1170,41 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"test:unit": "vitest run src/**/*.spec.ts --exclude src/*.integration.spec.ts --exclude dist/**",
|
||||
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**"
|
||||
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**",
|
||||
"migration:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate src/migrations/AutoMigration -d src/data-source.ts",
|
||||
"migration:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
|
||||
"migration:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.8",
|
||||
@@ -33,6 +36,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { buildTypeOrmConfig } from './database.config';
|
||||
import { HealthController } from './common/health.controller';
|
||||
import { MetricsController } from './common/metrics.controller';
|
||||
import { MetricsService } from './common/metrics.service';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
import { AuditModule } from './audit/audit.module';
|
||||
@@ -13,6 +15,7 @@ import { AuditModule } from './audit/audit.module';
|
||||
AuthModule,
|
||||
NodesModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController, MetricsController],
|
||||
providers: [MetricsService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
12
Fabric.Backend.Center/src/common/metrics.controller.ts
Normal file
12
Fabric.Backend.Center/src/common/metrics.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
constructor(private readonly metrics: MetricsService) {}
|
||||
|
||||
@Get()
|
||||
get() {
|
||||
return this.metrics.snapshot();
|
||||
}
|
||||
}
|
||||
35
Fabric.Backend.Center/src/common/metrics.service.ts
Normal file
35
Fabric.Backend.Center/src/common/metrics.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
type Bucket = {
|
||||
requests: number;
|
||||
errors: number;
|
||||
totalDurationMs: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
private readonly bucket: Bucket = { requests: 0, errors: 0, totalDurationMs: 0 };
|
||||
private startedAt = Date.now();
|
||||
|
||||
record(statusCode: number, durationMs: number): void {
|
||||
this.bucket.requests += 1;
|
||||
if (statusCode >= 400) this.bucket.errors += 1;
|
||||
this.bucket.totalDurationMs += durationMs;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const uptimeSec = Math.max(1, Math.floor((Date.now() - this.startedAt) / 1000));
|
||||
const qps = this.bucket.requests / uptimeSec;
|
||||
const avgLatencyMs = this.bucket.requests > 0 ? this.bucket.totalDurationMs / this.bucket.requests : 0;
|
||||
const errorRate = this.bucket.requests > 0 ? this.bucket.errors / this.bucket.requests : 0;
|
||||
|
||||
return {
|
||||
requests: this.bucket.requests,
|
||||
errors: this.bucket.errors,
|
||||
qps,
|
||||
avgLatencyMs,
|
||||
errorRate,
|
||||
uptimeSec,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
type ReqWithId = Request & { requestId?: string };
|
||||
|
||||
export function createRequestContextMiddleware(service: 'center' | 'guild', metrics: MetricsService) {
|
||||
return (req: ReqWithId, res: Response, next: NextFunction): void => {
|
||||
const headerId = req.headers['x-request-id'];
|
||||
const requestId =
|
||||
(Array.isArray(headerId) ? headerId[0] : headerId) || randomUUID();
|
||||
|
||||
req.requestId = requestId;
|
||||
res.setHeader('x-request-id', requestId);
|
||||
|
||||
const startedAt = Date.now();
|
||||
res.on('finish', () => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
metrics.record(res.statusCode, durationMs);
|
||||
|
||||
const log = {
|
||||
level: 'info',
|
||||
service,
|
||||
requestId,
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
console.log(JSON.stringify(log));
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
16
Fabric.Backend.Center/src/data-source.ts
Normal file
16
Fabric.Backend.Center/src/data-source.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { buildTypeOrmConfig } from './database.config';
|
||||
|
||||
const cfg = buildTypeOrmConfig();
|
||||
|
||||
const options: DataSourceOptions = {
|
||||
...(cfg as Record<string, unknown>),
|
||||
type: 'mysql',
|
||||
migrations: ['src/migrations/*.{ts,js}'],
|
||||
synchronize: false,
|
||||
} as DataSourceOptions;
|
||||
|
||||
export const AppDataSource = new DataSource(options);
|
||||
|
||||
export default AppDataSource;
|
||||
@@ -2,13 +2,14 @@ import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { createHmac, randomUUID } from 'crypto';
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1';
|
||||
process.env.DB_PORT = '3307';
|
||||
process.env.DB_USER = 'fabric';
|
||||
process.env.DB_PASSWORD = 'fabric';
|
||||
process.env.DB_NAME = 'fabric_center';
|
||||
process.env.DB_SYNC = 'false';
|
||||
process.env.DB_SYNC = 'true';
|
||||
process.env.CENTER_SHARED_SECRET = 'test-center-secret';
|
||||
process.env.JWT_ACCESS_SECRET = 'test-access-secret';
|
||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||
@@ -37,4 +38,32 @@ describe('center integration (mysql + api)', () => {
|
||||
expect(res.body.ok).toBe(true);
|
||||
expect(res.body.database).toBe('ready');
|
||||
});
|
||||
|
||||
it('POST /api/nodes/register follows center-guild contract (version + hmac)', async () => {
|
||||
const body = {
|
||||
nodeId: `guild-node-${Date.now()}`,
|
||||
name: 'Guild Node Contract Test',
|
||||
endpoint: `http://guild-${Date.now()}:7002`,
|
||||
};
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const nonce = randomUUID();
|
||||
const canonical = ['POST', '/api/nodes/register', timestamp, nonce, JSON.stringify(body)].join('\n');
|
||||
const signature = createHmac('sha256', process.env.CENTER_SHARED_SECRET as string)
|
||||
.update(canonical)
|
||||
.digest('hex');
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/nodes/register')
|
||||
.set('x-fabric-version', '1')
|
||||
.set('x-fabric-timestamp', timestamp)
|
||||
.set('x-fabric-nonce', nonce)
|
||||
.set('x-fabric-signature', signature)
|
||||
.send(body);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.status).toBe('accepted');
|
||||
expect(res.body.negotiatedVersion).toBe('1');
|
||||
expect(res.body.node.nodeId).toBe(body.nodeId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { createRequestContextMiddleware } from './common/request-context.middleware';
|
||||
import { MetricsService } from './common/metrics.service';
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
@@ -28,6 +30,8 @@ async function bootstrap() {
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
const metrics = app.get(MetricsService);
|
||||
app.use(createRequestContextMiddleware('center', metrics));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
||||
0
Fabric.Backend.Center/src/migrations/.gitkeep
Normal file
0
Fabric.Backend.Center/src/migrations/.gitkeep
Normal file
401
Fabric.Backend.Guild/package-lock.json
generated
401
Fabric.Backend.Guild/package-lock.json
generated
@@ -11,19 +11,23 @@
|
||||
"@nestjs/common": "^10.4.8",
|
||||
"@nestjs/core": "^10.4.8",
|
||||
"@nestjs/platform-express": "^10.4.8",
|
||||
"@nestjs/platform-socket.io": "^10.4.22",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^10.4.22",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"mysql2": "^3.22.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
@@ -507,6 +511,66 @@
|
||||
"@nestjs/core": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-10.4.22.tgz",
|
||||
"integrity": "sha512-xxGw3R0Ihr51/Omq23z3//bKmCXyVKaikxbH0/pkwqMsQrxkUv9NabNUZ22b4Jnlwwi02X+zlwo8GRa9u8oV9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/nest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.0.0",
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io/node_modules/debug": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@nestjs/platform-socket.io/node_modules/socket.io": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz",
|
||||
"integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/swagger": {
|
||||
"version": "7.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz",
|
||||
@@ -601,6 +665,29 @@
|
||||
"typeorm": "^0.3.0 || ^1.0.0-dev"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/websockets": {
|
||||
"version": "10.4.22",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-10.4.22.tgz",
|
||||
"integrity": "sha512-OLd4i0Faq7vgdtB5vVUrJ54hWEtcXy9poJ6n7kbbh/5ms+KffUl+wwGsbe7uSXLrkoyI8xXU6fZPkFArI+XiRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iterare": "1.2.1",
|
||||
"object-hash": "3.0.0",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-socket.io": "^10.0.0",
|
||||
"reflect-metadata": "^0.1.12 || ^0.2.0",
|
||||
"rxjs": "^7.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nestjs/platform-socket.io": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
@@ -926,6 +1013,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sqltools/formatter": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
|
||||
@@ -1025,6 +1118,17 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -1036,6 +1140,16 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
@@ -1043,6 +1157,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -1064,6 +1187,38 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -1087,6 +1242,41 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
@@ -1117,6 +1307,15 @@
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.3",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz",
|
||||
@@ -1812,6 +2011,15 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
@@ -2378,6 +2586,59 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
|
||||
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
@@ -4045,6 +4306,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-hash": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
@@ -4660,6 +4930,116 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/socket.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -5753,6 +6133,27 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
@@ -11,25 +11,32 @@
|
||||
"lint:fix": "eslint 'src/**/*.ts' --fix",
|
||||
"format": "prettier --write 'src/**/*.ts'",
|
||||
"test:unit": "vitest run src/**/*.spec.ts --exclude src/*.integration.spec.ts --exclude dist/**",
|
||||
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**"
|
||||
"test:integration": "vitest run src/*.integration.spec.ts --exclude dist/**",
|
||||
"migration:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate src/migrations/AutoMigration -d src/data-source.ts",
|
||||
"migration:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d src/data-source.ts",
|
||||
"migration:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d src/data-source.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.8",
|
||||
"@nestjs/core": "^10.4.8",
|
||||
"@nestjs/platform-express": "^10.4.8",
|
||||
"@nestjs/platform-socket.io": "^10.4.22",
|
||||
"@nestjs/swagger": "^7.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@nestjs/websockets": "^10.4.22",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"mysql2": "^3.22.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.3",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"typeorm": "^0.3.29"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@nestjs/testing": "^10.4.22",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
|
||||
@@ -3,22 +3,27 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { buildTypeOrmConfig } from './database.config';
|
||||
import { HealthController } from './common/health.controller';
|
||||
import { MetricsController } from './common/metrics.controller';
|
||||
import { MetricsService } from './common/metrics.service';
|
||||
import { ApiKeyGuard } from './common/api-key.guard';
|
||||
import { GuildsModule } from './guilds/guilds.module';
|
||||
import { ChannelsModule } from './channels/channels.module';
|
||||
import { MessagingModule } from './messaging/messaging.module';
|
||||
import { EventsModule } from './events/events.module';
|
||||
import { RealtimeModule } from './realtime/realtime.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRoot(buildTypeOrmConfig()),
|
||||
EventsModule,
|
||||
RealtimeModule,
|
||||
GuildsModule,
|
||||
ChannelsModule,
|
||||
MessagingModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController, MetricsController],
|
||||
providers: [
|
||||
MetricsService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ApiKeyGuard,
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
@Controller('channels')
|
||||
export class ChannelsController {
|
||||
constructor(private readonly channelsService: ChannelsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('guildId') guildId?: string) {
|
||||
if (!guildId) return [];
|
||||
return this.channelsService.listByGuild(guildId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: Record<string, unknown>) {
|
||||
return { status: 'todo', action: 'create-channel', received: body };
|
||||
return this.channelsService.create(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ChannelsController } from './channels.controller';
|
||||
import { Channel } from '../entities/channel.entity';
|
||||
import { ChannelsService } from './channels.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Channel])],
|
||||
controllers: [ChannelsController],
|
||||
providers: [ChannelsService],
|
||||
exports: [ChannelsService],
|
||||
})
|
||||
export class ChannelsModule {}
|
||||
|
||||
31
Fabric.Backend.Guild/src/channels/channels.service.ts
Normal file
31
Fabric.Backend.Guild/src/channels/channels.service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Channel } from '../entities/channel.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(
|
||||
@InjectRepository(Channel)
|
||||
private readonly channelRepo: Repository<Channel>,
|
||||
) {}
|
||||
|
||||
listByGuild(guildId: string) {
|
||||
return this.channelRepo.find({
|
||||
where: { guildId },
|
||||
order: { createdAt: 'ASC' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
create(input: Partial<Channel>) {
|
||||
const channel = this.channelRepo.create({
|
||||
guildId: String(input.guildId ?? ''),
|
||||
name: String(input.name ?? ''),
|
||||
kind: input.kind === 'announcement' ? 'announcement' : 'text',
|
||||
isPrivate: Boolean(input.isPrivate),
|
||||
lastSeq: 0,
|
||||
});
|
||||
return this.channelRepo.save(channel);
|
||||
}
|
||||
}
|
||||
12
Fabric.Backend.Guild/src/common/metrics.controller.ts
Normal file
12
Fabric.Backend.Guild/src/common/metrics.controller.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
@Controller('metrics')
|
||||
export class MetricsController {
|
||||
constructor(private readonly metrics: MetricsService) {}
|
||||
|
||||
@Get()
|
||||
get() {
|
||||
return this.metrics.snapshot();
|
||||
}
|
||||
}
|
||||
35
Fabric.Backend.Guild/src/common/metrics.service.ts
Normal file
35
Fabric.Backend.Guild/src/common/metrics.service.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
type Bucket = {
|
||||
requests: number;
|
||||
errors: number;
|
||||
totalDurationMs: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
private readonly bucket: Bucket = { requests: 0, errors: 0, totalDurationMs: 0 };
|
||||
private startedAt = Date.now();
|
||||
|
||||
record(statusCode: number, durationMs: number): void {
|
||||
this.bucket.requests += 1;
|
||||
if (statusCode >= 400) this.bucket.errors += 1;
|
||||
this.bucket.totalDurationMs += durationMs;
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
const uptimeSec = Math.max(1, Math.floor((Date.now() - this.startedAt) / 1000));
|
||||
const qps = this.bucket.requests / uptimeSec;
|
||||
const avgLatencyMs = this.bucket.requests > 0 ? this.bucket.totalDurationMs / this.bucket.requests : 0;
|
||||
const errorRate = this.bucket.requests > 0 ? this.bucket.errors / this.bucket.requests : 0;
|
||||
|
||||
return {
|
||||
requests: this.bucket.requests,
|
||||
errors: this.bucket.errors,
|
||||
qps,
|
||||
avgLatencyMs,
|
||||
errorRate,
|
||||
uptimeSec,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { MetricsService } from './metrics.service';
|
||||
|
||||
type ReqWithId = Request & { requestId?: string };
|
||||
|
||||
export function createRequestContextMiddleware(service: 'center' | 'guild', metrics: MetricsService) {
|
||||
return (req: ReqWithId, res: Response, next: NextFunction): void => {
|
||||
const headerId = req.headers['x-request-id'];
|
||||
const requestId =
|
||||
(Array.isArray(headerId) ? headerId[0] : headerId) || randomUUID();
|
||||
|
||||
req.requestId = requestId;
|
||||
res.setHeader('x-request-id', requestId);
|
||||
|
||||
const startedAt = Date.now();
|
||||
res.on('finish', () => {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
metrics.record(res.statusCode, durationMs);
|
||||
|
||||
const log = {
|
||||
level: 'info',
|
||||
service,
|
||||
requestId,
|
||||
method: req.method,
|
||||
path: req.originalUrl,
|
||||
statusCode: res.statusCode,
|
||||
durationMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
console.log(JSON.stringify(log));
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
16
Fabric.Backend.Guild/src/data-source.ts
Normal file
16
Fabric.Backend.Guild/src/data-source.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'reflect-metadata';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import { buildTypeOrmConfig } from './database.config';
|
||||
|
||||
const cfg = buildTypeOrmConfig();
|
||||
|
||||
const options: DataSourceOptions = {
|
||||
...(cfg as Record<string, unknown>),
|
||||
type: 'mysql',
|
||||
migrations: ['src/migrations/*.{ts,js}'],
|
||||
synchronize: false,
|
||||
} as DataSourceOptions;
|
||||
|
||||
export const AppDataSource = new DataSource(options);
|
||||
|
||||
export default AppDataSource;
|
||||
@@ -9,7 +9,6 @@ export class Message {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 80, unique: true })
|
||||
messageId!: string;
|
||||
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { GuildsService } from './guilds.service';
|
||||
|
||||
@Controller('guilds')
|
||||
export class GuildsController {
|
||||
constructor(private readonly guildsService: GuildsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.guildsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() body: Record<string, unknown>) {
|
||||
return { status: 'todo', action: 'create-guild', received: body };
|
||||
return this.guildsService.create(body);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { GuildsController } from './guilds.controller';
|
||||
import { Guild } from '../entities/guild.entity';
|
||||
import { GuildsService } from './guilds.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Guild])],
|
||||
controllers: [GuildsController],
|
||||
providers: [GuildsService],
|
||||
exports: [GuildsService],
|
||||
})
|
||||
export class GuildsModule {}
|
||||
|
||||
27
Fabric.Backend.Guild/src/guilds/guilds.service.ts
Normal file
27
Fabric.Backend.Guild/src/guilds/guilds.service.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Guild } from '../entities/guild.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GuildsService {
|
||||
constructor(
|
||||
@InjectRepository(Guild)
|
||||
private readonly guildRepo: Repository<Guild>,
|
||||
) {}
|
||||
|
||||
list() {
|
||||
return this.guildRepo.find({
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
create(input: Partial<Guild>) {
|
||||
const slug = String(input.slug ?? '').trim();
|
||||
const name = String(input.name ?? '').trim();
|
||||
const ownerUserId = input.ownerUserId ? String(input.ownerUserId) : null;
|
||||
const guild = this.guildRepo.create({ slug, name, ownerUserId });
|
||||
return this.guildRepo.save(guild);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Channel } from './entities/channel.entity';
|
||||
|
||||
process.env.DB_HOST = '127.0.0.1';
|
||||
process.env.DB_PORT = '3308';
|
||||
@@ -13,6 +15,7 @@ process.env.FABRIC_API_KEY = 'test-api-key';
|
||||
|
||||
describe('guild integration (mysql + api)', () => {
|
||||
let app: INestApplication;
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
const { AppModule } = await import('./app.module');
|
||||
@@ -23,6 +26,9 @@ describe('guild integration (mysql + api)', () => {
|
||||
app = moduleRef.createNestApplication();
|
||||
app.setGlobalPrefix('api');
|
||||
await app.init();
|
||||
dataSource = app.get(DataSource);
|
||||
await dataSource.dropDatabase();
|
||||
await dataSource.synchronize();
|
||||
}, 30000);
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -40,4 +46,32 @@ describe('guild integration (mysql + api)', () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/channels/non-exist/messages');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('supports concurrent message writes with continuous seq', async () => {
|
||||
const channelRepo = dataSource.getRepository(Channel);
|
||||
const channel = await channelRepo.save(
|
||||
channelRepo.create({
|
||||
guildId: 'test-guild',
|
||||
name: `concurrency-${Date.now()}`,
|
||||
kind: 'text',
|
||||
isPrivate: false,
|
||||
lastSeq: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const concurrent = 10;
|
||||
const tasks = Array.from({ length: concurrent }, (_, i) =>
|
||||
request(app.getHttpServer())
|
||||
.post(`/api/channels/${channel.id}/messages`)
|
||||
.set('x-api-key', 'test-api-key')
|
||||
.send({ content: `hello-${i + 1}`, authorUserId: 'u1' }),
|
||||
);
|
||||
|
||||
const responses = await Promise.all(tasks);
|
||||
const seqs = responses.map((r) => r.body.seq).sort((a, b) => a - b);
|
||||
|
||||
expect(responses.every((r) => r.status === 201)).toBe(true);
|
||||
expect(new Set(seqs).size).toBe(concurrent);
|
||||
expect(seqs).toEqual(Array.from({ length: concurrent }, (_, i) => i + 1));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,14 @@ import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { createRequestContextMiddleware } from './common/request-context.middleware';
|
||||
import { MetricsService } from './common/metrics.service';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
const metrics = app.get(MetricsService);
|
||||
app.use(createRequestContextMiddleware('guild', metrics));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Message } from '../entities/message.entity';
|
||||
import { IdempotencyRecord } from '../entities/idempotency-record.entity';
|
||||
import { EventsService } from '../events/events.service';
|
||||
import { clampLimit, computeNextExpectedSeq } from './pagination.util';
|
||||
import { RealtimeGateway } from '../realtime/realtime.gateway';
|
||||
|
||||
const EDIT_WINDOW_MS = 15 * 60 * 1000;
|
||||
const DEFAULT_PAGE_LIMIT = 50;
|
||||
@@ -34,6 +35,7 @@ export class MessagingController {
|
||||
@InjectRepository(IdempotencyRecord)
|
||||
private readonly idemRepo: Repository<IdempotencyRecord>,
|
||||
private readonly events: EventsService,
|
||||
private readonly realtime: RealtimeGateway,
|
||||
) {}
|
||||
|
||||
private async getIdempotentResponse(
|
||||
@@ -125,6 +127,7 @@ export class MessagingController {
|
||||
actorId: body.authorUserId ?? 'anonymous',
|
||||
data: responseBody,
|
||||
});
|
||||
this.realtime.emitChannelEvent(channelId, 'message.created', responseBody);
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
@@ -161,6 +164,7 @@ export class MessagingController {
|
||||
actorId: saved.authorUserId,
|
||||
data: responseBody,
|
||||
});
|
||||
this.realtime.emitChannelEvent(channelId, 'message.updated', responseBody);
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
@@ -202,6 +206,11 @@ export class MessagingController {
|
||||
deletedAt: item.deletedAt?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
this.realtime.emitChannelEvent(channelId, 'message.deleted', {
|
||||
messageId,
|
||||
seq: item.seq,
|
||||
deletedAt: item.deletedAt?.toISOString() ?? null,
|
||||
});
|
||||
|
||||
return responseBody;
|
||||
}
|
||||
|
||||
0
Fabric.Backend.Guild/src/migrations/.gitkeep
Normal file
0
Fabric.Backend.Guild/src/migrations/.gitkeep
Normal file
129
Fabric.Backend.Guild/src/realtime/realtime.gateway.ts
Normal file
129
Fabric.Backend.Guild/src/realtime/realtime.gateway.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: '/realtime',
|
||||
cors: {
|
||||
origin: '*',
|
||||
},
|
||||
})
|
||||
export class RealtimeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(RealtimeGateway.name);
|
||||
private readonly onlineUsers = new Set<string>();
|
||||
|
||||
private userIdFromClient(client: Socket): string {
|
||||
const authUser = client.handshake.auth?.userId;
|
||||
const headerUser = client.handshake.headers['x-user-id'];
|
||||
const userId = typeof authUser === 'string' ? authUser : Array.isArray(headerUser) ? headerUser[0] : headerUser;
|
||||
return userId && typeof userId === 'string' && userId.trim() !== '' ? userId : `anon:${client.id}`;
|
||||
}
|
||||
|
||||
handleConnection(client: Socket): void {
|
||||
const expected = process.env.FABRIC_API_KEY;
|
||||
if (!expected) {
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const authKey = client.handshake.auth?.apiKey;
|
||||
const headerKey = client.handshake.headers['x-api-key'];
|
||||
const apiKey = typeof authKey === 'string' ? authKey : Array.isArray(headerKey) ? headerKey[0] : headerKey;
|
||||
|
||||
if (apiKey !== expected) {
|
||||
this.logger.warn(`socket rejected: ${client.id}`);
|
||||
client.disconnect(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`socket connected: ${client.id}`);
|
||||
|
||||
const userId = this.userIdFromClient(client);
|
||||
client.data.userId = userId;
|
||||
this.onlineUsers.add(userId);
|
||||
this.server.emit('presence.online', {
|
||||
userId,
|
||||
onlineCount: this.onlineUsers.size,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket): void {
|
||||
this.logger.log(`socket disconnected: ${client.id}`);
|
||||
|
||||
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
||||
this.onlineUsers.delete(userId);
|
||||
this.server.emit('presence.offline', {
|
||||
userId,
|
||||
onlineCount: this.onlineUsers.size,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('join_channel')
|
||||
joinChannel(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { channelId?: string },
|
||||
): { ok: boolean } {
|
||||
if (!body?.channelId) return { ok: false };
|
||||
client.join(`channel:${body.channelId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@SubscribeMessage('leave_channel')
|
||||
leaveChannel(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { channelId?: string },
|
||||
): { ok: boolean } {
|
||||
if (!body?.channelId) return { ok: false };
|
||||
client.leave(`channel:${body.channelId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing.start')
|
||||
typingStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { channelId?: string },
|
||||
): { ok: boolean } {
|
||||
if (!body?.channelId) return { ok: false };
|
||||
|
||||
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
||||
this.server.to(`channel:${body.channelId}`).emit('typing.start', {
|
||||
channelId: body.channelId,
|
||||
userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@SubscribeMessage('typing.stop')
|
||||
typingStop(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() body: { channelId?: string },
|
||||
): { ok: boolean } {
|
||||
if (!body?.channelId) return { ok: false };
|
||||
|
||||
const userId = typeof client.data.userId === 'string' ? client.data.userId : `anon:${client.id}`;
|
||||
this.server.to(`channel:${body.channelId}`).emit('typing.stop', {
|
||||
channelId: body.channelId,
|
||||
userId,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
emitChannelEvent(channelId: string, event: string, data: Record<string, unknown>): void {
|
||||
this.server.to(`channel:${channelId}`).emit(event, data);
|
||||
}
|
||||
}
|
||||
9
Fabric.Backend.Guild/src/realtime/realtime.module.ts
Normal file
9
Fabric.Backend.Guild/src/realtime/realtime.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RealtimeGateway } from './realtime.gateway';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RealtimeGateway],
|
||||
exports: [RealtimeGateway],
|
||||
})
|
||||
export class RealtimeModule {}
|
||||
Submodule Fabric.Desktop updated: 395385c9aa...292d8c27f2
Submodule Fabric.Frontend updated: 642b81564d...763f06ab8c
90
docker-compose.prod.yml
Normal file
90
docker-compose.prod.yml
Normal file
@@ -0,0 +1,90 @@
|
||||
services:
|
||||
mysql-center:
|
||||
image: mysql:8.4
|
||||
container_name: fabric-mysql-center
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_CENTER_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_CENTER_DATABASE:-fabric_center}
|
||||
MYSQL_USER: ${MYSQL_CENTER_USER:-fabric}
|
||||
MYSQL_PASSWORD: ${MYSQL_CENTER_PASSWORD}
|
||||
ports:
|
||||
- "3307:3306"
|
||||
volumes:
|
||||
- mysql_center_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_CENTER_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
mysql-guild:
|
||||
image: mysql:8.4
|
||||
container_name: fabric-mysql-guild
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_GUILD_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_GUILD_DATABASE:-fabric_guild}
|
||||
MYSQL_USER: ${MYSQL_GUILD_USER:-fabric}
|
||||
MYSQL_PASSWORD: ${MYSQL_GUILD_PASSWORD}
|
||||
ports:
|
||||
- "3308:3306"
|
||||
volumes:
|
||||
- mysql_guild_data:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-p${MYSQL_GUILD_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
|
||||
backend-center:
|
||||
build:
|
||||
context: ./Fabric.Backend.Center
|
||||
dockerfile: Dockerfile
|
||||
container_name: fabric-backend-center
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mysql-center:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: 7001
|
||||
DB_HOST: mysql-center
|
||||
DB_PORT: 3306
|
||||
DB_USER: ${MYSQL_CENTER_USER:-fabric}
|
||||
DB_PASSWORD: ${MYSQL_CENTER_PASSWORD}
|
||||
DB_NAME: ${MYSQL_CENTER_DATABASE:-fabric_center}
|
||||
DB_SYNC: "false"
|
||||
DB_LOGGING: "false"
|
||||
CENTER_SHARED_SECRET: ${CENTER_SHARED_SECRET}
|
||||
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET}
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET}
|
||||
ports:
|
||||
- "7001:7001"
|
||||
|
||||
backend-guild:
|
||||
build:
|
||||
context: ./Fabric.Backend.Guild
|
||||
dockerfile: Dockerfile
|
||||
container_name: fabric-backend-guild
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mysql-guild:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PORT: 7002
|
||||
DB_HOST: mysql-guild
|
||||
DB_PORT: 3306
|
||||
DB_USER: ${MYSQL_GUILD_USER:-fabric}
|
||||
DB_PASSWORD: ${MYSQL_GUILD_PASSWORD}
|
||||
DB_NAME: ${MYSQL_GUILD_DATABASE:-fabric_guild}
|
||||
DB_SYNC: "false"
|
||||
DB_LOGGING: "false"
|
||||
FABRIC_API_KEY: ${FABRIC_API_KEY}
|
||||
FABRIC_WEBHOOK_URL: ${FABRIC_WEBHOOK_URL:-}
|
||||
FABRIC_WEBHOOK_SECRET: ${FABRIC_WEBHOOK_SECRET:-}
|
||||
ports:
|
||||
- "7002:7002"
|
||||
|
||||
volumes:
|
||||
mysql_center_data:
|
||||
mysql_guild_data:
|
||||
@@ -56,9 +56,9 @@
|
||||
- [x] 幂等键支持(写接口)
|
||||
|
||||
### 2.4 实时通信(MVP 后半)
|
||||
- [ ] WebSocket 网关接入
|
||||
- [ ] message.created/updated/deleted 事件广播
|
||||
- [ ] 在线状态 + typing 事件
|
||||
- [x] WebSocket 网关接入
|
||||
- [x] message.created/updated/deleted 事件广播
|
||||
- [x] 在线状态 + typing 事件
|
||||
|
||||
---
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
### 5.1 自动化测试
|
||||
- [x] 单元测试(auth/service/message/seq)
|
||||
- [x] 集成测试(MySQL + API)
|
||||
- [ ] 合约测试(Center-Guild 协议)
|
||||
- [x] 合约测试(Center-Guild 协议)
|
||||
|
||||
### 5.2 质量门禁
|
||||
- [x] lint/typecheck/build 全绿
|
||||
@@ -93,11 +93,11 @@
|
||||
---
|
||||
|
||||
## 6. 部署与运维
|
||||
- [ ] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`)
|
||||
- [ ] DB migration 机制(TypeORM migration)
|
||||
- [ ] 结构化日志 + request id
|
||||
- [ ] 基础监控指标(QPS、延迟、错误率)
|
||||
- [ ] 备份与恢复流程文档
|
||||
- [x] `docker-compose.prod.yml`(去掉 `DB_SYNC=true`)
|
||||
- [x] DB migration 机制(TypeORM migration)
|
||||
- [x] 结构化日志 + request id
|
||||
- [x] 基础监控指标(QPS、延迟、错误率)
|
||||
- [x] 备份与恢复流程文档
|
||||
|
||||
---
|
||||
|
||||
@@ -112,9 +112,9 @@
|
||||
---
|
||||
|
||||
## 8. Definition of Done(MVP)
|
||||
- [ ] 用户可注册登录
|
||||
- [x] 用户可注册登录
|
||||
- [ ] Guild/Channel/DM 可创建并发消息
|
||||
- [ ] 消息 seq 连续可回补
|
||||
- [ ] WebSocket 可实时收发
|
||||
- [ ] 插件可通过 Bot Token 写入消息并接收 webhook
|
||||
- [ ] docker-compose 一键部署可用
|
||||
- [x] 消息 seq 连续可回补
|
||||
- [x] WebSocket 可实时收发
|
||||
- [x] 插件可通过统一 API Key 写入消息并接收 webhook
|
||||
- [x] docker-compose 一键部署可用
|
||||
|
||||
87
docs/TODO-frontend-desktop.md
Normal file
87
docs/TODO-frontend-desktop.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# TODO - Frontend / Desktop 开发计划
|
||||
|
||||
## 0. 基础约束
|
||||
- [x] 技术栈:Frontend = React + Vite + TS,Desktop = Electron
|
||||
- [x] Frontend/Desktop 子模块初始化
|
||||
- [ ] 所有前端接口统一走 Guild/Center API(API Key 模型)
|
||||
|
||||
---
|
||||
|
||||
## 1. Frontend(Web)
|
||||
|
||||
### 1.1 应用骨架
|
||||
- [x] 路由骨架(登录页 / 工作台 / 聊天页)
|
||||
- [x] 全局布局(侧栏 + 主区 + 状态栏)
|
||||
- [x] API Client 封装(baseURL、API Key、错误处理)
|
||||
- [x] Socket 客户端封装(连接、重连、订阅/退订)
|
||||
|
||||
### 1.2 认证与会话
|
||||
- [x] 登录页(Center 登录)
|
||||
- [x] Access/Refresh token 管理
|
||||
- [x] 过期自动刷新与登出
|
||||
- [x] 基础路由守卫(未登录跳转)
|
||||
|
||||
### 1.3 Guild/Channel 浏览
|
||||
- [x] Guild 列表加载
|
||||
- [x] Channel 列表加载
|
||||
- [x] 频道切换与 URL 状态同步
|
||||
|
||||
### 1.4 消息主链路
|
||||
- [x] 消息拉取(分页/区间)
|
||||
- [x] 发送消息
|
||||
- [x] 编辑消息
|
||||
- [x] 删除消息
|
||||
- [x] 回补提示(next_expected_seq)
|
||||
|
||||
### 1.5 实时能力
|
||||
- [x] 实时消息(created/updated/deleted)
|
||||
- [x] typing/在线状态显示
|
||||
- [x] 断线重连与重拉策略
|
||||
|
||||
### 1.6 体验与稳定性
|
||||
- [ ] 基础 loading/empty/error 态
|
||||
- [ ] 关键页面可观测日志(requestId)
|
||||
- [ ] 前端构建与 lint 门禁
|
||||
|
||||
---
|
||||
|
||||
## 2. Desktop(Electron)
|
||||
|
||||
### 2.1 桌面壳
|
||||
- [ ] BrowserWindow 配置(尺寸、最小尺寸、标题)
|
||||
- [ ] Dev/Prod 加载策略(devServer / 本地包)
|
||||
- [ ] 应用菜单与快捷键基础
|
||||
|
||||
### 2.2 安全基线
|
||||
- [ ] contextIsolation 保持开启
|
||||
- [ ] preload + IPC 白名单(最小暴露)
|
||||
- [ ] 禁止任意导航/新窗口策略
|
||||
|
||||
### 2.3 桌面能力
|
||||
- [ ] 本地配置存储(API Base/API Key)
|
||||
- [ ] 系统通知(新消息)
|
||||
- [ ] 托盘与最小化到托盘(可选)
|
||||
|
||||
### 2.4 打包发布
|
||||
- [ ] 打包配置(Linux/macOS/Windows)
|
||||
- [ ] 版本号与产物命名规范
|
||||
- [ ] 一键构建命令与发布说明
|
||||
|
||||
---
|
||||
|
||||
## 3. 联调与验收
|
||||
- [ ] 与 Center/Guild 联调通过(登录、发消息、实时)
|
||||
- [ ] 关键链路冒烟(Web + Desktop)
|
||||
- [ ] MVP DoD 文档更新
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐执行顺序
|
||||
1. Frontend 1.1 应用骨架
|
||||
2. Frontend 1.2 认证与会话
|
||||
3. Frontend 1.3/1.4 核心聊天
|
||||
4. Frontend 1.5 实时完善
|
||||
5. Desktop 2.1/2.2 安全壳
|
||||
6. Desktop 2.3 桌面增强
|
||||
7. Desktop 2.4 打包发布
|
||||
8. 联调验收
|
||||
83
docs/backup-and-restore-runbook.md
Normal file
83
docs/backup-and-restore-runbook.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Fabric 备份与恢复 Runbook(v1)
|
||||
|
||||
## 1. 范围
|
||||
- MySQL Center:`fabric_center`
|
||||
- MySQL Guild:`fabric_guild`
|
||||
- 可选:持久卷级别备份(`mysql_center_data` / `mysql_guild_data`)
|
||||
|
||||
---
|
||||
|
||||
## 2. 备份策略(建议)
|
||||
- 频率:每天 1 次全量(低峰期)
|
||||
- 保留:最近 7~14 天
|
||||
- 方式:`mysqldump`(逻辑备份)+ 异地对象存储
|
||||
- 校验:每周至少一次恢复演练
|
||||
|
||||
---
|
||||
|
||||
## 3. 手动备份命令
|
||||
> 在 `Fabric/` 目录执行
|
||||
|
||||
### 3.1 备份 Center
|
||||
```bash
|
||||
docker exec fabric-mysql-center sh -lc \
|
||||
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick --routines --events fabric_center' \
|
||||
> backup-center-$(date +%F-%H%M%S).sql
|
||||
```
|
||||
|
||||
### 3.2 备份 Guild
|
||||
```bash
|
||||
docker exec fabric-mysql-guild sh -lc \
|
||||
'mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --quick --routines --events fabric_guild' \
|
||||
> backup-guild-$(date +%F-%H%M%S).sql
|
||||
```
|
||||
|
||||
### 3.3 压缩
|
||||
```bash
|
||||
gzip backup-center-*.sql
|
||||
gzip backup-guild-*.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 恢复流程
|
||||
|
||||
### 4.1 停写(建议)
|
||||
- 先停 `backend-center` / `backend-guild`,避免恢复时写入冲突。
|
||||
|
||||
### 4.2 恢复 Center
|
||||
```bash
|
||||
gunzip -c backup-center-YYYY-MM-DD-HHMMSS.sql.gz | \
|
||||
docker exec -i fabric-mysql-center sh -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" fabric_center'
|
||||
```
|
||||
|
||||
### 4.3 恢复 Guild
|
||||
```bash
|
||||
gunzip -c backup-guild-YYYY-MM-DD-HHMMSS.sql.gz | \
|
||||
docker exec -i fabric-mysql-guild sh -lc 'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" fabric_guild'
|
||||
```
|
||||
|
||||
### 4.4 恢复后检查
|
||||
- 启动后端服务
|
||||
- 调用:
|
||||
- `GET /api/healthz`
|
||||
- `GET /api/metrics`
|
||||
- 随机抽查:
|
||||
- 用户登录
|
||||
- 节点注册列表
|
||||
- 消息拉取/回补
|
||||
|
||||
---
|
||||
|
||||
## 5. 演练清单
|
||||
- [ ] 恢复耗时记录
|
||||
- [ ] 数据一致性抽样
|
||||
- [ ] 回滚预案验证
|
||||
- [ ] 文档更新(命令/版本/风险)
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与注意事项
|
||||
- 恢复前先确认目标库环境(避免误写生产)
|
||||
- 密钥与密码不要写入仓库,统一走环境变量
|
||||
- `DB_SYNC` 在生产保持 `false`,结构变更走 migration
|
||||
Reference in New Issue
Block a user