@dunx/create-app 3.0.5 → 3.1.1
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-5tn3ybzv.js → chunk-tn4dnvcw.js} +24 -8
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/features/cache/cache.module.ts +31 -2
- package/templates/features/cache/sessions.redis.ts +4 -0
- package/templates/features/cache/sessions.service.ts +5 -6
- package/templates/features/http/http-options.ts +73 -0
- package/templates/features/http/http.module.ts +28 -2
- package/templates/features/upstream/health.client.ts +4 -0
- package/templates/features/upstream/upstream.demo.ts +10 -0
- package/templates/features/upstream/upstream.module.ts +29 -1
- package/templates/features/users/users.repository.ts +12 -2
|
@@ -19,17 +19,33 @@ var CONFIG_GROUPS = Object.freeze({
|
|
|
19
19
|
schema: [
|
|
20
20
|
"LOG_LEVEL: z.enum(LogLevel).default(LogLevel.INFO),",
|
|
21
21
|
"/** Unset means console only. Set it to also append JSON to a rotating file. */",
|
|
22
|
-
"LOG_FILE: z.string().optional(),"
|
|
22
|
+
"LOG_FILE: z.string().optional(),",
|
|
23
|
+
"/** Both cost a `req.clone().text()` on the hot path, so off in production. */",
|
|
24
|
+
"LOG_REQUEST_BODY: z.stringbool().default(false),",
|
|
25
|
+
"LOG_RESPONSE_BODY: z.stringbool().default(false),"
|
|
23
26
|
],
|
|
24
|
-
field: "readonly log: { readonly level: LogLevel; readonly file: string | undefined };",
|
|
25
|
-
map: "log: { level: value.LOG_LEVEL, file: value.LOG_FILE },",
|
|
27
|
+
field: "readonly log: { readonly level: LogLevel; readonly file: string | undefined; " + "readonly requestBody: boolean; readonly responseBody: boolean };",
|
|
28
|
+
map: "log: { level: value.LOG_LEVEL, file: value.LOG_FILE, " + "requestBody: value.LOG_REQUEST_BODY, responseBody: value.LOG_RESPONSE_BODY },",
|
|
26
29
|
env: [{ name: "LOG_LEVEL", value: "info" }]
|
|
27
30
|
},
|
|
28
31
|
corsOrigin: {
|
|
29
|
-
schema: [
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
schema: [
|
|
33
|
+
"CORS_ORIGIN: z.string().default('https://example.com'),",
|
|
34
|
+
"/**",
|
|
35
|
+
" * Whether `x-forwarded-for` is believed. Off unless a trusted proxy is in",
|
|
36
|
+
" * front: with nothing stripping the header, any caller picks its own",
|
|
37
|
+
" * address, which fakes both rate limiting and the logged client address.",
|
|
38
|
+
" */",
|
|
39
|
+
"TRUST_PROXY: z.stringbool().default(false),"
|
|
40
|
+
],
|
|
41
|
+
field: `readonly corsOrigin: string;
|
|
42
|
+
readonly trustProxy: boolean;`,
|
|
43
|
+
map: `corsOrigin: value.CORS_ORIGIN,
|
|
44
|
+
trustProxy: value.TRUST_PROXY,`,
|
|
45
|
+
env: [
|
|
46
|
+
{ name: "CORS_ORIGIN", value: "https://example.com" },
|
|
47
|
+
{ name: "TRUST_PROXY", value: "false" }
|
|
48
|
+
]
|
|
33
49
|
},
|
|
34
50
|
database: {
|
|
35
51
|
schema: [
|
|
@@ -142,7 +158,7 @@ var FEATURES = [
|
|
|
142
158
|
requires: [],
|
|
143
159
|
module: { klass: "HttpModule", from: "./http/http.module.js" },
|
|
144
160
|
dependencies: [],
|
|
145
|
-
config: ["corsOrigin"]
|
|
161
|
+
config: ["corsOrigin", "redis"]
|
|
146
162
|
},
|
|
147
163
|
{
|
|
148
164
|
name: "guards",
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
import { Module } from '@dunx/core';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
defaultRedisUrl,
|
|
4
|
+
RedisConnection,
|
|
5
|
+
RedisModule,
|
|
6
|
+
} from '@dunx/infra/redis';
|
|
3
7
|
import { AppConfigService } from '../config.js';
|
|
4
8
|
import { CacheController } from './cache.controller.js';
|
|
9
|
+
import { SessionsRedis } from './sessions.redis.js';
|
|
5
10
|
import { Sessions } from './sessions.service.js';
|
|
6
11
|
|
|
12
|
+
/** The configured server, database 1. A path already on the url is replaced. */
|
|
13
|
+
const sessionsUrl = (url: string | undefined): string => {
|
|
14
|
+
const parsed = new URL(url ?? defaultRedisUrl());
|
|
15
|
+
parsed.pathname = '/1';
|
|
16
|
+
return parsed.href;
|
|
17
|
+
};
|
|
18
|
+
|
|
7
19
|
@Module({
|
|
8
20
|
imports: [
|
|
9
21
|
// No url, so Bun resolves $VALKEY_URL, $REDIS_URL, then localhost.
|
|
@@ -24,10 +36,27 @@ import { Sessions } from './sessions.service.js';
|
|
|
24
36
|
},
|
|
25
37
|
inject: [AppConfigService] as const,
|
|
26
38
|
}),
|
|
39
|
+
/**
|
|
40
|
+
* A subclass rather than a name, so `SessionsRedis` is an ordinary
|
|
41
|
+
* constructor parameter, and it does not claim `RedisConnection`. Database 1:
|
|
42
|
+
* separate clients do not isolate what a `FLUSHDB` reaches, so a shared
|
|
43
|
+
* database would mean flushing the cache signed every user out.
|
|
44
|
+
*/
|
|
45
|
+
RedisModule.forRootAsync(
|
|
46
|
+
{
|
|
47
|
+
useFactory: (config: AppConfigService) => ({
|
|
48
|
+
url: sessionsUrl(config.get('redis').url),
|
|
49
|
+
connectionTimeout: 500,
|
|
50
|
+
maxRetries: 0,
|
|
51
|
+
}),
|
|
52
|
+
inject: [AppConfigService] as const,
|
|
53
|
+
},
|
|
54
|
+
SessionsRedis,
|
|
55
|
+
),
|
|
27
56
|
],
|
|
28
57
|
controllers: [CacheController],
|
|
29
58
|
providers: [Sessions],
|
|
30
59
|
// Re-exported so the chat gateway fans out through the same connection.
|
|
31
|
-
exports: [RedisConnection, Sessions],
|
|
60
|
+
exports: [RedisConnection, SessionsRedis, Sessions],
|
|
32
61
|
})
|
|
33
62
|
export class CacheModule {}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import { Logger } from '@dunx/core';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
RedisConnection,
|
|
5
|
-
RedisOptions,
|
|
6
|
-
} from '@dunx/infra/redis';
|
|
2
|
+
import { isConnectionError, RedisOptions } from '@dunx/infra/redis';
|
|
3
|
+
import { SessionsRedis } from './sessions.redis.js';
|
|
7
4
|
|
|
8
5
|
export class Sessions {
|
|
9
6
|
// Namespaced by pid so one run cannot read another run's keys.
|
|
10
7
|
readonly #prefix = `dunx-full:${process.pid}`;
|
|
11
8
|
|
|
12
9
|
constructor(
|
|
13
|
-
|
|
10
|
+
// The subclass, not `RedisConnection`: a named connection is a parameter
|
|
11
|
+
// type now, so this needs no `inject()` in a field.
|
|
12
|
+
private readonly redis: SessionsRedis,
|
|
14
13
|
private readonly options: RedisOptions,
|
|
15
14
|
private readonly logger: Logger,
|
|
16
15
|
) {}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HttpOptionsProvider,
|
|
3
|
+
RedisRelay,
|
|
4
|
+
type CorsOptions,
|
|
5
|
+
type PubSubRelay,
|
|
6
|
+
type RequestLoggingOptions,
|
|
7
|
+
} from '@dunx/http';
|
|
8
|
+
import { AppConfigService, RELAY_CHANNEL } from '../config.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The HTTP settings that come from validated config, answered from the container
|
|
12
|
+
* rather than computed before it exists.
|
|
13
|
+
*
|
|
14
|
+
* `main.ts` used to open with `const log = validate(Bun.env).log`, because
|
|
15
|
+
* `HttpFactory.create(root, options)` builds the container and so its argument
|
|
16
|
+
* has to be ready first. That was a second call to `validate` on a second copy of
|
|
17
|
+
* the environment, invisible to `ConfigModule`. This class injects
|
|
18
|
+
* `AppConfigService` like anything else.
|
|
19
|
+
*
|
|
20
|
+
* What stays an argument to `create()`: `websocket`, `relay` and `relayChannel`,
|
|
21
|
+
* which are constructed objects rather than settings read from the environment.
|
|
22
|
+
*/
|
|
23
|
+
export class AppHttpOptions extends HttpOptionsProvider {
|
|
24
|
+
constructor(
|
|
25
|
+
private readonly config: AppConfigService,
|
|
26
|
+
private readonly bus: RedisRelay,
|
|
27
|
+
) {
|
|
28
|
+
super();
|
|
29
|
+
this.trustProxy = this.config.get('trustProxy');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A field on the base, so a field here (TS2611 rejects an accessor), assigned in
|
|
34
|
+
* the constructor, which is how a field derives from config. Defaults to
|
|
35
|
+
* **false**: believing `x-forwarded-for` with no proxy stripping it lets any
|
|
36
|
+
* caller pick its own address, faking the throttle subject and the logged one.
|
|
37
|
+
*/
|
|
38
|
+
override readonly trustProxy: boolean;
|
|
39
|
+
|
|
40
|
+
override get prefix(): string {
|
|
41
|
+
return 'api';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override get cors(): CorsOptions {
|
|
45
|
+
return {
|
|
46
|
+
origin: this.config.get('corsOrigin'),
|
|
47
|
+
credentials: true,
|
|
48
|
+
exposedHeaders: ['x-handled-by'],
|
|
49
|
+
maxAge: 600,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Multi-node websocket fan-out, resolved rather than constructed. */
|
|
54
|
+
override get relay(): PubSubRelay {
|
|
55
|
+
return this.bus;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
override readonly relayChannel = RELAY_CHANNEL;
|
|
59
|
+
|
|
60
|
+
override get requestLogging(): RequestLoggingOptions {
|
|
61
|
+
const log = this.config.get('log');
|
|
62
|
+
return {
|
|
63
|
+
// Off by default: both cost a `req.clone().text()` on the hot path.
|
|
64
|
+
requestBody: log.requestBody,
|
|
65
|
+
responseBody: log.responseBody,
|
|
66
|
+
// The dashboard polls every five seconds and would bury everything else.
|
|
67
|
+
ignorePrefix: ['/api/_dunx'],
|
|
68
|
+
// ~360 ns per request, so off by default. On here so `traceId` joins
|
|
69
|
+
// `requestId` and `@dunx/http/client` forwards it upstream.
|
|
70
|
+
trace: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
import { Module } from '@dunx/core';
|
|
2
|
-
import {
|
|
1
|
+
import { Module, provide } from '@dunx/core';
|
|
2
|
+
import {
|
|
3
|
+
CompressionModule,
|
|
4
|
+
HttpOptionsProvider,
|
|
5
|
+
WsRelayModule,
|
|
6
|
+
} from '@dunx/http';
|
|
7
|
+
import { AppConfigService } from '../config.js';
|
|
8
|
+
import { AppHttpOptions } from './http-options.js';
|
|
3
9
|
import { CompressionDemo } from './compression.demo.js';
|
|
4
10
|
import { TraceController } from './trace.controller.js';
|
|
5
11
|
import { TraceDemo } from './trace.demo.js';
|
|
@@ -19,9 +25,28 @@ import { RequestTrail, RequestTrailMiddleware } from './request-trail.js';
|
|
|
19
25
|
// body under it is sent as it is, because gzip's header and trailer alone are
|
|
20
26
|
// 18 bytes and a short JSON response comes out larger.
|
|
21
27
|
CompressionModule.forRoot({ threshold: 1024 }),
|
|
28
|
+
/**
|
|
29
|
+
* The relay as a provider, imported here because `AppHttpOptions` is what
|
|
30
|
+
* consumes it. `main.ts` used to build `new RedisRelay(...)` and thread it
|
|
31
|
+
* into `HttpFactory.create`, which was the last hand-built object in the
|
|
32
|
+
* options. The container closes it at shutdown.
|
|
33
|
+
*/
|
|
34
|
+
WsRelayModule.forRootAsync({
|
|
35
|
+
useFactory: (config: AppConfigService) => {
|
|
36
|
+
const { url } = config.get('redis');
|
|
37
|
+
return {
|
|
38
|
+
...(url === undefined ? {} : { url }),
|
|
39
|
+
connectionTimeout: 500,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
inject: [AppConfigService] as const,
|
|
43
|
+
}),
|
|
22
44
|
],
|
|
23
45
|
controllers: [TraceController],
|
|
24
46
|
providers: [
|
|
47
|
+
// The HTTP settings that read from config, resolved after the container
|
|
48
|
+
// exists. `HttpFactory` promotes a default, so binding this replaces it.
|
|
49
|
+
provide(HttpOptionsProvider, { useClass: AppHttpOptions }),
|
|
25
50
|
RequestTrail,
|
|
26
51
|
RequestTrailMiddleware,
|
|
27
52
|
HttpDemo,
|
|
@@ -29,6 +54,7 @@ import { RequestTrail, RequestTrailMiddleware } from './request-trail.js';
|
|
|
29
54
|
TraceDemo,
|
|
30
55
|
],
|
|
31
56
|
exports: [
|
|
57
|
+
HttpOptionsProvider,
|
|
32
58
|
RequestTrail,
|
|
33
59
|
RequestTrailMiddleware,
|
|
34
60
|
HttpDemo,
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FetchTransportError,
|
|
5
5
|
HttpService,
|
|
6
6
|
} from '@dunx/http/client';
|
|
7
|
+
import { HealthClient } from './health.client.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Calling out over `fetch`. Three things a bare `fetch` does not do: retry a 503
|
|
@@ -14,6 +15,10 @@ export class UpstreamDemo {
|
|
|
14
15
|
constructor(
|
|
15
16
|
private readonly logger: Logger,
|
|
16
17
|
private readonly http: HttpService,
|
|
18
|
+
// A named client as a constructor parameter, which is what registering it as
|
|
19
|
+
// a subclass buys: `inject(httpClient('health'))` in a field is the only way
|
|
20
|
+
// to reach one bound to a `Token`.
|
|
21
|
+
private readonly health: HealthClient,
|
|
17
22
|
) {}
|
|
18
23
|
|
|
19
24
|
async demonstrate(url: string): Promise<void> {
|
|
@@ -22,6 +27,11 @@ export class UpstreamDemo {
|
|
|
22
27
|
);
|
|
23
28
|
this.logger.info(`GET api/notes -> ${JSON.stringify(notes)}`);
|
|
24
29
|
|
|
30
|
+
const live = await this.health.get<{ status: string }>(
|
|
31
|
+
new URL('api/health/live', url),
|
|
32
|
+
);
|
|
33
|
+
this.logger.info(`HealthClient -> ${live.status}`);
|
|
34
|
+
|
|
25
35
|
const attempts: string[] = [];
|
|
26
36
|
const recovered = await this.http.get<{ after: number }>(
|
|
27
37
|
new URL('api/upstream/flaky', url),
|
|
@@ -2,6 +2,7 @@ import { Module } from '@dunx/core';
|
|
|
2
2
|
import { HttpModule as HttpClientModule } from '@dunx/http/client';
|
|
3
3
|
import { AppConfigService } from '../config.js';
|
|
4
4
|
import { FlakyController } from './flaky.controller.js';
|
|
5
|
+
import { HealthClient } from './health.client.js';
|
|
5
6
|
import { UpstreamDemo } from './upstream.demo.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -25,9 +26,36 @@ import { UpstreamDemo } from './upstream.demo.js';
|
|
|
25
26
|
}),
|
|
26
27
|
inject: [AppConfigService] as const,
|
|
27
28
|
}),
|
|
29
|
+
/**
|
|
30
|
+
* A second client, bound to a subclass rather than a name, so `HealthClient`
|
|
31
|
+
* is an ordinary constructor parameter. It does not claim `HttpService`, so
|
|
32
|
+
* the default above is untouched.
|
|
33
|
+
*/
|
|
34
|
+
HttpClientModule.forRootAsync(
|
|
35
|
+
{
|
|
36
|
+
useFactory: (config: AppConfigService) => ({
|
|
37
|
+
...config.get('upstream'),
|
|
38
|
+
// A readiness probe waits far less than a business call.
|
|
39
|
+
timeoutMs: 1_000,
|
|
40
|
+
/**
|
|
41
|
+
* Bun-only, passed straight to `fetch`. A probe follows nothing: a
|
|
42
|
+
* redirect from a health endpoint is a failure, not a hop to chase.
|
|
43
|
+
*
|
|
44
|
+
* `protocol: 'http2'` is the other option worth knowing about and is
|
|
45
|
+
* **not** set here, because this app calls itself over cleartext HTTP
|
|
46
|
+
* and Bun raises `HTTP2Unsupported` rather than falling back
|
|
47
|
+
* (docs/bun-apis.md). Set it against an HTTPS upstream that offers h2.
|
|
48
|
+
*/
|
|
49
|
+
maxRedirects: 0,
|
|
50
|
+
headers: { 'user-agent': `${config.get('appName')}/health` },
|
|
51
|
+
}),
|
|
52
|
+
inject: [AppConfigService] as const,
|
|
53
|
+
},
|
|
54
|
+
HealthClient,
|
|
55
|
+
),
|
|
28
56
|
],
|
|
29
57
|
controllers: [FlakyController],
|
|
30
58
|
providers: [UpstreamDemo],
|
|
31
|
-
exports: [UpstreamDemo],
|
|
59
|
+
exports: [UpstreamDemo, HealthClient],
|
|
32
60
|
})
|
|
33
61
|
export class UpstreamModule {}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SyncDatabase } from '@dunx/infra/db';
|
|
1
|
+
import { SyncDatabase, toDatabaseError } from '@dunx/infra/db';
|
|
2
2
|
import { eq, like, sql } from 'drizzle-orm';
|
|
3
3
|
import * as schema from '../database/schema.js';
|
|
4
4
|
import { users, type User } from '../database/schema.js';
|
|
@@ -44,7 +44,17 @@ export class UsersRepository {
|
|
|
44
44
|
return this.db.select().from(users).where(eq(users.id, id)).get() ?? null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* `name` is UNIQUE, so a repeat is a conflict rather than a server fault.
|
|
49
|
+
* `toDatabaseError` turns the driver's `SQLITE_CONSTRAINT_UNIQUE` into a
|
|
50
|
+
* `ConstraintError` carrying 409, and `@dunx/http` reads the status off it -
|
|
51
|
+
* no error filter, and nothing here knows what a Response is.
|
|
52
|
+
*/
|
|
47
53
|
async create(name: string): Promise<User> {
|
|
48
|
-
|
|
54
|
+
try {
|
|
55
|
+
return this.db.insert(users).values({ name }).returning().get();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw toDatabaseError(error);
|
|
58
|
+
}
|
|
49
59
|
}
|
|
50
60
|
}
|