@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.
- package/dist/chunk-jgd5dmqh.js +698 -0
- package/dist/chunk-jgd5dmqh.js.map +12 -0
- package/dist/cli.js +68 -5
- package/dist/cli.js.map +3 -3
- package/dist/features.d.ts +74 -0
- package/dist/generate.d.ts +21 -0
- package/dist/index.js +1 -1
- package/dist/scaffold.d.ts +12 -1
- package/package.json +1 -1
- package/templates/base/_gitignore +5 -0
- package/templates/base/tsconfig.json +19 -0
- package/templates/features/auth/audit.service.ts +36 -0
- package/templates/features/auth/auth.demo.ts +173 -0
- package/templates/features/auth/auth.module.ts +54 -0
- package/templates/features/auth/auth.tables.ts +84 -0
- package/templates/features/auth/profile.controller.ts +37 -0
- package/templates/features/cache/cache.controller.ts +85 -0
- package/templates/features/cache/cache.module.ts +36 -0
- package/templates/features/cache/sessions.service.ts +90 -0
- package/templates/features/chat/chat.demo.ts +184 -0
- package/templates/features/chat/chat.gateway.ts +85 -0
- package/templates/features/chat/chat.module.ts +11 -0
- package/templates/features/chat/lobby.service.ts +20 -0
- package/templates/features/database/auth.schema.ts +75 -0
- package/templates/features/database/database.module.ts +39 -0
- package/templates/features/database/ledger.controller.ts +137 -0
- package/templates/features/database/ledger.service.ts +257 -0
- package/templates/features/database/schema.ts +27 -0
- package/templates/features/database/seeds/0001_ledger.seeder.ts +12 -0
- package/templates/features/database/seeds/0002_production_audit.seeder.ts +13 -0
- package/templates/features/docs/docs.demo.ts +130 -0
- package/templates/features/docs/docs.module.ts +9 -0
- package/templates/features/guards/auth.guard.ts +66 -0
- package/templates/features/guards/guards.demo.ts +75 -0
- package/templates/features/guards/guards.module.ts +16 -0
- package/templates/features/guards/reports.controller.ts +60 -0
- package/templates/features/guards/reports.service.ts +22 -0
- package/templates/features/health/health.controller.ts +65 -0
- package/templates/features/health/health.module.ts +5 -0
- package/templates/features/http/http.demo.ts +115 -0
- package/templates/features/http/http.module.ts +10 -0
- package/templates/features/http/request-log.ts +37 -0
- package/templates/features/jobs/jobs.controller.ts +102 -0
- package/templates/features/jobs/jobs.module.ts +35 -0
- package/templates/features/jobs/thumbnail.jobs.ts +53 -0
- package/templates/features/notes/notes.controller.ts +65 -0
- package/templates/features/notes/notes.module.ts +9 -0
- package/templates/features/notes/notes.service.ts +21 -0
- package/templates/features/pictures/images.controller.ts +74 -0
- package/templates/features/pictures/pictures.module.ts +22 -0
- package/templates/features/pictures/thumbnails.service.ts +108 -0
- package/templates/features/storage/files.controller.ts +130 -0
- package/templates/features/storage/storage.module.ts +21 -0
- package/templates/features/storage/uploads.service.ts +66 -0
- package/templates/features/storage/workspace.ts +33 -0
- package/templates/features/users/users.controller.ts +47 -0
- package/templates/features/users/users.demo.ts +62 -0
- package/templates/features/users/users.module.ts +11 -0
- package/templates/features/users/users.repository.ts +59 -0
- package/templates/features/users/users.schemas.ts +68 -0
- package/templates/features/users/users.service.ts +46 -0
- package/templates/minimal/_bunfig.toml +7 -0
- package/dist/chunk-rnjjb0bq.js +0 -69
- package/dist/chunk-rnjjb0bq.js.map +0 -10
- /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,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,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 {}
|