@dunx/create-app 0.4.0 → 0.6.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 (65) hide show
  1. package/dist/chunk-jgd5dmqh.js +698 -0
  2. package/dist/chunk-jgd5dmqh.js.map +12 -0
  3. package/dist/cli.js +68 -5
  4. package/dist/cli.js.map +3 -3
  5. package/dist/features.d.ts +74 -0
  6. package/dist/generate.d.ts +21 -0
  7. package/dist/index.js +1 -1
  8. package/dist/scaffold.d.ts +12 -1
  9. package/package.json +1 -1
  10. package/templates/base/_gitignore +5 -0
  11. package/templates/base/tsconfig.json +19 -0
  12. package/templates/features/auth/audit.service.ts +36 -0
  13. package/templates/features/auth/auth.demo.ts +173 -0
  14. package/templates/features/auth/auth.module.ts +54 -0
  15. package/templates/features/auth/auth.tables.ts +84 -0
  16. package/templates/features/auth/profile.controller.ts +37 -0
  17. package/templates/features/cache/cache.controller.ts +85 -0
  18. package/templates/features/cache/cache.module.ts +36 -0
  19. package/templates/features/cache/sessions.service.ts +90 -0
  20. package/templates/features/chat/chat.demo.ts +184 -0
  21. package/templates/features/chat/chat.gateway.ts +85 -0
  22. package/templates/features/chat/chat.module.ts +11 -0
  23. package/templates/features/chat/lobby.service.ts +20 -0
  24. package/templates/features/database/auth.schema.ts +75 -0
  25. package/templates/features/database/database.module.ts +39 -0
  26. package/templates/features/database/ledger.controller.ts +137 -0
  27. package/templates/features/database/ledger.service.ts +257 -0
  28. package/templates/features/database/schema.ts +27 -0
  29. package/templates/features/database/seeds/0001_ledger.seeder.ts +12 -0
  30. package/templates/features/database/seeds/0002_production_audit.seeder.ts +13 -0
  31. package/templates/features/docs/docs.demo.ts +130 -0
  32. package/templates/features/docs/docs.module.ts +9 -0
  33. package/templates/features/guards/auth.guard.ts +66 -0
  34. package/templates/features/guards/guards.demo.ts +75 -0
  35. package/templates/features/guards/guards.module.ts +16 -0
  36. package/templates/features/guards/reports.controller.ts +60 -0
  37. package/templates/features/guards/reports.service.ts +22 -0
  38. package/templates/features/health/health.controller.ts +65 -0
  39. package/templates/features/health/health.module.ts +5 -0
  40. package/templates/features/http/http.demo.ts +115 -0
  41. package/templates/features/http/http.module.ts +10 -0
  42. package/templates/features/http/request-log.ts +37 -0
  43. package/templates/features/jobs/jobs.controller.ts +102 -0
  44. package/templates/features/jobs/jobs.module.ts +35 -0
  45. package/templates/features/jobs/thumbnail.jobs.ts +53 -0
  46. package/templates/features/notes/notes.controller.ts +65 -0
  47. package/templates/features/notes/notes.module.ts +9 -0
  48. package/templates/features/notes/notes.service.ts +21 -0
  49. package/templates/features/pictures/images.controller.ts +74 -0
  50. package/templates/features/pictures/pictures.module.ts +22 -0
  51. package/templates/features/pictures/thumbnails.service.ts +108 -0
  52. package/templates/features/storage/files.controller.ts +130 -0
  53. package/templates/features/storage/storage.module.ts +21 -0
  54. package/templates/features/storage/uploads.service.ts +66 -0
  55. package/templates/features/storage/workspace.ts +33 -0
  56. package/templates/features/users/users.controller.ts +47 -0
  57. package/templates/features/users/users.demo.ts +62 -0
  58. package/templates/features/users/users.module.ts +11 -0
  59. package/templates/features/users/users.repository.ts +59 -0
  60. package/templates/features/users/users.schemas.ts +68 -0
  61. package/templates/features/users/users.service.ts +46 -0
  62. package/templates/minimal/_bunfig.toml +7 -0
  63. package/dist/chunk-rnjjb0bq.js +0 -69
  64. package/dist/chunk-rnjjb0bq.js.map +0 -10
  65. /package/templates/{minimal/bunfig.toml → base/_bunfig.toml} +0 -0
@@ -0,0 +1,22 @@
1
+ import { HttpError, HttpStatusCode } from '@dunx/http';
2
+
3
+ export class ReportsService {
4
+ readonly #rows = ['q1 revenue'];
5
+
6
+ titles(): readonly string[] {
7
+ return this.#rows;
8
+ }
9
+
10
+ add(title: string): readonly string[] {
11
+ this.#rows.push(title);
12
+ return this.#rows;
13
+ }
14
+
15
+ rename(id: number, title: string): readonly string[] {
16
+ if (id < 1 || id > this.#rows.length) {
17
+ throw new HttpError(HttpStatusCode.NOT_FOUND, `No report ${id}`);
18
+ }
19
+ this.#rows[id - 1] = title;
20
+ return this.#rows;
21
+ }
22
+ }
@@ -0,0 +1,65 @@
1
+ import { Controller, Get, Public } from '@dunx/http';
2
+ import { Storage } from '@dunx/infra/files';
3
+ import { Sessions } from '../cache/sessions.service.js';
4
+ import { AppConfigService } from '../config.js';
5
+ import { Ledger } from '../database/ledger.service.js';
6
+
7
+ export interface AreaStatus {
8
+ readonly name: string;
9
+ readonly state: 'live' | 'degraded';
10
+ readonly detail: string;
11
+ }
12
+
13
+ /**
14
+ * What is actually working right now. Redis is the only area that can be down
15
+ * without stopping the app, so it is the only one that ever reports `degraded` -
16
+ * everything else is in-process and either booted or the app did not.
17
+ */
18
+ @Controller('health')
19
+ export class HealthController {
20
+ constructor(
21
+ private readonly config: AppConfigService,
22
+ private readonly ledger: Ledger,
23
+ private readonly storage: Storage,
24
+ private readonly sessions: Sessions,
25
+ ) {}
26
+
27
+ @Public()
28
+ @Get('/', {})
29
+ async status(): Promise<{
30
+ ok: boolean;
31
+ app: string;
32
+ areas: readonly AreaStatus[];
33
+ }> {
34
+ const areas = await this.areas();
35
+ return {
36
+ ok: true,
37
+ app: this.config.get('appName'),
38
+ areas,
39
+ };
40
+ }
41
+
42
+ async areas(): Promise<readonly AreaStatus[]> {
43
+ const cache = await this.sessions.status();
44
+ return [
45
+ {
46
+ name: '@dunx/infra/db',
47
+ state: 'live',
48
+ detail: `${this.ledger.rows()} ledger rows, balance ${this.ledger.balance()}`,
49
+ },
50
+ {
51
+ name: '@dunx/infra/files',
52
+ state: 'live',
53
+ detail: `${this.storage.constructor.name} ready`,
54
+ },
55
+ { name: '@dunx/infra/images', state: 'live', detail: 'Bun.Image ready' },
56
+ {
57
+ name: '@dunx/infra/redis',
58
+ state: cache.reachable ? 'live' : 'degraded',
59
+ detail: cache.reachable
60
+ ? `reachable at ${cache.url}`
61
+ : (cache.note ?? `unreachable at ${cache.url}`),
62
+ },
63
+ ];
64
+ }
65
+ }
@@ -0,0 +1,5 @@
1
+ import { Module } from '@dunx/core';
2
+ import { HealthController } from './health.controller.js';
3
+
4
+ @Module({ controllers: [HealthController] })
5
+ export class HealthModule {}
@@ -0,0 +1,115 @@
1
+ import { Logger } from '@dunx/core';
2
+ import type { HttpApp } from '@dunx/http';
3
+ import { AppConfigService } from '../config.js';
4
+ import { RequestLog } from './request-log.js';
5
+
6
+ const CORS_HEADERS = [
7
+ 'access-control-allow-origin',
8
+ 'access-control-allow-methods',
9
+ 'access-control-allow-headers',
10
+ 'access-control-allow-credentials',
11
+ 'access-control-max-age',
12
+ ] as const;
13
+
14
+ const describeCors = (response: Response): string =>
15
+ CORS_HEADERS.map(
16
+ (header) =>
17
+ `${header.slice('access-control-'.length)}=${response.headers.get(header) ?? '-'}`,
18
+ ).join(' ');
19
+
20
+ const preflight = (url: string, origin: string): Promise<Response> =>
21
+ fetch(new URL('api/notes', url), {
22
+ method: 'OPTIONS',
23
+ headers: {
24
+ origin,
25
+ 'access-control-request-method': 'POST',
26
+ 'access-control-request-headers': 'content-type',
27
+ },
28
+ });
29
+
30
+ const postNote = (url: string, text: unknown): Promise<Response> =>
31
+ fetch(new URL('api/notes', url), {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ text }),
35
+ });
36
+
37
+ const whoami = async (url: string, forwarded: boolean): Promise<string> => {
38
+ const response = await fetch(new URL('api/notes/whoami', url), {
39
+ headers: forwarded ? { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' } : {},
40
+ });
41
+ const { ip } = (await response.json()) as { ip: string | undefined };
42
+ return ip ?? '(none)';
43
+ };
44
+
45
+ export class HttpDemo {
46
+ constructor(
47
+ private readonly log: RequestLog,
48
+ private readonly config: AppConfigService,
49
+ private readonly logger: Logger,
50
+ ) {}
51
+
52
+ async demonstrate(app: HttpApp, url: string): Promise<void> {
53
+ const { logger } = this;
54
+ const origin = this.config.get('corsOrigin');
55
+
56
+ const prefixed = await fetch(new URL('api/notes', url));
57
+ logger.info(
58
+ `setGlobalPrefix("api"): GET /api/notes -> ${prefixed.status} ` +
59
+ `${JSON.stringify(await prefixed.json())}`,
60
+ );
61
+ const unprefixed = await fetch(new URL('notes', url));
62
+ logger.info(
63
+ `GET /notes -> ${unprefixed.status} (the unprefixed path is gone)`,
64
+ );
65
+
66
+ const created = await postNote(url, 'ship it');
67
+ logger.info(
68
+ `use(RequestLoggerMiddleware): POST /api/notes -> ${created.status}, ` +
69
+ `x-handled-by: ${created.headers.get('x-handled-by')}`,
70
+ );
71
+ logger.info(`RequestLog -> ${JSON.stringify(this.log.entries.slice(-2))}`);
72
+
73
+ const rejected = await postNote(url, 7);
74
+ logger.info(
75
+ `POST /api/notes {"text":7} -> ${rejected.status} ` +
76
+ `${JSON.stringify(await rejected.json())}`,
77
+ );
78
+
79
+ // Bun.serve answers a method miss with 404, so a preflight can never be
80
+ // inferred - enableCors mounts an explicit OPTIONS per path.
81
+ const allowed = await preflight(url, origin);
82
+ logger.info(
83
+ `enableCors: OPTIONS from ${origin} -> ${allowed.status} ${describeCors(allowed)}`,
84
+ );
85
+ // A denied origin gets no CORS headers at all, which is what makes a browser
86
+ // block the response.
87
+ const denied = await preflight(url, 'https://evil.test');
88
+ logger.info(
89
+ `OPTIONS from https://evil.test -> ${denied.status} ${describeCors(denied)}`,
90
+ );
91
+
92
+ logger.info(
93
+ `set("trust proxy", true): X-Forwarded-For sent -> ${await whoami(url, true)}`,
94
+ );
95
+ logger.info(`no header -> ${await whoami(url, false)}`);
96
+
97
+ // The route table and the middleware chain fold into one closure per route at
98
+ // listen(), so a late call could only ever be a silent no-op.
99
+ try {
100
+ app.setGlobalPrefix('too-late');
101
+ } catch (error) {
102
+ logger.info(
103
+ `setGlobalPrefix() after listen() threw: ${(error as Error).message}`,
104
+ );
105
+ }
106
+ }
107
+
108
+ /** The other half of `set('trust proxy')`, which needs its own app to observe. */
109
+ async proxyOff(url: string): Promise<void> {
110
+ this.logger.info(
111
+ `set("trust proxy", false): X-Forwarded-For sent -> ${await whoami(url, true)} ` +
112
+ '(the socket address; the header is ignored)',
113
+ );
114
+ }
115
+ }
@@ -0,0 +1,10 @@
1
+ import { Module } from '@dunx/core';
2
+ import { HttpDemo } from './http.demo.js';
3
+ import { RequestLog, RequestLoggerMiddleware } from './request-log.js';
4
+
5
+ // `use()` resolves middleware from the container, and every class self-binds - so
6
+ // declaring them here is for the reader, not for the resolver.
7
+ @Module({
8
+ providers: [RequestLog, RequestLoggerMiddleware, HttpDemo],
9
+ })
10
+ export class HttpModule {}
@@ -0,0 +1,37 @@
1
+ import type { BunRequest } from 'bun';
2
+ import type { Middleware, Next, RouteContext } from '@dunx/http';
3
+
4
+ /** The observable side effect: whatever the middleware saw is readable after. */
5
+ export class RequestLog {
6
+ readonly entries: string[] = [];
7
+ }
8
+
9
+ /**
10
+ * A class with `handle(req, ctx, next)`, resolved from the container - which is
11
+ * what lets it inject. Chains are folded into one closure per route at boot, and
12
+ * `ctx` is the route it was folded into: names, method, path, and its metadata.
13
+ *
14
+ * **This does not log.** `@dunx/http` already installs
15
+ * `RequestLoggingMiddleware` by default - one structured entry per request,
16
+ * with `requestId` propagated through `RequestContext` - so an app writing its
17
+ * own would be logging everything twice. What is left here is the part a
18
+ * framework cannot supply: an app-specific side effect, kept because the tour
19
+ * asserts on it and because it is the smallest possible example of the seam.
20
+ */
21
+ export class RequestLoggerMiddleware implements Middleware {
22
+ constructor(private readonly log: RequestLog) {}
23
+
24
+ async handle(
25
+ req: BunRequest,
26
+ ctx: RouteContext,
27
+ next: Next,
28
+ ): Promise<Response> {
29
+ const response = await next();
30
+ this.log.entries.push(
31
+ `${req.method} ${new URL(req.url).pathname} -> ${response.status} ` +
32
+ `(${ctx.controller}.${ctx.handler})`,
33
+ );
34
+ response.headers.set('x-handled-by', 'request-logger');
35
+ return response;
36
+ }
37
+ }
@@ -0,0 +1,102 @@
1
+ import {
2
+ Controller,
3
+ Get,
4
+ HttpError,
5
+ HttpStatusCode,
6
+ Post,
7
+ type Input,
8
+ } from '@dunx/http';
9
+ import { EncodableFormat } from '@dunx/infra/images';
10
+ import { isConnectionError } from '@dunx/infra/redis';
11
+ import { JobPublisher } from '@dunx/infra/queue';
12
+ import { z } from 'zod';
13
+ import { THUMBNAIL_QUEUE, type RenderResult } from './thumbnail.jobs.js';
14
+
15
+ const Enqueue = z
16
+ .object({
17
+ width: z.coerce.number().int().min(1).max(1024).default(128),
18
+ format: z.enum(EncodableFormat).default(EncodableFormat.WEBP),
19
+ })
20
+ .meta({ id: 'EnqueueRender', title: 'A thumbnail to render off the request' })
21
+ .strict();
22
+
23
+ const enqueue = { body: Enqueue } as const;
24
+ const oneJob = { params: z.object({ id: z.string().min(1) }) } as const;
25
+
26
+ /**
27
+ * The publish side. Nothing here consumes: `QueueModule.forRoot` binds
28
+ * `JobPublisher` and no worker, so this process enqueues and returns immediately.
29
+ * Run `bun run worker` to consume.
30
+ */
31
+ @Controller('jobs')
32
+ export class JobsController {
33
+ constructor(private readonly publisher: JobPublisher) {}
34
+
35
+ @Post('/thumbnails', enqueue)
36
+ async enqueue(
37
+ input: Input<typeof enqueue>,
38
+ ): Promise<{ id: string; queue: string; state: string }> {
39
+ const job = await this.degrades(() =>
40
+ this.publisher.publish(THUMBNAIL_QUEUE, 'render', input.body),
41
+ );
42
+
43
+ return {
44
+ id: job.id ?? '(unassigned)',
45
+ queue: THUMBNAIL_QUEUE,
46
+ // `waiting` until a worker takes it - which is the observable point of a
47
+ // queue, so it is in the response rather than hidden.
48
+ state: await job.getState(),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Poll a job. `returnvalue` is whatever the handler returned, so this is how the
54
+ * web process reads a result computed in another process.
55
+ */
56
+ @Get('/thumbnails/:id', oneJob)
57
+ async status(input: Input<typeof oneJob>): Promise<{
58
+ id: string;
59
+ state: string;
60
+ result: RenderResult | null;
61
+ failedReason: string | null;
62
+ }> {
63
+ const job = await this.degrades(() =>
64
+ this.publisher.queue(THUMBNAIL_QUEUE).getJob(input.params.id),
65
+ );
66
+ if (job === undefined) {
67
+ throw new HttpError(
68
+ HttpStatusCode.NOT_FOUND,
69
+ `No job ${input.params.id} on "${THUMBNAIL_QUEUE}"`,
70
+ );
71
+ }
72
+
73
+ return {
74
+ id: job.id ?? input.params.id,
75
+ state: await job.getState(),
76
+ result: (job.returnvalue as RenderResult | null) ?? null,
77
+ failedReason: job.failedReason ?? null,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * No Redis is a degraded queue, not a broken app - the same contract the cache
83
+ * routes keep. bullmq surfaces the failure through ioredis rather than Bun's
84
+ * client, so the connection-error shape is not guaranteed to match; anything
85
+ * unrecognised still becomes a 503 rather than a 500, because "the queue is not
86
+ * reachable" is the only thing it can mean here.
87
+ */
88
+ private async degrades<T>(run: () => Promise<T>): Promise<T> {
89
+ try {
90
+ return await run();
91
+ } catch (error) {
92
+ if (error instanceof HttpError) throw error;
93
+ const reason = isConnectionError(error)
94
+ ? (error as Error).message
95
+ : `${(error as Error).name}: ${(error as Error).message}`;
96
+ throw new HttpError(
97
+ HttpStatusCode.SERVICE_UNAVAILABLE,
98
+ `Queue unavailable: ${reason}`,
99
+ );
100
+ }
101
+ }
102
+ }
@@ -0,0 +1,35 @@
1
+ import { Module } from '@dunx/core';
2
+ import { QueueModule } from '@dunx/infra/queue';
3
+ import { AppConfigService } from '../config.js';
4
+ import { PicturesModule } from '../pictures/pictures.module.js';
5
+ import { JobsController } from './jobs.controller.js';
6
+ import { ThumbnailJobs } from './thumbnail.jobs.js';
7
+
8
+ /**
9
+ * Imported by **both** containers, which is the whole shape of a queue: the web
10
+ * process publishes, a separate worker process consumes, and they agree only on
11
+ * this module.
12
+ *
13
+ * `QueueModule.forRoot` binds the publish side alone, so importing it does not
14
+ * open a worker - a web process that publishes never consumes by accident.
15
+ * `PicturesModule` is here because the handler injects `Thumbnails`, and the
16
+ * worker's container has to be able to build it.
17
+ */
18
+ @Module({
19
+ imports: [
20
+ QueueModule.forRootAsync({
21
+ useFactory: (config: AppConfigService) => {
22
+ const { url } = config.get('redis');
23
+ return {
24
+ ...(url === undefined ? {} : { url }),
25
+ prefix: 'dunx-full',
26
+ };
27
+ },
28
+ inject: [AppConfigService] as const,
29
+ }),
30
+ PicturesModule,
31
+ ],
32
+ controllers: [JobsController],
33
+ providers: [ThumbnailJobs],
34
+ })
35
+ export class JobsModule {}
@@ -0,0 +1,53 @@
1
+ import { Logger } from '@dunx/core';
2
+ import { EncodableFormat, ImageFit } from '@dunx/infra/images';
3
+ import { JobHandler } from '@dunx/infra/queue';
4
+ import type { Job } from 'bullmq';
5
+ import { Thumbnails } from '../pictures/thumbnails.service.js';
6
+
7
+ export const THUMBNAIL_QUEUE = 'thumbnails';
8
+
9
+ export interface RenderRequest {
10
+ readonly width: number;
11
+ readonly format: EncodableFormat;
12
+ }
13
+
14
+ export interface RenderResult {
15
+ readonly width: number;
16
+ readonly height: number;
17
+ readonly bytes: number;
18
+ }
19
+
20
+ /**
21
+ * A job handler is a method with a decorator and nothing else - no registry, no
22
+ * class decorator, no queue token. `WorkerFactory` finds it by walking the
23
+ * prototypes of the classes already in `providers`, the same marker-plus-scan the
24
+ * route and gateway discovery use.
25
+ *
26
+ * It injects like anything else, which is the point: the same `Thumbnails` service
27
+ * the HTTP routes use does the work here, with no second wiring.
28
+ */
29
+ export class ThumbnailJobs {
30
+ constructor(
31
+ private readonly thumbnails: Thumbnails,
32
+ private readonly logger: Logger,
33
+ ) {}
34
+
35
+ @JobHandler({ queue: THUMBNAIL_QUEUE, name: 'render' })
36
+ async render(job: Job<RenderRequest>): Promise<RenderResult> {
37
+ const encoded = await this.thumbnails.render({
38
+ width: job.data.width,
39
+ fit: ImageFit.INSIDE,
40
+ format: job.data.format,
41
+ });
42
+
43
+ const result: RenderResult = {
44
+ width: encoded.width,
45
+ height: encoded.height,
46
+ bytes: encoded.bytes.byteLength,
47
+ };
48
+ // Written by the worker process, so seeing this line is how you know the job
49
+ // did not run in the web process.
50
+ this.logger.info(`rendered job ${job.id ?? '?'}`, result);
51
+ return result;
52
+ }
53
+ }
@@ -0,0 +1,65 @@
1
+ import {
2
+ ClientAddress,
3
+ Controller,
4
+ Get,
5
+ HttpStatusCode,
6
+ Post,
7
+ type Input,
8
+ type RouteSchemas,
9
+ } from '@dunx/http';
10
+ import { ApiDoc } from '@dunx/openapi';
11
+ import { z } from 'zod';
12
+ import { NotesService } from './notes.service.js';
13
+
14
+ const CreateNote = z
15
+ .object({ text: z.string().min(1) })
16
+ .meta({ id: 'CreateNote', title: 'Add a note' });
17
+
18
+ // An explicit status, unlike the users controller which takes the POST default.
19
+ const createNote = {
20
+ body: CreateNote,
21
+ status: HttpStatusCode.CREATED,
22
+ } as const satisfies RouteSchemas;
23
+
24
+ /**
25
+ * `@ApiDoc` carries what no zod schema can - prose, grouping, deprecation. It is a
26
+ * thin wrapper over `@dunx/http`'s generic route-metadata channel (`metaKey` mints
27
+ * a symbol, `meta` writes it), which is why documentation needs no parallel
28
+ * registry and no second discovery pass. At class scope it names the tag every
29
+ * route below is grouped under.
30
+ */
31
+ @ApiDoc({
32
+ tags: ['Notes'],
33
+ description: 'A list in memory, for showing the prefix, middleware and CORS.',
34
+ })
35
+ @Controller('notes')
36
+ export class NotesController {
37
+ // ClientAddress is a framework class with no registration - the container
38
+ // self-binds it, and app.listen() hands it the live server.
39
+ constructor(
40
+ private readonly notes: NotesService,
41
+ private readonly address: ClientAddress,
42
+ ) {}
43
+
44
+ @Get('/')
45
+ list(): readonly string[] {
46
+ return this.notes.rows();
47
+ }
48
+
49
+ // No schemas declared, so the request is all `input` carries.
50
+ @ApiDoc({
51
+ summary: 'Echo the caller’s address',
52
+ description:
53
+ 'Reads the socket address, honouring `x-forwarded-for` because `trust proxy` is set.',
54
+ deprecated: true,
55
+ })
56
+ @Get('/whoami')
57
+ whoami(input: Input<RouteSchemas>): { ip: string | undefined } {
58
+ return { ip: this.address.of(input.req) };
59
+ }
60
+
61
+ @Post('/', createNote)
62
+ create(input: Input<typeof createNote>): readonly string[] {
63
+ return this.notes.add(input.body.text);
64
+ }
65
+ }
@@ -0,0 +1,9 @@
1
+ import { Module } from '@dunx/core';
2
+ import { NotesController } from './notes.controller.js';
3
+ import { NotesService } from './notes.service.js';
4
+
5
+ @Module({
6
+ controllers: [NotesController],
7
+ providers: [NotesService],
8
+ })
9
+ export class NotesModule {}
@@ -0,0 +1,21 @@
1
+ import { Logger } from '@dunx/core';
2
+ import type { OnInit } from '@dunx/core';
3
+
4
+ export class NotesService implements OnInit {
5
+ readonly #rows = ['read the architecture doc', 'measure before deciding'];
6
+
7
+ constructor(private readonly logger: Logger) {}
8
+
9
+ onInit(): void {
10
+ this.logger.info('notes ready');
11
+ }
12
+
13
+ rows(): readonly string[] {
14
+ return this.#rows;
15
+ }
16
+
17
+ add(text: string): readonly string[] {
18
+ this.#rows.push(text);
19
+ return this.#rows;
20
+ }
21
+ }
@@ -0,0 +1,74 @@
1
+ import { Controller, Get, Post, type Input } from '@dunx/http';
2
+ import { EncodableFormat, ImageFit } from '@dunx/infra/images';
3
+ import { z } from 'zod';
4
+ import { Thumbnails } from './thumbnails.service.js';
5
+
6
+ const Resize = z
7
+ .object({
8
+ width: z.coerce.number().int().min(1).max(1024).default(64),
9
+ height: z.coerce.number().int().min(1).max(1024).optional(),
10
+ fit: z.enum(ImageFit).default(ImageFit.INSIDE),
11
+ format: z.enum(EncodableFormat).default(EncodableFormat.PNG),
12
+ quality: z.coerce.number().int().min(1).max(100).optional(),
13
+ })
14
+ .meta({ id: 'Resize', title: 'How to render the generated source image' });
15
+
16
+ const render = { query: Resize } as const;
17
+ const describe = {
18
+ body: z
19
+ .object({ base64: z.string().min(1) })
20
+ .meta({ id: 'InlineImage', title: 'Any image, base64-encoded' }),
21
+ } as const;
22
+
23
+ /**
24
+ * Every byte here is produced at runtime by `Bun.Image` from a 4x4 seed, so the
25
+ * example checks in no binaries and downloads nothing.
26
+ */
27
+ @Controller('images')
28
+ export class ImagesController {
29
+ constructor(private readonly thumbnails: Thumbnails) {}
30
+
31
+ /** Returns the encoded image itself, so a browser renders it inline. */
32
+ @Get('/render', render)
33
+ async render(input: Input<typeof render>): Promise<Response> {
34
+ const encoded = await this.thumbnails.render(input.query);
35
+ return new Response(encoded.bytes, {
36
+ headers: {
37
+ 'content-type': encoded.mimeType,
38
+ 'x-dimensions': `${encoded.width}x${encoded.height}`,
39
+ },
40
+ });
41
+ }
42
+
43
+ /** The same render, described rather than returned - easier to read in swagger. */
44
+ @Get('/metadata', render)
45
+ async metadata(input: Input<typeof render>): Promise<{
46
+ width: number;
47
+ height: number;
48
+ format: string;
49
+ mimeType: string;
50
+ bytes: number;
51
+ }> {
52
+ const encoded = await this.thumbnails.render(input.query);
53
+ return {
54
+ width: encoded.width,
55
+ height: encoded.height,
56
+ format: encoded.format,
57
+ mimeType: encoded.mimeType,
58
+ bytes: encoded.bytes.byteLength,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Format detection is content-based - magic bytes, never a filename - and this
64
+ * is a header read rather than a decode, so a truncated file still answers.
65
+ */
66
+ @Post('/describe', describe)
67
+ describe(input: Input<typeof describe>): Promise<{
68
+ width: number;
69
+ height: number;
70
+ format: string;
71
+ }> {
72
+ return this.thumbnails.describe(input.body.base64);
73
+ }
74
+ }
@@ -0,0 +1,22 @@
1
+ import { Module } from '@dunx/core';
2
+ import { ImagesModule } from '@dunx/infra/images';
3
+ import { AppConfigService } from '../config.js';
4
+ import { ImagesController } from './images.controller.js';
5
+ import { Thumbnails } from './thumbnails.service.js';
6
+
7
+ @Module({
8
+ imports: [
9
+ // `forRootAsync` is what a factory needs in order to *inject*; asynchrony is
10
+ // free either way, since every factory settles before the first constructor.
11
+ ImagesModule.forRootAsync({
12
+ useFactory: (config: AppConfigService) => ({
13
+ quality: config.get('images').quality,
14
+ maxWidth: 1024,
15
+ }),
16
+ inject: [AppConfigService],
17
+ }),
18
+ ],
19
+ controllers: [ImagesController],
20
+ providers: [Thumbnails],
21
+ })
22
+ export class PicturesModule {}