@podosoft/podokit 0.1.0 → 0.2.0

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.
Files changed (153) hide show
  1. package/README.md +98 -0
  2. package/dist/add.d.ts +40 -0
  3. package/dist/add.js +114 -0
  4. package/dist/create.d.ts +2 -1
  5. package/dist/create.js +3 -2
  6. package/dist/index.js +39 -1
  7. package/dist/prompt.d.ts +0 -1
  8. package/dist/prompt.js +6 -7
  9. package/dist/templates/fullstack-nest-svelte/README.md +20 -8
  10. package/dist/templates/fullstack-nest-svelte/apps/api/package.json +14 -2
  11. package/dist/templates/fullstack-nest-svelte/apps/api/src/app.module.ts +11 -1
  12. package/dist/templates/fullstack-nest-svelte/apps/api/src/config/env.validation.ts +20 -15
  13. package/dist/templates/fullstack-nest-svelte/apps/api/src/database/data-source.ts +19 -0
  14. package/dist/templates/fullstack-nest-svelte/apps/api/src/health/health.controller.ts +15 -5
  15. package/dist/templates/fullstack-nest-svelte/apps/api/src/main.ts +8 -0
  16. package/dist/templates/fullstack-nest-svelte/apps/web/components.json +1 -1
  17. package/dist/templates/fullstack-nest-svelte/apps/web/package.json +9 -2
  18. package/dist/templates/fullstack-nest-svelte/apps/web/src/app.css +72 -8
  19. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/button/button.svelte +82 -0
  20. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/button/index.ts +17 -0
  21. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card-action.svelte +23 -0
  22. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card-content.svelte +20 -0
  23. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card-description.svelte +20 -0
  24. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card-footer.svelte +20 -0
  25. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card-header.svelte +23 -0
  26. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card-title.svelte +20 -0
  27. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/card.svelte +22 -0
  28. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/card/index.ts +25 -0
  29. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/checkbox/checkbox.svelte +39 -0
  30. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/checkbox/index.ts +6 -0
  31. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/index.ts +7 -0
  32. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/input/input.svelte +48 -0
  33. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/label/index.ts +7 -0
  34. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/components/ui/label/label.svelte +20 -0
  35. package/dist/templates/fullstack-nest-svelte/apps/web/src/lib/utils.ts +11 -0
  36. package/dist/templates/fullstack-nest-svelte/apps/web/src/routes/+page.svelte +19 -12
  37. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/auth.controller.ts +31 -0
  38. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/auth.module.ts +22 -0
  39. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/auth.service.ts +44 -0
  40. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/dto/login.dto.ts +12 -0
  41. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/dto/register.dto.ts +13 -0
  42. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/jwt-auth.guard.ts +5 -0
  43. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/jwt.strategy.ts +23 -0
  44. package/dist/templates/modules/auth-jwt/files/apps/api/src/auth/user.entity.ts +16 -0
  45. package/dist/templates/modules/auth-jwt/files/apps/api/src/migrations/1720200000000-InitUsers.ts +23 -0
  46. package/dist/templates/modules/auth-jwt/module.manifest.json +31 -0
  47. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/demo.processor.ts +12 -0
  48. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/dto/create-job.dto.ts +9 -0
  49. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.controller.ts +29 -0
  50. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +15 -0
  51. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +8 -0
  52. package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +20 -0
  53. package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +14 -0
  54. package/dist/templates/modules/bullmq/files/infra/docker/worker.compose.example.yml +18 -0
  55. package/dist/templates/modules/bullmq/files/infra/k3s/worker-deployment.yaml +22 -0
  56. package/dist/templates/modules/bullmq/module.manifest.json +28 -0
  57. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.controller.ts +29 -0
  58. package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +9 -0
  59. package/dist/templates/modules/file-upload/module.manifest.json +19 -0
  60. package/dist/templates/modules/job-progress/files/apps/api/src/progress/dto/start-job.dto.ts +11 -0
  61. package/dist/templates/modules/job-progress/files/apps/api/src/progress/job-progress.module.ts +13 -0
  62. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.bridge.ts +19 -0
  63. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.controller.ts +17 -0
  64. package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.processor.ts +25 -0
  65. package/dist/templates/modules/job-progress/module.manifest.json +23 -0
  66. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/dto/put-object.dto.ts +8 -0
  67. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +31 -0
  68. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.controller.ts +29 -0
  69. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.module.ts +11 -0
  70. package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +37 -0
  71. package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +33 -0
  72. package/dist/templates/modules/object-storage-s3/module.manifest.json +31 -0
  73. package/dist/templates/modules/redis/files/apps/api/src/redis/cache.controller.ts +21 -0
  74. package/dist/templates/modules/redis/files/apps/api/src/redis/dto/set-cache.dto.ts +14 -0
  75. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +12 -0
  76. package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +43 -0
  77. package/dist/templates/modules/redis/module.manifest.json +21 -0
  78. package/dist/templates/modules/sse/files/apps/api/src/events/dto/publish-event.dto.ts +8 -0
  79. package/dist/templates/modules/sse/files/apps/api/src/events/events.controller.ts +25 -0
  80. package/dist/templates/modules/sse/files/apps/api/src/events/events.module.ts +12 -0
  81. package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +17 -0
  82. package/dist/templates/modules/sse/module.manifest.json +16 -0
  83. package/dist/templates/todo/README.md +40 -0
  84. package/dist/templates/todo/apps/api/Dockerfile +22 -0
  85. package/dist/templates/todo/apps/api/nest-cli.json +5 -0
  86. package/dist/templates/todo/apps/api/package.json +44 -0
  87. package/dist/templates/todo/apps/api/src/app.module.ts +19 -0
  88. package/dist/templates/todo/apps/api/src/common/all-exceptions.filter.ts +43 -0
  89. package/dist/templates/todo/apps/api/src/common/app-exception.ts +12 -0
  90. package/dist/templates/todo/apps/api/src/config/env.validation.ts +23 -0
  91. package/dist/templates/todo/apps/api/src/database/data-source.ts +19 -0
  92. package/dist/templates/todo/apps/api/src/health/health.controller.ts +23 -0
  93. package/dist/templates/todo/apps/api/src/health/health.module.ts +7 -0
  94. package/dist/templates/todo/apps/api/src/main.ts +29 -0
  95. package/dist/templates/todo/apps/api/src/migrations/1720100000000-InitTodos.ts +22 -0
  96. package/dist/templates/todo/apps/api/src/todos/dto/create-todo.dto.ts +10 -0
  97. package/dist/templates/todo/apps/api/src/todos/dto/update-todo.dto.ts +10 -0
  98. package/dist/templates/todo/apps/api/src/todos/todo.entity.ts +16 -0
  99. package/dist/templates/todo/apps/api/src/todos/todos.controller.ts +38 -0
  100. package/dist/templates/todo/apps/api/src/todos/todos.module.ts +12 -0
  101. package/dist/templates/todo/apps/api/src/todos/todos.service.ts +41 -0
  102. package/dist/templates/todo/apps/api/test/health.e2e-spec.ts +23 -0
  103. package/dist/templates/todo/apps/api/tsconfig.json +21 -0
  104. package/dist/templates/todo/apps/web/Dockerfile +22 -0
  105. package/dist/templates/todo/apps/web/components.json +15 -0
  106. package/dist/templates/todo/apps/web/package.json +35 -0
  107. package/dist/templates/todo/apps/web/src/app.css +81 -0
  108. package/dist/templates/todo/apps/web/src/app.d.ts +11 -0
  109. package/dist/templates/todo/apps/web/src/app.html +11 -0
  110. package/dist/templates/todo/apps/web/src/lib/components/ui/button/button.svelte +82 -0
  111. package/dist/templates/todo/apps/web/src/lib/components/ui/button/index.ts +17 -0
  112. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card-action.svelte +23 -0
  113. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card-content.svelte +20 -0
  114. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card-description.svelte +20 -0
  115. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card-footer.svelte +20 -0
  116. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card-header.svelte +23 -0
  117. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card-title.svelte +20 -0
  118. package/dist/templates/todo/apps/web/src/lib/components/ui/card/card.svelte +22 -0
  119. package/dist/templates/todo/apps/web/src/lib/components/ui/card/index.ts +25 -0
  120. package/dist/templates/todo/apps/web/src/lib/components/ui/checkbox/checkbox.svelte +39 -0
  121. package/dist/templates/todo/apps/web/src/lib/components/ui/checkbox/index.ts +6 -0
  122. package/dist/templates/todo/apps/web/src/lib/components/ui/input/index.ts +7 -0
  123. package/dist/templates/todo/apps/web/src/lib/components/ui/input/input.svelte +48 -0
  124. package/dist/templates/todo/apps/web/src/lib/components/ui/label/index.ts +7 -0
  125. package/dist/templates/todo/apps/web/src/lib/components/ui/label/label.svelte +20 -0
  126. package/dist/templates/todo/apps/web/src/lib/i18n/README.md +7 -0
  127. package/dist/templates/todo/apps/web/src/lib/i18n/en.ts +10 -0
  128. package/dist/templates/todo/apps/web/src/lib/i18n/ko.ts +8 -0
  129. package/dist/templates/todo/apps/web/src/lib/server/backend-proxy.ts +16 -0
  130. package/dist/templates/todo/apps/web/src/lib/utils.ts +11 -0
  131. package/dist/templates/todo/apps/web/src/routes/+layout.svelte +9 -0
  132. package/dist/templates/todo/apps/web/src/routes/+page.svelte +95 -0
  133. package/dist/templates/todo/apps/web/src/routes/api/health/+server.ts +12 -0
  134. package/dist/templates/todo/apps/web/src/routes/api/todos/+server.ts +24 -0
  135. package/dist/templates/todo/apps/web/src/routes/api/todos/[id]/+server.ts +24 -0
  136. package/dist/templates/todo/apps/web/static/.gitkeep +0 -0
  137. package/dist/templates/todo/apps/web/svelte.config.js +15 -0
  138. package/dist/templates/todo/apps/web/tsconfig.json +9 -0
  139. package/dist/templates/todo/apps/web/vite.config.ts +7 -0
  140. package/dist/templates/todo/dot-env.example +16 -0
  141. package/dist/templates/todo/dot-gitignore +9 -0
  142. package/dist/templates/todo/infra/docker/docker-compose.yml +29 -0
  143. package/dist/templates/todo/infra/k3s/api-deployment.yaml +24 -0
  144. package/dist/templates/todo/infra/k3s/configmap.yaml +10 -0
  145. package/dist/templates/todo/infra/k3s/ingress.yaml +18 -0
  146. package/dist/templates/todo/infra/k3s/namespace.yaml +4 -0
  147. package/dist/templates/todo/infra/k3s/secret.example.yaml +10 -0
  148. package/dist/templates/todo/infra/k3s/services.yaml +21 -0
  149. package/dist/templates/todo/infra/k3s/web-deployment.yaml +20 -0
  150. package/dist/templates/todo/package.json +13 -0
  151. package/dist/templates.d.ts +10 -0
  152. package/dist/templates.js +33 -0
  153. package/package.json +14 -4
@@ -0,0 +1,12 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsEmail, IsString } from "class-validator";
3
+
4
+ export class LoginDto {
5
+ @ApiProperty({ example: "user@example.com" })
6
+ @IsEmail()
7
+ email!: string;
8
+
9
+ @ApiProperty({ example: "password123" })
10
+ @IsString()
11
+ password!: string;
12
+ }
@@ -0,0 +1,13 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsEmail, IsString, MinLength } from "class-validator";
3
+
4
+ export class RegisterDto {
5
+ @ApiProperty({ example: "user@example.com" })
6
+ @IsEmail()
7
+ email!: string;
8
+
9
+ @ApiProperty({ example: "password123", minLength: 8 })
10
+ @IsString()
11
+ @MinLength(8)
12
+ password!: string;
13
+ }
@@ -0,0 +1,5 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import { AuthGuard } from "@nestjs/passport";
3
+
4
+ @Injectable()
5
+ export class JwtAuthGuard extends AuthGuard("jwt") {}
@@ -0,0 +1,23 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import { PassportStrategy } from "@nestjs/passport";
3
+ import { ExtractJwt, Strategy } from "passport-jwt";
4
+
5
+ export interface JwtPayload {
6
+ sub: string;
7
+ email: string;
8
+ }
9
+
10
+ @Injectable()
11
+ export class JwtStrategy extends PassportStrategy(Strategy) {
12
+ constructor() {
13
+ super({
14
+ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
15
+ ignoreExpiration: false,
16
+ secretOrKey: process.env.JWT_SECRET ?? "change-me-in-production",
17
+ });
18
+ }
19
+
20
+ validate(payload: JwtPayload): { userId: string; email: string } {
21
+ return { userId: payload.sub, email: payload.email };
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from "typeorm";
2
+
3
+ @Entity("users")
4
+ export class User {
5
+ @PrimaryGeneratedColumn("uuid")
6
+ id!: string;
7
+
8
+ @Column({ type: "varchar", length: 320, unique: true })
9
+ email!: string;
10
+
11
+ @Column({ type: "varchar" })
12
+ passwordHash!: string;
13
+
14
+ @CreateDateColumn({ type: "timestamptz" })
15
+ createdAt!: Date;
16
+ }
@@ -0,0 +1,23 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class InitUsers1720200000000 implements MigrationInterface {
4
+ name = "InitUsers1720200000000";
5
+
6
+ async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "pgcrypto"`);
8
+ await queryRunner.query(`
9
+ CREATE TABLE "users" (
10
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
11
+ "email" character varying(320) NOT NULL,
12
+ "passwordHash" character varying NOT NULL,
13
+ "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
14
+ CONSTRAINT "PK_users_id" PRIMARY KEY ("id"),
15
+ CONSTRAINT "UQ_users_email" UNIQUE ("email")
16
+ )
17
+ `);
18
+ }
19
+
20
+ async down(queryRunner: QueryRunner): Promise<void> {
21
+ await queryRunner.query(`DROP TABLE "users"`);
22
+ }
23
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "auth-jwt",
3
+ "description": "JWT auth: register, login, a JWT guard, and a protected /auth/me route (TypeORM users).",
4
+ "requires": [],
5
+ "targetApp": "api",
6
+ "dependencies": {
7
+ "@nestjs/jwt": "^10.2.0",
8
+ "@nestjs/passport": "^10.0.3",
9
+ "bcryptjs": "^2.4.3",
10
+ "passport": "^0.7.0",
11
+ "passport-jwt": "^4.0.1"
12
+ },
13
+ "devDependencies": {
14
+ "@types/bcryptjs": "^2.4.6",
15
+ "@types/passport-jwt": "^4.0.1"
16
+ },
17
+ "env": [
18
+ "# auth-jwt",
19
+ "JWT_SECRET=change-me-in-production",
20
+ "JWT_EXPIRES_IN=3600s"
21
+ ],
22
+ "inject": [
23
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { AuthModule } from \"./auth/auth.module\";" },
24
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "AuthModule," }
25
+ ],
26
+ "instructions": [
27
+ "Run the users migration: npm run migration:run -w <app>-api",
28
+ "Register: curl -XPOST localhost:3000/auth/register -H 'content-type: application/json' -d '{\"email\":\"a@example.com\",\"password\":\"password123\"}'",
29
+ "Call the protected route with the returned token: curl localhost:3000/auth/me -H 'authorization: Bearer <token>'"
30
+ ]
31
+ }
@@ -0,0 +1,12 @@
1
+ import { Processor, WorkerHost } from "@nestjs/bullmq";
2
+ import type { Job } from "bullmq";
3
+ import { DEMO_QUEUE } from "./queue";
4
+
5
+ @Processor(DEMO_QUEUE)
6
+ export class DemoProcessor extends WorkerHost {
7
+ async process(job: Job<{ text: string }>): Promise<{ upper: string }> {
8
+ // Simulate work.
9
+ await new Promise((resolve) => setTimeout(resolve, 500));
10
+ return { upper: String(job.data.text ?? "").toUpperCase() };
11
+ }
12
+ }
@@ -0,0 +1,9 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsNotEmpty, IsString } from "class-validator";
3
+
4
+ export class CreateJobDto {
5
+ @ApiProperty({ example: "hello" })
6
+ @IsString()
7
+ @IsNotEmpty()
8
+ text!: string;
9
+ }
@@ -0,0 +1,29 @@
1
+ import { Body, Controller, Get, NotFoundException, Param, Post } from "@nestjs/common";
2
+ import { InjectQueue } from "@nestjs/bullmq";
3
+ import { ApiTags } from "@nestjs/swagger";
4
+ import { Queue } from "bullmq";
5
+ import { CreateJobDto } from "./dto/create-job.dto";
6
+ import { DEMO_QUEUE } from "./queue";
7
+
8
+ @ApiTags("jobs")
9
+ @Controller("jobs")
10
+ export class JobsController {
11
+ constructor(@InjectQueue(DEMO_QUEUE) private readonly queue: Queue) {}
12
+
13
+ @Post()
14
+ async enqueue(@Body() dto: CreateJobDto): Promise<{ id: string | undefined }> {
15
+ const job = await this.queue.add("demo", { text: dto.text });
16
+ return { id: job.id };
17
+ }
18
+
19
+ @Get(":id")
20
+ async status(
21
+ @Param("id") id: string,
22
+ ): Promise<{ id: string | undefined; state: string; result: unknown }> {
23
+ const job = await this.queue.getJob(id);
24
+ if (!job) {
25
+ throw new NotFoundException(`Job ${id} not found`);
26
+ }
27
+ return { id: job.id, state: await job.getState(), result: job.returnvalue ?? null };
28
+ }
29
+ }
@@ -0,0 +1,15 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { BullModule } from "@nestjs/bullmq";
3
+ import { JobsController } from "./jobs.controller";
4
+ import { DEMO_QUEUE, redisConnection } from "./queue";
5
+
6
+ // Producer side: registers the queue and exposes enqueue/status endpoints.
7
+ // The processor runs in a separate worker process (see main-worker.ts).
8
+ @Module({
9
+ imports: [
10
+ BullModule.forRoot({ connection: redisConnection() }),
11
+ BullModule.registerQueue({ name: DEMO_QUEUE }),
12
+ ],
13
+ controllers: [JobsController],
14
+ })
15
+ export class JobsModule {}
@@ -0,0 +1,8 @@
1
+ export const DEMO_QUEUE = "demo";
2
+
3
+ export function redisConnection(): { host: string; port: number } {
4
+ return {
5
+ host: process.env.REDIS_HOST ?? "localhost",
6
+ port: Number(process.env.REDIS_PORT ?? 6379),
7
+ };
8
+ }
@@ -0,0 +1,20 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { BullModule } from "@nestjs/bullmq";
3
+ import { DemoProcessor } from "./demo.processor";
4
+ import { DEMO_QUEUE, redisConnection } from "./queue";
5
+ // podokit:worker-imports
6
+
7
+ // Consumer side: runs BullMQ processors. Bootstrapped by main-worker.ts as a
8
+ // separate process so workers scale independently of the API.
9
+ @Module({
10
+ imports: [
11
+ BullModule.forRoot({ connection: redisConnection() }),
12
+ BullModule.registerQueue({ name: DEMO_QUEUE }),
13
+ // podokit:worker-queues
14
+ ],
15
+ providers: [
16
+ DemoProcessor,
17
+ // podokit:worker-providers
18
+ ],
19
+ })
20
+ export class WorkerModule {}
@@ -0,0 +1,14 @@
1
+ import "dotenv/config";
2
+ import { NestFactory } from "@nestjs/core";
3
+ import { WorkerModule } from "./jobs/worker.module";
4
+
5
+ // Runs BullMQ processors in their own process (no HTTP server).
6
+ async function bootstrap(): Promise<void> {
7
+ const app = await NestFactory.createApplicationContext(WorkerModule);
8
+ await app.init();
9
+ // Keep the process alive to consume jobs.
10
+ process.on("SIGINT", () => void app.close().then(() => process.exit(0)));
11
+ process.on("SIGTERM", () => void app.close().then(() => process.exit(0)));
12
+ }
13
+
14
+ void bootstrap();
@@ -0,0 +1,18 @@
1
+ # Example: run the BullMQ worker as its own service.
2
+ #
3
+ # The default infra/docker/docker-compose.yml is dev infra (PostgreSQL + Redis)
4
+ # only, with the app running on the host. For a fully containerized deployment,
5
+ # build the API image and run the worker entrypoint against it — for example:
6
+ #
7
+ # docker compose -f infra/docker/docker-compose.yml -f infra/docker/worker.compose.example.yml up -d
8
+ services:
9
+ worker:
10
+ build:
11
+ context: ../../apps/api
12
+ command: ["node", "dist/main-worker"]
13
+ environment:
14
+ REDIS_HOST: redis
15
+ REDIS_PORT: "6379"
16
+ depends_on:
17
+ redis:
18
+ condition: service_healthy
@@ -0,0 +1,22 @@
1
+ # BullMQ worker — runs the same API image with the worker entrypoint.
2
+ # It has no HTTP server, so there is no Service or Ingress.
3
+ apiVersion: apps/v1
4
+ kind: Deployment
5
+ metadata:
6
+ name: worker
7
+ namespace: podokit
8
+ spec:
9
+ replicas: 1
10
+ selector:
11
+ matchLabels: { app: worker }
12
+ template:
13
+ metadata:
14
+ labels: { app: worker }
15
+ spec:
16
+ containers:
17
+ - name: worker
18
+ image: ghcr.io/example/podokit-api:latest
19
+ command: ["node", "dist/main-worker"]
20
+ envFrom:
21
+ - configMapRef: { name: app-config }
22
+ - secretRef: { name: app-secrets }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "bullmq",
3
+ "description": "Background jobs with BullMQ: a demo queue, a separate worker process, and enqueue/status endpoints (needs Redis).",
4
+ "requires": [],
5
+ "targetApp": "api",
6
+ "dependencies": {
7
+ "@nestjs/bullmq": "^10.2.0",
8
+ "bullmq": "^5.34.0"
9
+ },
10
+ "scripts": {
11
+ "start:worker": "node dist/main-worker",
12
+ "dev:worker": "nest start --watch --entryFile main-worker"
13
+ },
14
+ "env": [
15
+ "# bullmq uses REDIS_HOST / REDIS_PORT (already defined above)"
16
+ ],
17
+ "inject": [
18
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { JobsModule } from \"./jobs/jobs.module\";" },
19
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "JobsModule," }
20
+ ],
21
+ "instructions": [
22
+ "Ensure Redis is running: docker compose -f infra/docker/docker-compose.yml up -d",
23
+ "Run the worker in a separate terminal: npm run dev:worker -w <app>-api",
24
+ "Enqueue a job: curl -XPOST localhost:3000/jobs -H 'content-type: application/json' -d '{\"text\":\"hello\"}'",
25
+ "Check status: curl localhost:3000/jobs/<id> (state goes waiting -> active -> completed)",
26
+ "Deploy the worker as its own process: k3s manifest added at infra/k3s/worker-deployment.yaml; Docker Compose example at infra/docker/worker.compose.example.yml"
27
+ ]
28
+ }
@@ -0,0 +1,29 @@
1
+ import { BadRequestException, Controller, Get, Param, Post, UploadedFile, UseInterceptors } from "@nestjs/common";
2
+ import { FileInterceptor } from "@nestjs/platform-express";
3
+ import { ApiConsumes, ApiTags } from "@nestjs/swagger";
4
+ import { randomUUID } from "node:crypto";
5
+ import { StorageService } from "../storage/storage.service";
6
+
7
+ @ApiTags("files")
8
+ @Controller("files")
9
+ export class FilesController {
10
+ constructor(private readonly storage: StorageService) {}
11
+
12
+ @Post()
13
+ @ApiConsumes("multipart/form-data")
14
+ @UseInterceptors(FileInterceptor("file"))
15
+ async upload(@UploadedFile() file?: Express.Multer.File): Promise<{ key: string; url: string }> {
16
+ if (!file) {
17
+ throw new BadRequestException("A file field is required (multipart/form-data)");
18
+ }
19
+ const safeName = file.originalname.replace(/[^\w.-]+/g, "_");
20
+ const key = `uploads/${randomUUID()}-${safeName}`;
21
+ await this.storage.put(key, file.buffer, file.mimetype);
22
+ return { key, url: await this.storage.presignedGetUrl(key) };
23
+ }
24
+
25
+ @Get(":key/url")
26
+ async url(@Param("key") key: string): Promise<{ url: string }> {
27
+ return { url: await this.storage.presignedGetUrl(key) };
28
+ }
29
+ }
@@ -0,0 +1,9 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { FilesController } from "./files.controller";
3
+
4
+ // Uploads are stored through StorageService, provided globally by the
5
+ // object-storage-s3 module.
6
+ @Module({
7
+ controllers: [FilesController],
8
+ })
9
+ export class FilesModule {}
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "file-upload",
3
+ "description": "Multipart file upload endpoint that stores files via object storage and returns a presigned URL.",
4
+ "requires": ["object-storage-s3"],
5
+ "targetApp": "api",
6
+ "devDependencies": {
7
+ "@types/multer": "^1.4.12"
8
+ },
9
+ "env": [],
10
+ "inject": [
11
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { FilesModule } from \"./files/files.module\";" },
12
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "FilesModule," }
13
+ ],
14
+ "instructions": [
15
+ "Start MinIO (or configure AWS S3): docker compose -f infra/docker/docker-compose.yml -f infra/docker/minio.compose.yml up -d",
16
+ "Upload a file: curl -F 'file=@./some.png' localhost:3000/files",
17
+ "The response includes { key, url } — a presigned download URL."
18
+ ]
19
+ }
@@ -0,0 +1,11 @@
1
+ import { ApiPropertyOptional } from "@nestjs/swagger";
2
+ import { IsInt, IsOptional, Max, Min } from "class-validator";
3
+
4
+ export class StartJobDto {
5
+ @ApiPropertyOptional({ example: 5, description: "Number of progress steps" })
6
+ @IsOptional()
7
+ @IsInt()
8
+ @Min(1)
9
+ @Max(100)
10
+ steps?: number;
11
+ }
@@ -0,0 +1,13 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { BullModule } from "@nestjs/bullmq";
3
+ import { ProgressController } from "./progress.controller";
4
+ import { ProgressBridge } from "./progress.bridge";
5
+
6
+ // API side: enqueue progress jobs and relay their progress to SSE.
7
+ // RedisService and EventsService are provided globally by their modules.
8
+ @Module({
9
+ imports: [BullModule.registerQueue({ name: "progress" })],
10
+ controllers: [ProgressController],
11
+ providers: [ProgressBridge],
12
+ })
13
+ export class JobProgressModule {}
@@ -0,0 +1,19 @@
1
+ import { Injectable, type OnModuleInit } from "@nestjs/common";
2
+ import { RedisService } from "../redis/redis.service";
3
+ import { EventsService } from "../events/events.service";
4
+ import { PROGRESS_CHANNEL } from "./progress.processor";
5
+
6
+ // Runs in the API process: relays worker progress (Redis pub/sub) to SSE.
7
+ @Injectable()
8
+ export class ProgressBridge implements OnModuleInit {
9
+ constructor(
10
+ private readonly redis: RedisService,
11
+ private readonly events: EventsService,
12
+ ) {}
13
+
14
+ onModuleInit(): void {
15
+ this.redis.subscribe(PROGRESS_CHANNEL, (message) => {
16
+ this.events.publish({ type: "job-progress", ...JSON.parse(message) });
17
+ });
18
+ }
19
+ }
@@ -0,0 +1,17 @@
1
+ import { Body, Controller, Post } from "@nestjs/common";
2
+ import { InjectQueue } from "@nestjs/bullmq";
3
+ import { ApiTags } from "@nestjs/swagger";
4
+ import { Queue } from "bullmq";
5
+ import { StartJobDto } from "./dto/start-job.dto";
6
+
7
+ @ApiTags("progress")
8
+ @Controller("progress")
9
+ export class ProgressController {
10
+ constructor(@InjectQueue("progress") private readonly queue: Queue) {}
11
+
12
+ @Post()
13
+ async start(@Body() dto: StartJobDto): Promise<{ jobId: string | undefined }> {
14
+ const job = await this.queue.add("progress", { steps: dto.steps ?? 5 });
15
+ return { jobId: job.id };
16
+ }
17
+ }
@@ -0,0 +1,25 @@
1
+ import { Processor, WorkerHost } from "@nestjs/bullmq";
2
+ import type { Job } from "bullmq";
3
+ import { RedisService } from "../redis/redis.service";
4
+
5
+ export const PROGRESS_CHANNEL = "job:progress";
6
+
7
+ // Runs in the worker process. Reports progress both to BullMQ and, via Redis
8
+ // pub/sub, to the API process (which relays it to SSE clients).
9
+ @Processor("progress")
10
+ export class ProgressProcessor extends WorkerHost {
11
+ constructor(private readonly redis: RedisService) {
12
+ super();
13
+ }
14
+
15
+ async process(job: Job<{ steps?: number }>): Promise<{ done: true }> {
16
+ const steps = job.data.steps ?? 5;
17
+ for (let i = 1; i <= steps; i += 1) {
18
+ await new Promise((resolve) => setTimeout(resolve, 400));
19
+ const progress = Math.round((i / steps) * 100);
20
+ await job.updateProgress(progress);
21
+ await this.redis.publish(PROGRESS_CHANNEL, JSON.stringify({ jobId: job.id, progress }));
22
+ }
23
+ return { done: true };
24
+ }
25
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "job-progress",
3
+ "description": "Live job progress: a worker reports progress over Redis pub/sub and the API relays it to SSE clients (combines bullmq + redis + sse).",
4
+ "requires": ["bullmq", "sse", "redis"],
5
+ "targetApp": "api",
6
+ "env": [],
7
+ "inject": [
8
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:imports", "text": "import { JobProgressModule } from \"./progress/job-progress.module\";" },
9
+ { "file": "apps/api/src/app.module.ts", "marker": "// podokit:module-imports", "text": "JobProgressModule," },
10
+ { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:worker-imports", "text": "import { ProgressProcessor } from \"../progress/progress.processor\";" },
11
+ { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:worker-imports", "text": "import { RedisModule } from \"../redis/redis.module\";" },
12
+ { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:worker-queues", "text": "RedisModule," },
13
+ { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:worker-queues", "text": "BullModule.registerQueue({ name: \"progress\" })," },
14
+ { "file": "apps/api/src/jobs/worker.module.ts", "marker": "// podokit:worker-providers", "text": "ProgressProcessor," }
15
+ ],
16
+ "instructions": [
17
+ "Start Redis + Postgres: docker compose -f infra/docker/docker-compose.yml up -d",
18
+ "Run API and worker in separate terminals: npm run dev / npm run dev:worker -w <app>-api",
19
+ "Watch progress: curl -N localhost:3000/events/stream",
20
+ "Start a job: curl -XPOST localhost:3000/progress -H 'content-type: application/json' -d '{\"steps\":5}'",
21
+ "The stream shows job-progress events 20 -> 40 -> ... -> 100 pushed from the worker via Redis."
22
+ ]
23
+ }
@@ -0,0 +1,8 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsString } from "class-validator";
3
+
4
+ export class PutObjectDto {
5
+ @ApiProperty({ example: "hello world" })
6
+ @IsString()
7
+ content!: string;
8
+ }
@@ -0,0 +1,31 @@
1
+ import type { S3ClientConfig } from "@aws-sdk/client-s3";
2
+
3
+ export interface StorageSettings {
4
+ provider: "minio" | "aws";
5
+ bucket: string;
6
+ }
7
+
8
+ export function storageSettings(): StorageSettings {
9
+ const provider = process.env.STORAGE_PROVIDER === "aws" ? "aws" : "minio";
10
+ return { provider, bucket: process.env.S3_BUCKET ?? "podokit" };
11
+ }
12
+
13
+ // Build an S3 client config that works for both AWS S3 and MinIO.
14
+ // MinIO needs a custom endpoint and path-style addressing; AWS does not.
15
+ export function storageClientConfig(): S3ClientConfig {
16
+ const { provider } = storageSettings();
17
+ const region = process.env.S3_REGION ?? "us-east-1";
18
+
19
+ const accessKeyId = process.env.S3_ACCESS_KEY_ID;
20
+ const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY;
21
+ const credentials = accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined;
22
+
23
+ const forcePathStyle = process.env.S3_FORCE_PATH_STYLE
24
+ ? process.env.S3_FORCE_PATH_STYLE === "true"
25
+ : provider === "minio";
26
+
27
+ const config: S3ClientConfig = { region, forcePathStyle };
28
+ if (credentials) config.credentials = credentials;
29
+ if (process.env.S3_ENDPOINT) config.endpoint = process.env.S3_ENDPOINT;
30
+ return config;
31
+ }
@@ -0,0 +1,29 @@
1
+ import { Body, Controller, Get, Param, Put } from "@nestjs/common";
2
+ import { ApiTags } from "@nestjs/swagger";
3
+ import { StorageService } from "./storage.service";
4
+ import { PutObjectDto } from "./dto/put-object.dto";
5
+
6
+ // Demo endpoints — replace with your own. See the file-upload module for
7
+ // multipart uploads built on this service.
8
+ @ApiTags("storage")
9
+ @Controller("storage")
10
+ export class StorageController {
11
+ constructor(private readonly storage: StorageService) {}
12
+
13
+ @Put(":key")
14
+ async put(@Param("key") key: string, @Body() dto: PutObjectDto): Promise<{ key: string }> {
15
+ await this.storage.put(key, dto.content, "text/plain");
16
+ return { key };
17
+ }
18
+
19
+ @Get(":key")
20
+ async get(@Param("key") key: string): Promise<{ key: string; content: string }> {
21
+ const buffer = await this.storage.get(key);
22
+ return { key, content: buffer.toString("utf8") };
23
+ }
24
+
25
+ @Get(":key/presigned")
26
+ async presigned(@Param("key") key: string): Promise<{ url: string }> {
27
+ return { url: await this.storage.presignedGetUrl(key) };
28
+ }
29
+ }
@@ -0,0 +1,11 @@
1
+ import { Global, Module } from "@nestjs/common";
2
+ import { StorageController } from "./storage.controller";
3
+ import { StorageService } from "./storage.service";
4
+
5
+ @Global()
6
+ @Module({
7
+ controllers: [StorageController],
8
+ providers: [StorageService],
9
+ exports: [StorageService],
10
+ })
11
+ export class StorageModule {}
@@ -0,0 +1,37 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import {
3
+ DeleteObjectCommand,
4
+ GetObjectCommand,
5
+ PutObjectCommand,
6
+ S3Client,
7
+ } from "@aws-sdk/client-s3";
8
+ import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
9
+ import { storageClientConfig, storageSettings } from "./storage.config";
10
+
11
+ @Injectable()
12
+ export class StorageService {
13
+ private readonly client = new S3Client(storageClientConfig());
14
+ private readonly bucket = storageSettings().bucket;
15
+
16
+ async put(key: string, body: Buffer | string, contentType?: string): Promise<void> {
17
+ await this.client.send(
18
+ new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: body, ContentType: contentType }),
19
+ );
20
+ }
21
+
22
+ async get(key: string): Promise<Buffer> {
23
+ const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
24
+ const bytes = await res.Body?.transformToByteArray();
25
+ return Buffer.from(bytes ?? new Uint8Array());
26
+ }
27
+
28
+ presignedGetUrl(key: string, expiresIn = 3600): Promise<string> {
29
+ return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), {
30
+ expiresIn,
31
+ });
32
+ }
33
+
34
+ async delete(key: string): Promise<void> {
35
+ await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
36
+ }
37
+ }