create-forgeon 0.3.5 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/add-help.mjs +1 -0
- package/src/cli/add-options.mjs +6 -0
- package/src/cli/add-options.test.mjs +2 -0
- package/src/modules/dependencies.mjs +31 -0
- package/src/modules/dependencies.test.mjs +207 -5
- package/src/modules/executor.mjs +14 -0
- package/src/modules/executor.test.mjs +752 -14
- package/src/modules/files-access.mjs +446 -0
- package/src/modules/files-image.mjs +540 -0
- package/src/modules/files-local.mjs +221 -0
- package/src/modules/files-quotas.mjs +402 -0
- package/src/modules/files-s3.mjs +266 -0
- package/src/modules/files.mjs +527 -0
- package/src/modules/queue.mjs +410 -0
- package/src/modules/registry.mjs +93 -3
- package/src/modules/shared/patch-utils.mjs +25 -0
- package/src/run-add-module.mjs +89 -2
- package/templates/module-fragments/files/00_title.md +1 -0
- package/templates/module-fragments/files/10_overview.md +17 -0
- package/templates/module-fragments/files/20_scope.md +13 -0
- package/templates/module-fragments/files/90_status_implemented.md +3 -0
- package/templates/module-fragments/files-access/00_title.md +1 -0
- package/templates/module-fragments/files-access/10_overview.md +9 -0
- package/templates/module-fragments/files-access/20_scope.md +20 -0
- package/templates/module-fragments/files-access/90_status_implemented.md +3 -0
- package/templates/module-fragments/files-image/00_title.md +1 -0
- package/templates/module-fragments/files-image/10_overview.md +10 -0
- package/templates/module-fragments/files-image/20_scope.md +20 -0
- package/templates/module-fragments/files-image/90_status_implemented.md +3 -0
- package/templates/module-fragments/files-local/00_title.md +1 -0
- package/templates/module-fragments/files-local/10_overview.md +9 -0
- package/templates/module-fragments/files-local/20_scope.md +10 -0
- package/templates/module-fragments/files-local/90_status_implemented.md +3 -0
- package/templates/module-fragments/files-quotas/00_title.md +1 -0
- package/templates/module-fragments/files-quotas/10_overview.md +9 -0
- package/templates/module-fragments/files-quotas/20_scope.md +20 -0
- package/templates/module-fragments/files-quotas/90_status_implemented.md +3 -0
- package/templates/module-fragments/files-s3/00_title.md +1 -0
- package/templates/module-fragments/files-s3/10_overview.md +17 -0
- package/templates/module-fragments/files-s3/20_scope.md +11 -0
- package/templates/module-fragments/files-s3/90_status_implemented.md +5 -0
- package/templates/module-fragments/queue/20_scope.md +8 -7
- package/templates/module-fragments/queue/90_status_implemented.md +3 -0
- package/templates/module-presets/files/apps/api/prisma/migrations/20260306_files_file_record/migration.sql +30 -0
- package/templates/module-presets/files/apps/api/prisma/migrations/20260306_files_file_variant/migration.sql +55 -0
- package/templates/module-presets/files/packages/files/package.json +24 -0
- package/templates/module-presets/files/packages/files/src/dto/create-file.dto.ts +30 -0
- package/templates/module-presets/files/packages/files/src/files-config.loader.ts +21 -0
- package/templates/module-presets/files/packages/files/src/files-config.module.ts +12 -0
- package/templates/module-presets/files/packages/files/src/files-config.service.ts +32 -0
- package/templates/module-presets/files/packages/files/src/files-env.schema.ts +30 -0
- package/templates/module-presets/files/packages/files/src/files.controller.ts +90 -0
- package/templates/module-presets/files/packages/files/src/files.service.ts +762 -0
- package/templates/module-presets/files/packages/files/src/files.types.ts +35 -0
- package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +12 -0
- package/templates/module-presets/files/packages/files/src/index.ts +9 -0
- package/templates/module-presets/files/packages/files/tsconfig.json +9 -0
- package/templates/module-presets/files-access/packages/files-access/package.json +17 -0
- package/templates/module-presets/files-access/packages/files-access/src/files-access.service.ts +59 -0
- package/templates/module-presets/files-access/packages/files-access/src/files-access.subject.ts +45 -0
- package/templates/module-presets/files-access/packages/files-access/src/files-access.types.ts +14 -0
- package/templates/module-presets/files-access/packages/files-access/src/forgeon-files-access.module.ts +8 -0
- package/templates/module-presets/files-access/packages/files-access/src/index.ts +4 -0
- package/templates/module-presets/files-access/packages/files-access/tsconfig.json +9 -0
- package/templates/module-presets/files-image/packages/files-image/package.json +21 -0
- package/templates/module-presets/files-image/packages/files-image/src/files-image-config.loader.ts +32 -0
- package/templates/module-presets/files-image/packages/files-image/src/files-image-config.module.ts +11 -0
- package/templates/module-presets/files-image/packages/files-image/src/files-image-config.service.ts +55 -0
- package/templates/module-presets/files-image/packages/files-image/src/files-image-env.schema.ts +28 -0
- package/templates/module-presets/files-image/packages/files-image/src/files-image.service.ts +420 -0
- package/templates/module-presets/files-image/packages/files-image/src/files-image.types.ts +18 -0
- package/templates/module-presets/files-image/packages/files-image/src/forgeon-files-image.module.ts +10 -0
- package/templates/module-presets/files-image/packages/files-image/src/index.ts +7 -0
- package/templates/module-presets/files-image/packages/files-image/tsconfig.json +9 -0
- package/templates/module-presets/files-local/packages/files-local/package.json +19 -0
- package/templates/module-presets/files-local/packages/files-local/src/files-local-config.loader.ts +13 -0
- package/templates/module-presets/files-local/packages/files-local/src/files-local-config.module.ts +12 -0
- package/templates/module-presets/files-local/packages/files-local/src/files-local-config.service.ts +11 -0
- package/templates/module-presets/files-local/packages/files-local/src/files-local-env.schema.ts +13 -0
- package/templates/module-presets/files-local/packages/files-local/src/index.ts +4 -0
- package/templates/module-presets/files-local/packages/files-local/tsconfig.json +9 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/package.json +20 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.loader.ts +22 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.module.ts +11 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-config.service.ts +27 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas-env.schema.ts +15 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts +118 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.types.ts +22 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +11 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/src/index.ts +7 -0
- package/templates/module-presets/files-quotas/packages/files-quotas/tsconfig.json +9 -0
- package/templates/module-presets/files-s3/packages/files-s3/package.json +20 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.loader.ts +57 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.module.ts +12 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-config.service.ts +44 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/files-s3-env.schema.ts +51 -0
- package/templates/module-presets/files-s3/packages/files-s3/src/index.ts +4 -0
- package/templates/module-presets/files-s3/packages/files-s3/tsconfig.json +9 -0
- package/templates/module-presets/queue/packages/queue/package.json +21 -0
- package/templates/module-presets/queue/packages/queue/src/forgeon-queue.module.ts +10 -0
- package/templates/module-presets/queue/packages/queue/src/index.ts +6 -0
- package/templates/module-presets/queue/packages/queue/src/queue-config.loader.ts +24 -0
- package/templates/module-presets/queue/packages/queue/src/queue-config.module.ts +10 -0
- package/templates/module-presets/queue/packages/queue/src/queue-config.service.ts +69 -0
- package/templates/module-presets/queue/packages/queue/src/queue-env.schema.ts +17 -0
- package/templates/module-presets/queue/packages/queue/src/queue.service.ts +88 -0
- package/templates/module-presets/queue/packages/queue/tsconfig.json +9 -0
- package/templates/module-fragments/queue/90_status_planned.md +0 -3
package/templates/module-presets/files-quotas/packages/files-quotas/src/files-quotas.service.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ConflictException, Injectable } from '@nestjs/common';
|
|
2
|
+
import { FilesService } from '@forgeon/files';
|
|
3
|
+
import { FilesQuotasConfigService } from './files-quotas-config.service';
|
|
4
|
+
import type { FilesQuotaCheckInput, FilesQuotaCheckResult } from './files-quotas.types';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class FilesQuotasService {
|
|
8
|
+
constructor(
|
|
9
|
+
private readonly filesService: FilesService,
|
|
10
|
+
private readonly configService: FilesQuotasConfigService,
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
get enabled(): boolean {
|
|
14
|
+
return this.configService.enabled;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get limits() {
|
|
18
|
+
return {
|
|
19
|
+
maxFilesPerOwner: this.configService.maxFilesPerOwner,
|
|
20
|
+
maxBytesPerOwner: this.configService.maxBytesPerOwner,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async evaluateUpload(input: FilesQuotaCheckInput): Promise<FilesQuotaCheckResult> {
|
|
25
|
+
const limits = this.limits;
|
|
26
|
+
const emptyUsage = {
|
|
27
|
+
filesCount: 0,
|
|
28
|
+
totalBytes: 0,
|
|
29
|
+
};
|
|
30
|
+
const baseResult = {
|
|
31
|
+
limits,
|
|
32
|
+
current: emptyUsage,
|
|
33
|
+
next: {
|
|
34
|
+
filesCount: emptyUsage.filesCount + 1,
|
|
35
|
+
totalBytes: emptyUsage.totalBytes + input.fileSize,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
if (!this.enabled) {
|
|
40
|
+
return {
|
|
41
|
+
allowed: true,
|
|
42
|
+
reason: 'disabled',
|
|
43
|
+
...baseResult,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!input.ownerId) {
|
|
48
|
+
return {
|
|
49
|
+
allowed: true,
|
|
50
|
+
reason: 'owner-missing',
|
|
51
|
+
...baseResult,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const usage = await this.filesService.getOwnerUsage(input.ownerType, input.ownerId);
|
|
56
|
+
const next = {
|
|
57
|
+
filesCount: usage.filesCount + 1,
|
|
58
|
+
totalBytes: usage.totalBytes + input.fileSize,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (next.filesCount > limits.maxFilesPerOwner) {
|
|
62
|
+
return {
|
|
63
|
+
allowed: false,
|
|
64
|
+
reason: 'max-files',
|
|
65
|
+
limits,
|
|
66
|
+
current: usage,
|
|
67
|
+
next,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (next.totalBytes > limits.maxBytesPerOwner) {
|
|
71
|
+
return {
|
|
72
|
+
allowed: false,
|
|
73
|
+
reason: 'max-bytes',
|
|
74
|
+
limits,
|
|
75
|
+
current: usage,
|
|
76
|
+
next,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
allowed: true,
|
|
82
|
+
reason: 'ok',
|
|
83
|
+
limits,
|
|
84
|
+
current: usage,
|
|
85
|
+
next,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async assertUploadAllowed(input: FilesQuotaCheckInput): Promise<void> {
|
|
90
|
+
const result = await this.evaluateUpload(input);
|
|
91
|
+
if (result.allowed) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
throw new ConflictException({
|
|
96
|
+
message: 'File quota exceeded for owner',
|
|
97
|
+
details: {
|
|
98
|
+
reason: result.reason,
|
|
99
|
+
limits: result.limits,
|
|
100
|
+
current: result.current,
|
|
101
|
+
next: result.next,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async getProbeStatus(input: FilesQuotaCheckInput): Promise<{
|
|
107
|
+
status: 'ok';
|
|
108
|
+
feature: 'files-quotas';
|
|
109
|
+
result: FilesQuotaCheckResult;
|
|
110
|
+
}> {
|
|
111
|
+
const result = await this.evaluateUpload(input);
|
|
112
|
+
return {
|
|
113
|
+
status: 'ok',
|
|
114
|
+
feature: 'files-quotas',
|
|
115
|
+
result,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type FilesQuotaCheckInput = {
|
|
2
|
+
ownerType: string;
|
|
3
|
+
ownerId: string | null;
|
|
4
|
+
fileSize: number;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type FilesQuotaCheckResult = {
|
|
8
|
+
allowed: boolean;
|
|
9
|
+
reason: 'ok' | 'disabled' | 'owner-missing' | 'max-files' | 'max-bytes';
|
|
10
|
+
limits: {
|
|
11
|
+
maxFilesPerOwner: number;
|
|
12
|
+
maxBytesPerOwner: number;
|
|
13
|
+
};
|
|
14
|
+
current: {
|
|
15
|
+
filesCount: number;
|
|
16
|
+
totalBytes: number;
|
|
17
|
+
};
|
|
18
|
+
next: {
|
|
19
|
+
filesCount: number;
|
|
20
|
+
totalBytes: number;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ForgeonFilesModule } from '@forgeon/files';
|
|
3
|
+
import { FilesQuotasConfigModule } from './files-quotas-config.module';
|
|
4
|
+
import { FilesQuotasService } from './files-quotas.service';
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [ForgeonFilesModule, FilesQuotasConfigModule],
|
|
8
|
+
providers: [FilesQuotasService],
|
|
9
|
+
exports: [FilesQuotasConfigModule, FilesQuotasService],
|
|
10
|
+
})
|
|
11
|
+
export class ForgeonFilesQuotasModule {}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export * from './files-quotas-config.loader';
|
|
2
|
+
export * from './files-quotas-config.module';
|
|
3
|
+
export * from './files-quotas-config.service';
|
|
4
|
+
export * from './files-quotas-env.schema';
|
|
5
|
+
export * from './files-quotas.service';
|
|
6
|
+
export * from './files-quotas.types';
|
|
7
|
+
export * from './forgeon-files-quotas.module';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/files-s3",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@aws-sdk/client-s3": "^3.922.0",
|
|
12
|
+
"@nestjs/common": "^11.0.1",
|
|
13
|
+
"@nestjs/config": "^4.0.0",
|
|
14
|
+
"zod": "^3.24.2"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^22.10.7",
|
|
18
|
+
"typescript": "^5.7.3"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { registerAs } from '@nestjs/config';
|
|
2
|
+
import { parseFilesS3Env } from './files-s3-env.schema';
|
|
3
|
+
|
|
4
|
+
export type FilesS3ProviderPreset = 'minio' | 'r2' | 'aws' | 'custom';
|
|
5
|
+
|
|
6
|
+
export type FilesS3ConfigValue = {
|
|
7
|
+
providerPreset: FilesS3ProviderPreset;
|
|
8
|
+
bucket: string;
|
|
9
|
+
region: string;
|
|
10
|
+
endpoint?: string;
|
|
11
|
+
accessKeyId: string;
|
|
12
|
+
secretAccessKey: string;
|
|
13
|
+
forcePathStyle: boolean;
|
|
14
|
+
maxAttempts: number;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const providerDefaults: Record<
|
|
18
|
+
FilesS3ProviderPreset,
|
|
19
|
+
{
|
|
20
|
+
region: string;
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
forcePathStyle: boolean;
|
|
23
|
+
}
|
|
24
|
+
> = {
|
|
25
|
+
minio: {
|
|
26
|
+
region: 'auto',
|
|
27
|
+
endpoint: 'http://localhost:9000',
|
|
28
|
+
forcePathStyle: true,
|
|
29
|
+
},
|
|
30
|
+
r2: {
|
|
31
|
+
region: 'auto',
|
|
32
|
+
forcePathStyle: false,
|
|
33
|
+
},
|
|
34
|
+
aws: {
|
|
35
|
+
region: 'eu-central-1',
|
|
36
|
+
forcePathStyle: false,
|
|
37
|
+
},
|
|
38
|
+
custom: {
|
|
39
|
+
region: 'eu-central-1',
|
|
40
|
+
forcePathStyle: false,
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const filesS3Config = registerAs('filesS3', (): FilesS3ConfigValue => {
|
|
45
|
+
const env = parseFilesS3Env(process.env);
|
|
46
|
+
const presetDefaults = providerDefaults[env.FILES_S3_PROVIDER_PRESET];
|
|
47
|
+
return {
|
|
48
|
+
providerPreset: env.FILES_S3_PROVIDER_PRESET,
|
|
49
|
+
bucket: env.FILES_S3_BUCKET,
|
|
50
|
+
region: env.FILES_S3_REGION ?? presetDefaults.region,
|
|
51
|
+
endpoint: env.FILES_S3_ENDPOINT ?? presetDefaults.endpoint,
|
|
52
|
+
accessKeyId: env.FILES_S3_ACCESS_KEY_ID,
|
|
53
|
+
secretAccessKey: env.FILES_S3_SECRET_ACCESS_KEY,
|
|
54
|
+
forcePathStyle: env.FILES_S3_FORCE_PATH_STYLE ?? presetDefaults.forcePathStyle,
|
|
55
|
+
maxAttempts: env.FILES_S3_MAX_ATTEMPTS,
|
|
56
|
+
};
|
|
57
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '@nestjs/config';
|
|
3
|
+
import { filesS3Config } from './files-s3-config.loader';
|
|
4
|
+
import { FilesS3ConfigService } from './files-s3-config.service';
|
|
5
|
+
|
|
6
|
+
@Global()
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [ConfigModule.forFeature(filesS3Config)],
|
|
9
|
+
providers: [FilesS3ConfigService],
|
|
10
|
+
exports: [FilesS3ConfigService],
|
|
11
|
+
})
|
|
12
|
+
export class FilesS3ConfigModule {}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import type { FilesS3ConfigValue } from './files-s3-config.loader';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class FilesS3ConfigService {
|
|
7
|
+
constructor(private readonly configService: ConfigService) {}
|
|
8
|
+
|
|
9
|
+
get bucket(): string {
|
|
10
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['bucket']>('filesS3.bucket');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get providerPreset(): FilesS3ConfigValue['providerPreset'] {
|
|
14
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['providerPreset']>('filesS3.providerPreset');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get region(): string {
|
|
18
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['region']>('filesS3.region');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get endpoint(): string | undefined {
|
|
22
|
+
return this.configService.get<FilesS3ConfigValue['endpoint']>('filesS3.endpoint');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get accessKeyId(): string {
|
|
26
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['accessKeyId']>('filesS3.accessKeyId');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
get secretAccessKey(): string {
|
|
30
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['secretAccessKey']>(
|
|
31
|
+
'filesS3.secretAccessKey',
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get forcePathStyle(): boolean {
|
|
36
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['forcePathStyle']>(
|
|
37
|
+
'filesS3.forcePathStyle',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get maxAttempts(): number {
|
|
42
|
+
return this.configService.getOrThrow<FilesS3ConfigValue['maxAttempts']>('filesS3.maxAttempts');
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
const s3ProviderPresetSchema = z.enum(['minio', 'r2', 'aws', 'custom']);
|
|
4
|
+
|
|
5
|
+
function normalizeOptionalString() {
|
|
6
|
+
return z.string().optional().transform((value) => {
|
|
7
|
+
if (value === undefined) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const normalized = value.trim();
|
|
11
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseIntFromEnv(defaultValue: number, minValue: number) {
|
|
16
|
+
return z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.default(String(defaultValue))
|
|
20
|
+
.transform((value) => Number.parseInt(value, 10))
|
|
21
|
+
.refine((value) => Number.isInteger(value), 'must be an integer')
|
|
22
|
+
.refine((value) => value >= minValue, `must be >= ${minValue}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const filesS3EnvSchema = z.object({
|
|
26
|
+
FILES_S3_PROVIDER_PRESET: s3ProviderPresetSchema.optional().default('minio'),
|
|
27
|
+
FILES_S3_BUCKET: z.string().optional().default('forgeon-files'),
|
|
28
|
+
FILES_S3_REGION: normalizeOptionalString(),
|
|
29
|
+
FILES_S3_ENDPOINT: normalizeOptionalString(),
|
|
30
|
+
FILES_S3_ACCESS_KEY_ID: z.string().optional().default('forgeon'),
|
|
31
|
+
FILES_S3_SECRET_ACCESS_KEY: z.string().optional().default('forgeon-secret'),
|
|
32
|
+
FILES_S3_FORCE_PATH_STYLE: z.string().optional().transform((value) => {
|
|
33
|
+
if (value === undefined) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const normalized = value.trim().toLowerCase();
|
|
37
|
+
if (normalized.length === 0) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return normalized !== 'false';
|
|
41
|
+
}),
|
|
42
|
+
FILES_S3_MAX_ATTEMPTS: parseIntFromEnv(3, 1),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export type FilesS3EnvSchema = z.infer<typeof filesS3EnvSchema>;
|
|
46
|
+
|
|
47
|
+
export function parseFilesS3Env(input: Record<string, string | undefined>): FilesS3EnvSchema {
|
|
48
|
+
return filesS3EnvSchema.parse(input);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const filesS3EnvSchemaZod = filesS3EnvSchema;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeon/queue",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc -p tsconfig.json"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@nestjs/common": "^11.0.1",
|
|
12
|
+
"@nestjs/config": "^4.0.2",
|
|
13
|
+
"bullmq": "^5.61.0",
|
|
14
|
+
"rxjs": "^7.8.1",
|
|
15
|
+
"zod": "^3.23.8"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.10.7",
|
|
19
|
+
"typescript": "^5.7.3"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { QueueConfigModule } from './queue-config.module';
|
|
3
|
+
import { QueueService } from './queue.service';
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
imports: [QueueConfigModule],
|
|
7
|
+
providers: [QueueService],
|
|
8
|
+
exports: [QueueConfigModule, QueueService],
|
|
9
|
+
})
|
|
10
|
+
export class ForgeonQueueModule {}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { registerAs } from '@nestjs/config';
|
|
2
|
+
import { parseQueueEnv } from './queue-env.schema';
|
|
3
|
+
|
|
4
|
+
export const QUEUE_CONFIG_NAMESPACE = 'queue';
|
|
5
|
+
|
|
6
|
+
export interface QueueConfigValues {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
redisUrl: string;
|
|
9
|
+
prefix: string;
|
|
10
|
+
defaultAttempts: number;
|
|
11
|
+
defaultBackoffMs: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const queueConfig = registerAs(QUEUE_CONFIG_NAMESPACE, (): QueueConfigValues => {
|
|
15
|
+
const env = parseQueueEnv(process.env);
|
|
16
|
+
|
|
17
|
+
return {
|
|
18
|
+
enabled: env.QUEUE_ENABLED,
|
|
19
|
+
redisUrl: env.QUEUE_REDIS_URL,
|
|
20
|
+
prefix: env.QUEUE_PREFIX,
|
|
21
|
+
defaultAttempts: env.QUEUE_DEFAULT_ATTEMPTS,
|
|
22
|
+
defaultBackoffMs: env.QUEUE_DEFAULT_BACKOFF_MS,
|
|
23
|
+
};
|
|
24
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule } from '@nestjs/config';
|
|
3
|
+
import { QueueConfigService } from './queue-config.service';
|
|
4
|
+
|
|
5
|
+
@Module({
|
|
6
|
+
imports: [ConfigModule],
|
|
7
|
+
providers: [QueueConfigService],
|
|
8
|
+
exports: [QueueConfigService],
|
|
9
|
+
})
|
|
10
|
+
export class QueueConfigModule {}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { ConfigService } from '@nestjs/config';
|
|
3
|
+
import type { ConnectionOptions } from 'bullmq';
|
|
4
|
+
import { QUEUE_CONFIG_NAMESPACE, QueueConfigValues } from './queue-config.loader';
|
|
5
|
+
|
|
6
|
+
@Injectable()
|
|
7
|
+
export class QueueConfigService {
|
|
8
|
+
constructor(private readonly configService: ConfigService) {}
|
|
9
|
+
|
|
10
|
+
get enabled(): QueueConfigValues['enabled'] {
|
|
11
|
+
return this.configService.getOrThrow<QueueConfigValues['enabled']>(
|
|
12
|
+
`${QUEUE_CONFIG_NAMESPACE}.enabled`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get redisUrl(): QueueConfigValues['redisUrl'] {
|
|
17
|
+
return this.configService.getOrThrow<QueueConfigValues['redisUrl']>(
|
|
18
|
+
`${QUEUE_CONFIG_NAMESPACE}.redisUrl`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
get prefix(): QueueConfigValues['prefix'] {
|
|
23
|
+
return this.configService.getOrThrow<QueueConfigValues['prefix']>(
|
|
24
|
+
`${QUEUE_CONFIG_NAMESPACE}.prefix`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
get defaultAttempts(): QueueConfigValues['defaultAttempts'] {
|
|
29
|
+
return this.configService.getOrThrow<QueueConfigValues['defaultAttempts']>(
|
|
30
|
+
`${QUEUE_CONFIG_NAMESPACE}.defaultAttempts`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get defaultBackoffMs(): QueueConfigValues['defaultBackoffMs'] {
|
|
35
|
+
return this.configService.getOrThrow<QueueConfigValues['defaultBackoffMs']>(
|
|
36
|
+
`${QUEUE_CONFIG_NAMESPACE}.defaultBackoffMs`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
get connectionOptions(): ConnectionOptions {
|
|
41
|
+
const parsedUrl = new URL(this.redisUrl);
|
|
42
|
+
const options: ConnectionOptions = {
|
|
43
|
+
host: parsedUrl.hostname,
|
|
44
|
+
port: parsedUrl.port ? Number(parsedUrl.port) : 6379,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (parsedUrl.username) {
|
|
48
|
+
(options as { username?: string }).username = decodeURIComponent(parsedUrl.username);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (parsedUrl.password) {
|
|
52
|
+
(options as { password?: string }).password = decodeURIComponent(parsedUrl.password);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const dbPath = parsedUrl.pathname.replace(/^\//, '');
|
|
56
|
+
if (dbPath.length > 0) {
|
|
57
|
+
const dbValue = Number(dbPath);
|
|
58
|
+
if (Number.isInteger(dbValue) && dbValue >= 0) {
|
|
59
|
+
(options as { db?: number }).db = dbValue;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (parsedUrl.protocol === 'rediss:') {
|
|
64
|
+
(options as { tls?: Record<string, never> }).tls = {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return options;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const queueEnvSchema = z
|
|
4
|
+
.object({
|
|
5
|
+
QUEUE_ENABLED: z.coerce.boolean().default(true),
|
|
6
|
+
QUEUE_REDIS_URL: z.string().trim().min(1).default('redis://localhost:6379'),
|
|
7
|
+
QUEUE_PREFIX: z.string().trim().min(1).default('forgeon'),
|
|
8
|
+
QUEUE_DEFAULT_ATTEMPTS: z.coerce.number().int().positive().default(3),
|
|
9
|
+
QUEUE_DEFAULT_BACKOFF_MS: z.coerce.number().int().nonnegative().default(1000),
|
|
10
|
+
})
|
|
11
|
+
.passthrough();
|
|
12
|
+
|
|
13
|
+
export type QueueEnv = z.infer<typeof queueEnvSchema>;
|
|
14
|
+
|
|
15
|
+
export function parseQueueEnv(input: Record<string, unknown>): QueueEnv {
|
|
16
|
+
return queueEnvSchema.parse(input);
|
|
17
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
|
2
|
+
import type { JobsOptions } from 'bullmq';
|
|
3
|
+
import { Queue } from 'bullmq';
|
|
4
|
+
import { QueueConfigService } from './queue-config.service';
|
|
5
|
+
|
|
6
|
+
export const FORGEON_DEFAULT_QUEUE = 'forgeon.default';
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class QueueService implements OnModuleDestroy {
|
|
10
|
+
private queue: Queue | null = null;
|
|
11
|
+
|
|
12
|
+
constructor(private readonly queueConfig: QueueConfigService) {}
|
|
13
|
+
|
|
14
|
+
async enqueue(
|
|
15
|
+
name: string,
|
|
16
|
+
data: Record<string, unknown>,
|
|
17
|
+
options: JobsOptions = {},
|
|
18
|
+
): Promise<{ queued: boolean; id: string | null }> {
|
|
19
|
+
if (!this.queueConfig.enabled) {
|
|
20
|
+
return { queued: false, id: null };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const job = await this.getQueue().add(name, data, {
|
|
24
|
+
attempts: this.queueConfig.defaultAttempts,
|
|
25
|
+
backoff: {
|
|
26
|
+
type: 'fixed',
|
|
27
|
+
delay: this.queueConfig.defaultBackoffMs,
|
|
28
|
+
},
|
|
29
|
+
...options,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
queued: true,
|
|
34
|
+
id: job.id == null ? null : String(job.id),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async getProbeStatus(): Promise<Record<string, unknown>> {
|
|
39
|
+
if (!this.queueConfig.enabled) {
|
|
40
|
+
return {
|
|
41
|
+
status: 'ok',
|
|
42
|
+
feature: 'queue',
|
|
43
|
+
enabled: false,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const queue = this.getQueue();
|
|
49
|
+
const client = await queue.client;
|
|
50
|
+
const ping = await client.ping();
|
|
51
|
+
return {
|
|
52
|
+
status: 'ok',
|
|
53
|
+
feature: 'queue',
|
|
54
|
+
enabled: true,
|
|
55
|
+
queueName: FORGEON_DEFAULT_QUEUE,
|
|
56
|
+
prefix: this.queueConfig.prefix,
|
|
57
|
+
redis: ping,
|
|
58
|
+
};
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
status: 'error',
|
|
62
|
+
feature: 'queue',
|
|
63
|
+
enabled: true,
|
|
64
|
+
queueName: FORGEON_DEFAULT_QUEUE,
|
|
65
|
+
message: error instanceof Error ? error.message : 'Queue probe failed',
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async onModuleDestroy(): Promise<void> {
|
|
71
|
+
if (!this.queue) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await this.queue.close();
|
|
76
|
+
this.queue = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private getQueue(): Queue {
|
|
80
|
+
if (!this.queue) {
|
|
81
|
+
this.queue = new Queue(FORGEON_DEFAULT_QUEUE, {
|
|
82
|
+
prefix: this.queueConfig.prefix,
|
|
83
|
+
connection: this.queueConfig.connectionOptions,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return this.queue;
|
|
87
|
+
}
|
|
88
|
+
}
|