@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.
@@ -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: ["CORS_ORIGIN: z.string().default('https://example.com'),"],
30
- field: "readonly corsOrigin: string;",
31
- map: "corsOrigin: value.CORS_ORIGIN,",
32
- env: [{ name: "CORS_ORIGIN", value: "https://example.com" }]
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
@@ -9,7 +9,7 @@ import {
9
9
  packageVersion,
10
10
  resolveFeatures,
11
11
  scaffold
12
- } from "./chunk-5tn3ybzv.js";
12
+ } from "./chunk-tn4dnvcw.js";
13
13
 
14
14
  // src/cli.ts
15
15
  import { parseArgs } from "util";
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  featureNames,
9
9
  isValidPackageName,
10
10
  scaffold
11
- } from "./chunk-5tn3ybzv.js";
11
+ } from "./chunk-tn4dnvcw.js";
12
12
  export {
13
13
  FEATURES,
14
14
  ScaffoldError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dunx/create-app",
3
- "version": "3.0.5",
3
+ "version": "3.1.1",
4
4
  "description": "Scaffold a new dunx application - bunx @dunx/create-app my-api",
5
5
  "keywords": [
6
6
  "bun",
@@ -1,9 +1,21 @@
1
1
  import { Module } from '@dunx/core';
2
- import { RedisConnection, RedisModule } from '@dunx/infra/redis';
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 {}
@@ -0,0 +1,4 @@
1
+ import { Redis } from '@dunx/infra/redis';
2
+
3
+ /** The session store, on database 1 so a cache flush cannot sign users out. */
4
+ export class SessionsRedis extends Redis {}
@@ -1,16 +1,15 @@
1
1
  import { Logger } from '@dunx/core';
2
- import {
3
- isConnectionError,
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
- private readonly redis: RedisConnection,
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 { CompressionModule } from '@dunx/http';
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,
@@ -0,0 +1,4 @@
1
+ import { HttpService } from '@dunx/http/client';
2
+
3
+ /** A probe client with its own short timeout, taken as a parameter. */
4
+ export class HealthClient extends HttpService {}
@@ -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
- return this.db.insert(users).values({ name }).returning().get();
54
+ try {
55
+ return this.db.insert(users).values({ name }).returning().get();
56
+ } catch (error) {
57
+ throw toDatabaseError(error);
58
+ }
49
59
  }
50
60
  }