@gaonjs/config 0.13.0 → 0.14.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/testkit.js CHANGED
@@ -13,8 +13,8 @@ import { loadGaonConfig } from './load.js';
13
13
  import { configureNonDbBatteries } from './wire.js';
14
14
  import { resetStorage } from '@gaonjs/storage';
15
15
  import { resetI18n } from '@gaonjs/i18n';
16
- import { connectNats, configureJobs, configureEvents, configureBroadcast, resetJobs, resetEvents, resetBroadcast, } from '@gaonjs/async';
17
- import { createDb, registerConnection, deriveTestDatabaseConfig, destroyAllConnections, } from '@gaonjs/data';
16
+ import { connectNats, configureJobs, configureEvents, configureBroadcast, resetJobs, resetEvents, resetBroadcast, outboxServiceTxWrapper, ensureOutboxTable, } from '@gaonjs/async';
17
+ import { createDb, registerConnection, deriveTestDatabaseConfig, destroyAllConnections, setServiceTxWrapper, getConnection, hasConnection, } from '@gaonjs/data';
18
18
  /**
19
19
  * 테스트용 DB 커넥션 + DB 외 배터리(mail·storage·i18n)를 배선한다(결정 111·136).
20
20
  * 각 커넥션을 `<db>_test` 로 파생해 poolMax:1 로 붙이고, 스토리지·메일을 테스트
@@ -44,6 +44,13 @@ export async function connectTestDatabase(cwd = process.cwd()) {
44
44
  configureJobs(nats);
45
45
  configureEvents(nats);
46
46
  configureBroadcast(nats); // 결정 126: 잡·리스너에서 broadcast() 사용.
47
+ // 결정 144(테스트 짝): 서비스 트랜잭션 emit 을 아웃박스로 스테이징하는 래퍼를
48
+ // serve·work 의 wireGaon 과 같이 배선한다. 이게 없으면 테스트의 service() 가
49
+ // currentOutbox() 없이 즉시발행 경로를 타, "롤백 시 미발행" 보장이 표준 하네스에서
50
+ // 공허하게 통과했다(13차 프로브 실측 · 결정 143 은 넣었는데 144 는 빠진 비대칭).
51
+ setServiceTxWrapper(outboxServiceTxWrapper());
52
+ if (hasConnection('main'))
53
+ await ensureOutboxTable(getConnection('main'));
47
54
  }
48
55
  return {
49
56
  close: async () => {
@@ -54,6 +61,8 @@ export async function connectTestDatabase(cwd = process.cwd()) {
54
61
  resetJobs();
55
62
  resetEvents();
56
63
  resetBroadcast();
64
+ // 결정 144: 서비스 트랜잭션 래퍼 해제(다음 테스트 파일로 누출 방지 · wireGaon close 대칭).
65
+ setServiceTxWrapper(undefined);
57
66
  if (nats)
58
67
  await nats.close();
59
68
  },
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ConnectionConfig } from '@gaonjs/data';
2
2
  import type { SecurityOptions, ClientIpConfig } from '@gaonjs/web';
3
- import type { AuthOptions, JwtAuthOptions } from '@gaonjs/web';
3
+ import type { AuthOptions, JwtAuthOptions, SharedPropsFn } from '@gaonjs/web';
4
4
  /**
5
5
  * DB 커넥션 맵 — 키는 커넥션 이름(§4.5), 값은 어댑터 설정. `main` 은 관례
6
6
  * (스키마가 `{ db: 'legacy' }` 처럼 명시하지 않으면 main 으로 붙는다).
@@ -56,7 +56,7 @@ export interface StorageConfig {
56
56
  readonly default: string;
57
57
  readonly disks: Record<string, StorageDiskConfig>;
58
58
  }
59
- /** i18n — locales/ 로드 옵션. */
59
+ /** i18n — locales/ 로드 + 요청별 로케일 협상 옵션(결정 159 · 13차 W1). */
60
60
  export interface I18nConfig {
61
61
  /** locales/ 디렉토리 경로 (cwd 기준 상대). 생략 시 'locales'. */
62
62
  readonly dir?: string;
@@ -64,6 +64,16 @@ export interface I18nConfig {
64
64
  readonly fallbackLng: string;
65
65
  /** 허용 언어(생략 시 locales 폴더 하위 언어들). */
66
66
  readonly supportedLngs?: readonly string[];
67
+ /**
68
+ * 요청별 로케일 감지(결정 159 · W1). i18n 설정이 있으면 `gaon serve`/`gaon dev` 가
69
+ * 매 요청 자동으로 detectLanguage → enterLanguage 를 배선한다(자동 · opt-in 아님).
70
+ */
71
+ readonly detect?: {
72
+ /** 로케일 쿠키 이름. 생략 시 'gaon_locale'. `this.setLocale()` 이 이 쿠키를 쓴다. */
73
+ readonly cookieName?: string;
74
+ /** 출처 우선순위. 생략 시 ['session','cookie','header']. */
75
+ readonly priority?: readonly ('session' | 'cookie' | 'header')[];
76
+ };
67
77
  }
68
78
  /** 웹 서버 부팅 옵션 — `gaon serve` 가 소비. */
69
79
  export interface WebConfig {
@@ -159,6 +169,13 @@ export interface AppConfig {
159
169
  readonly seal?: boolean | {
160
170
  readonly except?: readonly string[];
161
171
  };
172
+ /**
173
+ * 결정 150: 앱 전역 공유 prop 제공자. 요청마다 실행돼 반환 객체가 이 앱의 모든 렌더
174
+ * shared 에 병합된다 — 코어 3종(currentUser·csrf·flash)은 예약이라 덮지 못한다.
175
+ * 페이지는 `useShared()` 로 읽는다(gaonjs/vue GaonSharedProps 선언 병합으로 타입 확장):
176
+ * sharedProps: (ctx) => ({ locale: ctx.session?.locale ?? 'en' })
177
+ */
178
+ readonly sharedProps?: SharedPropsFn;
162
179
  }
163
180
  /** 타입 안전 헬퍼 — `export default defineAppConfig({...})`. */
164
181
  export declare function defineAppConfig(config: AppConfig): AppConfig;
package/dist/wire.js CHANGED
@@ -7,11 +7,11 @@
7
7
  // 세션은 세션 옵션이 세팅된 앱에만 등록된다. AppSessionOptions.secret 은
8
8
  // 필수이므로 auth 앱은 명시적으로 secret 을 제공해야 한다(env 로 주입 권장).
9
9
  import { Redis } from 'ioredis';
10
- import { createDb, registerConnection, destroyAllConnections, getConnection, hasConnection, setServiceTxWrapper, } from '@gaonjs/data';
11
- import { connectNats, configureJobs, configureEvents, outboxServiceTxWrapper, ensureOutboxTable, } from '@gaonjs/async';
10
+ import { createDb, registerConnection, destroyAllConnections, getConnection, hasConnection, setServiceTxWrapper, setCacheBackend, redisCacheBackend, } from '@gaonjs/data';
11
+ import { connectNats, configureJobs, configureEvents, outboxServiceTxWrapper, ensureOutboxTable, setLockBackend, redisLockBackend, memoryLockBackend, } from '@gaonjs/async';
12
12
  import { configureMailer, smtpTransport } from '@gaonjs/mail';
13
13
  import { configureStorage, localDisk, s3Disk, resetStorage } from '@gaonjs/storage';
14
- import { configureI18n, loadLocales, resetI18n } from '@gaonjs/i18n';
14
+ import { configureI18n, loadLocales, resetI18n, detectLanguage, enterLanguage, languages, } from '@gaonjs/i18n';
15
15
  import { createApp, redisSessionStore } from '@gaonjs/web';
16
16
  import { resolveApps, toAppSpec } from './apps.js';
17
17
  import { existsSync } from 'node:fs';
@@ -125,6 +125,19 @@ export async function wireGaon(config, cwd = process.cwd()) {
125
125
  if (config.redis) {
126
126
  redis = new Redis(config.redis.url, { lazyConnect: false, maxRetriesPerRequest: 3 });
127
127
  }
128
+ // 결정 147: 분산 락 백엔드 배선. redis 가 있으면 진짜 분산 락(Redis SET NX PX),
129
+ // 없으면 dev/테스트용 in-memory 폴백을 심는다. prod 에서 redis 미설정이면 여기서
130
+ // 아무 것도 안 심어 lock() 은 미설정 백엔드로 남아 사용 시 수리 안내로 throw 한다
131
+ // (로컬 뮤텍스로 조용히 떨어지지 않는다 · 결정 88 ①).
132
+ if (redis)
133
+ setLockBackend(redisLockBackend(redis));
134
+ else if (process.env.NODE_ENV !== 'production')
135
+ setLockBackend(memoryLockBackend());
136
+ // 결정 148: 캐시 백엔드 배선. redis 가 있으면 Redis 캐시(§7.4), 없으면 data 의
137
+ // 기본 in-memory 폴백을 그대로 둔다(캐시는 best-effort 라 미설정 폴백이 안전 ·
138
+ // 락과 다른 지점 · 결정 147).
139
+ if (redis)
140
+ setCacheBackend(redisCacheBackend(redis));
128
141
  // 3) NATS — 웹 프로세스도 채널·잡·이벤트 publish 를 위해 연결한다.
129
142
  // 연결만으로는 부족하다 — 전송 런타임까지 배선해야 컨트롤러·서비스의
130
143
  // `.later()`·`emit()` 이 실제로 publish 된다(work 는 runWork 가 이미 설정하지만
@@ -169,6 +182,10 @@ export async function wireGaon(config, cwd = process.cwd()) {
169
182
  if (ac?.auth && typeof ac.auth.loadUser === 'function') {
170
183
  spec = { ...spec, auth: ac.auth };
171
184
  }
185
+ // 결정 150: app.config 의 sharedProps 제공자를 AppSpec 으로 전달한다(앱 전역 공유 prop).
186
+ if (typeof ac?.sharedProps === 'function') {
187
+ spec = { ...spec, sharedProps: ac.sharedProps };
188
+ }
172
189
  if (ac?.session?.secret && redis) {
173
190
  spec = {
174
191
  ...spec,
@@ -183,12 +200,21 @@ export async function wireGaon(config, cwd = process.cwd()) {
183
200
  },
184
201
  };
185
202
  }
203
+ // 결정 159(W1): i18n 설정이 있으면 this.setLocale() 이 쓸 로케일 쿠키 이름을 배선
204
+ // (detectLanguage 가 읽는 이름과 일치 · 기본 gaon_locale).
205
+ if (config.i18n) {
206
+ spec = { ...spec, localeCookie: config.i18n.detect?.cookieName ?? 'gaon_locale' };
207
+ }
186
208
  return spec;
187
209
  });
188
210
  const opts = {
189
211
  apps: specs,
190
212
  version: config.web?.assetsVersion,
191
213
  cookieSecret: config.web?.cookieSecret,
214
+ // 결정 159(W1): i18n 설정이 있으면 매 요청 로케일을 협상해 ALS 에 심는다(자동 배선).
215
+ // createApp 의 onRequest 슬롯이 이 통로다(web↔i18n 결합 회피). 이게 없으면
216
+ // detectLanguage·enterLanguage 가 죽은 코드라 t() 가 항상 fallback 이었다(반쪽 배선).
217
+ onRequest: config.i18n ? makeI18nOnRequest(config.i18n) : undefined,
192
218
  // 보안 기본값 조정(생략 시 코어 기본값 전부 켬 · CLAUDE.md 규칙 8).
193
219
  security: config.web?.security,
194
220
  // 결정 131: 스토리지 오리진 CSP 자동 배선(설정 있으면 img-src·connect-src 에 부착).
@@ -215,12 +241,57 @@ export async function wireGaon(config, cwd = process.cwd()) {
215
241
  await nats.close();
216
242
  if (redis)
217
243
  redis.disconnect();
244
+ // 결정 147: 락 백엔드 배선 해제(다음 wireGaon 인스턴스로 누출 방지).
245
+ setLockBackend(undefined);
246
+ // 결정 148: 캐시 백엔드 배선 해제(메모리 폴백으로 되돌림 · 인스턴스 누출 방지).
247
+ setCacheBackend(undefined);
218
248
  // 결정 144: 서비스 트랜잭션 래퍼 배선 해제(다음 wireGaon 인스턴스로 누출 방지).
219
249
  setServiceTxWrapper(undefined);
220
250
  await domain.closeDomain();
221
251
  },
222
252
  };
223
253
  }
254
+ /**
255
+ * 결정 159(W1): 요청별 로케일 협상 훅을 만든다. 매 요청 header(Accept-Language)·
256
+ * cookie(gaon_locale)·session(가능 시)에서 로케일을 감지해 ALS 에 심는다 → 이후 같은
257
+ * 요청의 모든 t() 가 그 로케일로 번역된다. 세션은 앱 스코프라 이 전역 훅 시점엔 대개
258
+ * 없지만, setLocale 이 쿠키에도 함께 기록하므로 쿠키가 그 선택을 실어 나른다.
259
+ */
260
+ function makeI18nOnRequest(i18n) {
261
+ const cookieName = i18n.detect?.cookieName ?? 'gaon_locale';
262
+ const priority = i18n.detect?.priority;
263
+ return (request) => {
264
+ const supported = languages();
265
+ if (supported.length === 0)
266
+ return; // i18n 미구성 — 폴백만.
267
+ const h = request.headers['accept-language'];
268
+ const header = typeof h === 'string' ? h : undefined;
269
+ const rawCookie = request.headers['cookie'];
270
+ const cookie = readLocaleCookie(typeof rawCookie === 'string' ? rawCookie : undefined, cookieName);
271
+ // 세션은 앱 스코프라 전역 훅 시점엔 대개 없다(있으면 읽고, 없으면 쿠키가 실어 나른다).
272
+ const session = request.session?.locale;
273
+ enterLanguage(detectLanguage({ header, cookie, session }, { supported, fallback: i18n.fallbackLng, priority }));
274
+ };
275
+ }
276
+ /** Cookie 헤더에서 로케일 쿠키 값을 뽑는다(@fastify/cookie 파싱 순서 이전에 도는 훅용). */
277
+ function readLocaleCookie(header, name) {
278
+ if (!header)
279
+ return undefined;
280
+ for (const part of header.split(';')) {
281
+ const eq = part.indexOf('=');
282
+ if (eq < 0)
283
+ continue;
284
+ if (part.slice(0, eq).trim() === name) {
285
+ try {
286
+ return decodeURIComponent(part.slice(eq + 1).trim());
287
+ }
288
+ catch {
289
+ return part.slice(eq + 1).trim();
290
+ }
291
+ }
292
+ }
293
+ return undefined;
294
+ }
224
295
  // redisSessionStore 는 참조만 유지(트리 셰이킹 방지 · 향후 캐시 재사용).
225
296
  void redisSessionStore;
226
297
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/config",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Gaon 루트 설정 로더·자동 배선 (gaon.config.ts · §3.3 · M9-A)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,13 +25,13 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "ioredis": "^5.4.1",
28
- "@gaonjs/async": "0.9.0",
29
28
  "@gaonjs/core": "0.2.1",
30
- "@gaonjs/i18n": "0.1.2",
31
- "@gaonjs/data": "0.15.0",
32
- "@gaonjs/mail": "0.1.3",
29
+ "@gaonjs/data": "0.16.0",
30
+ "@gaonjs/async": "0.10.0",
31
+ "@gaonjs/i18n": "0.2.0",
32
+ "@gaonjs/mail": "0.2.0",
33
33
  "@gaonjs/storage": "0.1.2",
34
- "@gaonjs/web": "0.15.0"
34
+ "@gaonjs/web": "0.16.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "fastify": "^5.0.0"