@gaonjs/web 0.2.2 → 0.5.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/app.d.ts +20 -1
- package/dist/app.js +45 -1
- package/dist/auth.d.ts +1 -1
- package/dist/controller.d.ts +95 -4
- package/dist/controller.js +65 -0
- package/dist/dispatch.js +106 -9
- package/dist/host-router.d.ts +20 -0
- package/dist/host-router.js +114 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -1
- package/dist/params.d.ts +29 -0
- package/dist/params.js +179 -0
- package/package.json +9 -8
package/dist/app.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FastifyInstance, type FastifyServerOptions } from 'fastify';
|
|
1
|
+
import { type FastifyInstance, type FastifyRequest, type FastifyServerOptions } from 'fastify';
|
|
2
2
|
import { type FastifyCorsOptions } from '@fastify/cors';
|
|
3
3
|
import { type GaonNats } from '@gaonjs/async';
|
|
4
4
|
import type { RouteDef } from './routes.js';
|
|
@@ -27,6 +27,12 @@ export interface AppSpec {
|
|
|
27
27
|
* 이름별로 모은 맵. realtime(NATS)이 설정된 경우에만 활성.
|
|
28
28
|
*/
|
|
29
29
|
readonly channels?: ChannelMap;
|
|
30
|
+
/**
|
|
31
|
+
* app.config.ts 의 `hosts:` — 이 앱이 응답할 도메인(v0.16 §3.3).
|
|
32
|
+
* 생략해도 `<앱이름>.localhost` 관례는 자동 성립한다(host-router).
|
|
33
|
+
* 지정 시 그 도메인들도 이 앱으로 라우팅된다.
|
|
34
|
+
*/
|
|
35
|
+
readonly hosts?: readonly string[];
|
|
30
36
|
}
|
|
31
37
|
export interface SecurityOptions {
|
|
32
38
|
/** CORS 설정. 기본은 same-origin 만 허용(origin:false). false 로 완전 비활성. */
|
|
@@ -44,6 +50,19 @@ export interface CreateAppOptions {
|
|
|
44
50
|
/** 서명 쿠키·CSRF 용 비밀. 생략 시 env GAON_COOKIE_SECRET. */
|
|
45
51
|
readonly cookieSecret?: string;
|
|
46
52
|
readonly security?: SecurityOptions;
|
|
53
|
+
/**
|
|
54
|
+
* 업로드(multipart) 한도(§7 — this.params()/this.file() 처리). 생략 시
|
|
55
|
+
* 파일당 10MB. false 로 multipart 파싱 비활성.
|
|
56
|
+
*/
|
|
57
|
+
readonly uploads?: {
|
|
58
|
+
readonly maxFileSize?: number;
|
|
59
|
+
readonly maxFiles?: number;
|
|
60
|
+
} | false;
|
|
61
|
+
/**
|
|
62
|
+
* 요청별 훅 — onRequest 시점에 매 요청 실행된다. i18n 언어 감지처럼
|
|
63
|
+
* 요청 컨텍스트(ALS)를 심어야 하는 배터리를 web 결합 없이 배선하는 통로다.
|
|
64
|
+
*/
|
|
65
|
+
readonly onRequest?: (request: FastifyRequest) => void | Promise<void>;
|
|
47
66
|
readonly fastify?: FastifyServerOptions;
|
|
48
67
|
/**
|
|
49
68
|
* 실시간 백본(§7 · M6). 설정하면 채널(ws)이 활성화된다. nats 커넥션은
|
package/dist/app.js
CHANGED
|
@@ -10,10 +10,13 @@ import Fastify from 'fastify';
|
|
|
10
10
|
import cookie from '@fastify/cookie';
|
|
11
11
|
import cors from '@fastify/cors';
|
|
12
12
|
import rateLimit from '@fastify/rate-limit';
|
|
13
|
+
import multipart from '@fastify/multipart';
|
|
13
14
|
import { hostname } from 'node:os';
|
|
14
15
|
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { enterLogContext, rootLogger, setRootLogger } from '@gaonjs/core';
|
|
15
17
|
import { createChannelRuntime } from '@gaonjs/async';
|
|
16
18
|
import { prefixFor, registerApp } from './dispatch.js';
|
|
19
|
+
import { resolveHostRewrite } from './host-router.js';
|
|
17
20
|
import { registerSession } from './session.js';
|
|
18
21
|
import { jwtAuthAugment, sessionAuthAugment } from './auth.js';
|
|
19
22
|
import { registerChannels, registerWebSocketSupport, } from './websocket.js';
|
|
@@ -23,7 +26,40 @@ import { verifyToken } from './jwt.js';
|
|
|
23
26
|
* 켜고, 각 앱의 라우트를 프리픽스 아래 등록한다. listen 은 호출자가 한다.
|
|
24
27
|
*/
|
|
25
28
|
export async function createApp(opts) {
|
|
26
|
-
|
|
29
|
+
// 로거를 하나로 통일한다(§7 구조화 로깅) — Fastify 의 요청 자동 로그와
|
|
30
|
+
// 어디서나 쓰는 전역 log.* 가 같은 pino 인스턴스를 공유한다. Fastify 가
|
|
31
|
+
// 요청 ID(req.id)를 관리하고, onRequest 훅이 그 ID 를 전역 log 컨텍스트에
|
|
32
|
+
// 심어(enterLogContext) 요청 처리 중 모든 log.* 에 자동 전파한다.
|
|
33
|
+
const logger = rootLogger();
|
|
34
|
+
setRootLogger(logger);
|
|
35
|
+
// <앱>.localhost 매핑 준비 — 앱별 프리픽스·hosts 를 한 표로 만든다(v0.16 §3.3).
|
|
36
|
+
// rewriteUrl 은 라우트 해석 이전에 실행돼 URL 을 다시 쓰면 그대로 라우터가
|
|
37
|
+
// 새 URL 을 매칭한다(Fastify 문서 · rewriteUrl 옵션).
|
|
38
|
+
const hostBindings = opts.apps.map((s) => ({
|
|
39
|
+
name: s.name,
|
|
40
|
+
prefix: s.prefix ?? prefixFor(s.name),
|
|
41
|
+
hosts: (s.hosts ?? []).map((h) => h.toLowerCase()),
|
|
42
|
+
}));
|
|
43
|
+
const app = Fastify({
|
|
44
|
+
loggerInstance: logger,
|
|
45
|
+
// 분산 추적: 클라이언트가 x-request-id 를 주면 이어받는다.
|
|
46
|
+
requestIdHeader: 'x-request-id',
|
|
47
|
+
// <앱>.localhost / app.config.ts hosts: → URL 을 앱 프리픽스로 재작성.
|
|
48
|
+
// 관례에 없는 host 는 그대로 통과(원 프리픽스 접근이 계속 동작).
|
|
49
|
+
rewriteUrl: (req) => {
|
|
50
|
+
const rawUrl = req.url ?? '/';
|
|
51
|
+
const rewritten = resolveHostRewrite({ host: req.headers?.host, url: rawUrl }, hostBindings);
|
|
52
|
+
return rewritten ?? rawUrl;
|
|
53
|
+
},
|
|
54
|
+
...opts.fastify,
|
|
55
|
+
});
|
|
56
|
+
app.addHook('onRequest', async (request) => {
|
|
57
|
+
enterLogContext({ requestId: request.id });
|
|
58
|
+
// 요청별 훅(예: i18n 언어 감지 → enterLanguage). web 은 i18n 을 모르고,
|
|
59
|
+
// 부트스트랩이 이 콜백으로 언어 컨텍스트를 심어 결합을 피한다.
|
|
60
|
+
if (opts.onRequest)
|
|
61
|
+
await opts.onRequest(request);
|
|
62
|
+
});
|
|
27
63
|
const security = opts.security ?? {};
|
|
28
64
|
// 실시간 채널 런타임 — 프로세스당 하나(전 앱 공유, NATS 는 호출자 소유).
|
|
29
65
|
let runtime;
|
|
@@ -52,6 +88,14 @@ export async function createApp(opts) {
|
|
|
52
88
|
const rl = security.rateLimit ?? {};
|
|
53
89
|
await app.register(rateLimit, { max: rl.max ?? 100, timeWindow: rl.timeWindow ?? '1 minute' });
|
|
54
90
|
}
|
|
91
|
+
// 업로드(multipart) — this.params()/this.file() 이 업로드를 처리한다(§7).
|
|
92
|
+
// parts() 스트리밍을 쓰므로 attachFieldsToBody 는 끈다(디스패처가 파싱).
|
|
93
|
+
if (opts.uploads !== false) {
|
|
94
|
+
const up = opts.uploads ?? {};
|
|
95
|
+
await app.register(multipart, {
|
|
96
|
+
limits: { fileSize: up.maxFileSize ?? 10 * 1024 * 1024, files: up.maxFiles ?? 10 },
|
|
97
|
+
});
|
|
98
|
+
}
|
|
55
99
|
// 앱마다 캡슐화 스코프에 등록한다 — 세션·CSRF·라우트가 그 앱 프리픽스에
|
|
56
100
|
// 갇혀 앱별 완전 분리가 성립한다(질문 13). 프리픽스는 스코프가 건다.
|
|
57
101
|
for (const spec of opts.apps) {
|
package/dist/auth.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export declare function isUnauthorized(e: unknown): boolean;
|
|
|
7
7
|
export interface AuthOptions {
|
|
8
8
|
/**
|
|
9
9
|
* session.userId → 사용자 로드. 앱이 도메인 모델로 구현한다(예:
|
|
10
|
-
* (id) => User.
|
|
10
|
+
* (id) => User.where('id','=',BigInt(id)).first()). falsy 반환은
|
|
11
11
|
* 비로그인 취급. web 이 data 를 직접 import 하지 않게 하는 이음새다.
|
|
12
12
|
*/
|
|
13
13
|
loadUser(id: unknown): Promise<GaonCurrentUser | null | undefined> | GaonCurrentUser | null | undefined;
|
package/dist/controller.d.ts
CHANGED
|
@@ -17,7 +17,51 @@ export interface JsonResult {
|
|
|
17
17
|
readonly json: unknown;
|
|
18
18
|
readonly status?: number;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* 업로드된 파일 한 개(multipart). 본문은 버퍼로 미리 읽혀 있어, 스토리지에
|
|
22
|
+
* 그대로 저장한다(§7 — this.params() 가 업로드도 처리):
|
|
23
|
+
* const f = this.file('avatar')
|
|
24
|
+
* if (f) await Storage.put(`avatars/${user.id}.png`, f.buffer, { contentType: f.mimetype })
|
|
25
|
+
*/
|
|
26
|
+
export interface UploadedFile {
|
|
27
|
+
/** 폼 필드명. */
|
|
28
|
+
readonly field: string;
|
|
29
|
+
/** 원본 파일명. */
|
|
30
|
+
readonly filename: string;
|
|
31
|
+
/** MIME 타입. */
|
|
32
|
+
readonly mimetype: string;
|
|
33
|
+
/** 파일 본문(스토리지에 그대로 넣을 수 있다). */
|
|
34
|
+
readonly buffer: Buffer;
|
|
35
|
+
/** 바이트 크기. */
|
|
36
|
+
readonly size: number;
|
|
37
|
+
}
|
|
20
38
|
export type ActionResult<P = unknown> = Rendered<P> | Redirect | JsonResult | void;
|
|
39
|
+
/**
|
|
40
|
+
* 검증 실패(스키마 오류) — this.params/body/query 가 던진다. 디스패처가 잡아
|
|
41
|
+
* 422 JSON 응답으로 자동 변환한다(§7.5.3 — 에러가 곧 수리 안내서). 어느 키가
|
|
42
|
+
* 어느 출처(route/body/query)에서 왔고 왜 거부됐는지, 스키마 어느 파일을
|
|
43
|
+
* 어떻게 고쳐야 하는지까지 메시지에 담는다.
|
|
44
|
+
*/
|
|
45
|
+
export declare class ValidationError extends Error {
|
|
46
|
+
readonly status: 422;
|
|
47
|
+
readonly issues: readonly ValidationIssue[];
|
|
48
|
+
constructor(message: string, issues: readonly ValidationIssue[]);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* this.notFound() 가 던지는 404 신호를 만든다. requireAuth 의 unauthorizedError
|
|
52
|
+
* 와 같은 방식(마커 프로퍼티) — 디스패처가 응답 분기에서 404 로 마감한다.
|
|
53
|
+
*/
|
|
54
|
+
export declare function notFoundError(message?: string): Error;
|
|
55
|
+
/** 던져진 값이 404 신호인지 판정한다(디스패처가 응답 분기에 사용). */
|
|
56
|
+
export declare function isNotFound(e: unknown): boolean;
|
|
57
|
+
export interface ValidationIssue {
|
|
58
|
+
/** 문제 필드 이름. */
|
|
59
|
+
readonly field: string;
|
|
60
|
+
/** 어느 출처에서 값이 왔는지 — route/body/query/none. */
|
|
61
|
+
readonly source: 'route' | 'body' | 'query' | 'none';
|
|
62
|
+
/** 사람이 읽는 사유(예: "필수 값이 없습니다", "숫자 변환 실패"). */
|
|
63
|
+
readonly reason: string;
|
|
64
|
+
}
|
|
21
65
|
/**
|
|
22
66
|
* 현재 로그인 사용자의 모양. 앱이 declaration merging 으로 자기 User 필드를
|
|
23
67
|
* 얹는다(GaonRouteMap·GaonTables 와 동일 관례) — `gaon g auth` 스캐폴드가
|
|
@@ -63,14 +107,41 @@ export interface ControllerContext {
|
|
|
63
107
|
/** JSON 응답(API/JWT 앱). 값은 응답 경계에서 직렬화된다(bigint/Date/hidden §4.2). */
|
|
64
108
|
json(data: unknown, status?: number): JsonResult;
|
|
65
109
|
/**
|
|
66
|
-
* 폼 입력을 모델 form 의 Row 모양으로 읽는다.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
110
|
+
* 폼 입력을 모델 form 의 Row 모양으로 읽는다. 병합 우선순위는 **라우트
|
|
111
|
+
* 파라미터 > body > query** (errata E-3 §5.1). 라우트 파라미터는 body/query 로
|
|
112
|
+
* 절대 덮어써지지 않아 파라미터 오염 공격이 원천 차단된다. 중복 키
|
|
113
|
+
* (`?tag=a&tag=b`)는 스키마 필드가 배열이면 배열로, 아니면 마지막 값이
|
|
114
|
+
* 이긴다. 스키마 검증 실패는 ValidationError 로 던져지고 디스패처가 422
|
|
115
|
+
* JSON 으로 응답한다(§7.5.3).
|
|
116
|
+
*
|
|
117
|
+
* 인자 없이 부르면 병합된 모든 입력을 그대로(any) 돌려준다. 프로토타입 단계
|
|
118
|
+
* 하위호환용이며, 스키마 없는 접근은 대량 할당을 막지 못하므로 실 사용에서는
|
|
119
|
+
* `this.params(Form)` 을 권장한다.
|
|
70
120
|
*/
|
|
121
|
+
params(): Record<string, unknown>;
|
|
71
122
|
params<T>(form: {
|
|
72
123
|
readonly _row: T;
|
|
73
124
|
}): T;
|
|
125
|
+
/**
|
|
126
|
+
* 탈출구(§5.4) — body 만 읽는다. 웹훅처럼 라우트 파라미터·쿼리와 섞을 이유가
|
|
127
|
+
* 없는 드문 경우에 쓴다. 검증은 params 와 동일하게 스키마 파생.
|
|
128
|
+
*/
|
|
129
|
+
body(): Record<string, unknown>;
|
|
130
|
+
body<T>(form: {
|
|
131
|
+
readonly _row: T;
|
|
132
|
+
}): T;
|
|
133
|
+
/** 탈출구(§5.4) — query 만 읽는다. 중복 키 규칙은 params 와 동일. */
|
|
134
|
+
query(): Record<string, unknown>;
|
|
135
|
+
query<T>(form: {
|
|
136
|
+
readonly _row: T;
|
|
137
|
+
}): T;
|
|
138
|
+
/**
|
|
139
|
+
* 업로드된 파일 목록(multipart 요청). 비-multipart 요청은 빈 배열.
|
|
140
|
+
* 파일 본문은 이미 버퍼로 읽혀 있어 Storage.put 에 바로 넣을 수 있다(§7).
|
|
141
|
+
*/
|
|
142
|
+
files(): readonly UploadedFile[];
|
|
143
|
+
/** 필드명으로 업로드 파일 하나를 고른다(없으면 undefined). */
|
|
144
|
+
file(field: string): UploadedFile | undefined;
|
|
74
145
|
/**
|
|
75
146
|
* 세션 저장소(§7 세션 배터리). 세션 앱에서만 존재하고, JWT/API 앱은
|
|
76
147
|
* undefined 다. 로그인은 `this.session.userId = ...` 로 사용자를 심는다.
|
|
@@ -88,9 +159,29 @@ export interface ControllerContext {
|
|
|
88
159
|
* 리다이렉트(폼/Inertia)하거나 401(API)로 응답한다. 로그인 상태면 사용자를 돌려준다.
|
|
89
160
|
*/
|
|
90
161
|
requireAuth(): GaonCurrentUser;
|
|
162
|
+
/**
|
|
163
|
+
* 리소스 부재 가드 — 404 로 응답한다. requireAuth 와 동형으로 예외를 던져
|
|
164
|
+
* 디스패처가 404 로 마감한다(never 반환이라 `return this.notFound()` 이후로는
|
|
165
|
+
* 값이 존재하는 것으로 좁혀진다):
|
|
166
|
+
* const post = await Post.find(id)
|
|
167
|
+
* if (!post) return this.notFound() // 이 뒤로 post 는 non-null
|
|
168
|
+
* return this.render('posts/Show', { post })
|
|
169
|
+
* never 를 반환하므로 액션의 추론 반환 타입(Rendered<P>)을 넓히지 않아
|
|
170
|
+
* 타입 브리지(pageProps/api)가 그대로 유지된다.
|
|
171
|
+
*/
|
|
172
|
+
notFound(message?: string): never;
|
|
91
173
|
readonly request: FastifyRequest;
|
|
92
174
|
readonly reply: FastifyReply;
|
|
93
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* 값이 페이지 렌더 결과인가? — 디스패처 응답 분기와 스캐폴드 예시에서 쓴다.
|
|
178
|
+
* page·props 두 키가 정확히 있고 다른 응답 마커(json/redirect)가 없어야 한다.
|
|
179
|
+
*/
|
|
180
|
+
export declare function isRendered(v: unknown): v is Rendered<unknown>;
|
|
181
|
+
/** 값이 리다이렉트 결과인가? */
|
|
182
|
+
export declare function isRedirect(v: unknown): v is Redirect;
|
|
183
|
+
/** 값이 명시적 JsonResult(this.json) 결과인가? */
|
|
184
|
+
export declare function isJsonResult(v: unknown): v is JsonResult;
|
|
94
185
|
/** 디스패처가 raw(미바인딩) 액션 맵을 꺼내는 키. */
|
|
95
186
|
export declare const RAW_ACTIONS: unique symbol;
|
|
96
187
|
/** 컨트롤러 객체에서 raw 액션 맵을 읽는다(디스패처 전용). */
|
package/dist/controller.js
CHANGED
|
@@ -12,6 +12,36 @@
|
|
|
12
12
|
// 그 컨텍스트로 호출한다. 그래서 controller() 는 raw(미바인딩) 액션을
|
|
13
13
|
// 심볼로 노출한다. 반환 객체 자체는 기본 컨텍스트에 바인딩돼 있어 단위
|
|
14
14
|
// 테스트에서 c.index() 를 그대로 호출할 수 있다(this.render 동작).
|
|
15
|
+
/**
|
|
16
|
+
* 검증 실패(스키마 오류) — this.params/body/query 가 던진다. 디스패처가 잡아
|
|
17
|
+
* 422 JSON 응답으로 자동 변환한다(§7.5.3 — 에러가 곧 수리 안내서). 어느 키가
|
|
18
|
+
* 어느 출처(route/body/query)에서 왔고 왜 거부됐는지, 스키마 어느 파일을
|
|
19
|
+
* 어떻게 고쳐야 하는지까지 메시지에 담는다.
|
|
20
|
+
*/
|
|
21
|
+
export class ValidationError extends Error {
|
|
22
|
+
status = 422;
|
|
23
|
+
issues;
|
|
24
|
+
constructor(message, issues) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'ValidationError';
|
|
27
|
+
this.issues = issues;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** this.notFound() 가 던지는 리소스 부재 신호. unauthorizedError 와 동형 마커. */
|
|
31
|
+
const NOT_FOUND = Symbol.for('gaon.web.notFound');
|
|
32
|
+
/**
|
|
33
|
+
* this.notFound() 가 던지는 404 신호를 만든다. requireAuth 의 unauthorizedError
|
|
34
|
+
* 와 같은 방식(마커 프로퍼티) — 디스패처가 응답 분기에서 404 로 마감한다.
|
|
35
|
+
*/
|
|
36
|
+
export function notFoundError(message) {
|
|
37
|
+
const e = new Error(message ?? 'Not Found');
|
|
38
|
+
e[NOT_FOUND] = true;
|
|
39
|
+
return e;
|
|
40
|
+
}
|
|
41
|
+
/** 던져진 값이 404 신호인지 판정한다(디스패처가 응답 분기에 사용). */
|
|
42
|
+
export function isNotFound(e) {
|
|
43
|
+
return (typeof e === 'object' && e !== null && e[NOT_FOUND] === true);
|
|
44
|
+
}
|
|
15
45
|
/** 요청 없이 호출되는 기본 컨텍스트(단위 테스트·타입 브리지). */
|
|
16
46
|
const defaultContext = {
|
|
17
47
|
render(page, props) {
|
|
@@ -26,6 +56,18 @@ const defaultContext = {
|
|
|
26
56
|
params(_form) {
|
|
27
57
|
return undefined;
|
|
28
58
|
},
|
|
59
|
+
body(_form) {
|
|
60
|
+
return undefined;
|
|
61
|
+
},
|
|
62
|
+
query(_form) {
|
|
63
|
+
return undefined;
|
|
64
|
+
},
|
|
65
|
+
files() {
|
|
66
|
+
return [];
|
|
67
|
+
},
|
|
68
|
+
file() {
|
|
69
|
+
return undefined;
|
|
70
|
+
},
|
|
29
71
|
session: undefined,
|
|
30
72
|
csrfToken() {
|
|
31
73
|
return '';
|
|
@@ -35,9 +77,32 @@ const defaultContext = {
|
|
|
35
77
|
requireAuth() {
|
|
36
78
|
throw new Error('requireAuth: 인증 컨텍스트가 없습니다(단위 테스트 기본 컨텍스트).');
|
|
37
79
|
},
|
|
80
|
+
notFound(message) {
|
|
81
|
+
throw notFoundError(message);
|
|
82
|
+
},
|
|
38
83
|
// request/reply 는 실제 요청에서만 존재한다 — 기본 컨텍스트(단위 테스트·타입
|
|
39
84
|
// 브리지)는 의도적으로 생략하므로 unknown 경유로 캐스트한다.
|
|
40
85
|
};
|
|
86
|
+
/**
|
|
87
|
+
* 값이 페이지 렌더 결과인가? — 디스패처 응답 분기와 스캐폴드 예시에서 쓴다.
|
|
88
|
+
* page·props 두 키가 정확히 있고 다른 응답 마커(json/redirect)가 없어야 한다.
|
|
89
|
+
*/
|
|
90
|
+
export function isRendered(v) {
|
|
91
|
+
return (typeof v === 'object' &&
|
|
92
|
+
v !== null &&
|
|
93
|
+
'page' in v &&
|
|
94
|
+
'props' in v &&
|
|
95
|
+
!('json' in v) &&
|
|
96
|
+
!('redirect' in v));
|
|
97
|
+
}
|
|
98
|
+
/** 값이 리다이렉트 결과인가? */
|
|
99
|
+
export function isRedirect(v) {
|
|
100
|
+
return typeof v === 'object' && v !== null && 'redirect' in v;
|
|
101
|
+
}
|
|
102
|
+
/** 값이 명시적 JsonResult(this.json) 결과인가? */
|
|
103
|
+
export function isJsonResult(v) {
|
|
104
|
+
return typeof v === 'object' && v !== null && 'json' in v;
|
|
105
|
+
}
|
|
41
106
|
/** 디스패처가 raw(미바인딩) 액션 맵을 꺼내는 키. */
|
|
42
107
|
export const RAW_ACTIONS = Symbol.for('gaon.web.rawActions');
|
|
43
108
|
/** 컨트롤러 객체에서 raw 액션 맵을 읽는다(디스패처 전용). */
|
package/dist/dispatch.js
CHANGED
|
@@ -8,11 +8,34 @@
|
|
|
8
8
|
// 앱별 프리픽스는 createApp 의 캡슐화 스코프(register({ prefix }))가 담당하므로,
|
|
9
9
|
// 여기서는 라우트를 프리픽스 없는 상대 경로로 등록한다 — 세션·CSRF 도 같은
|
|
10
10
|
// 스코프에 갇혀 앱별 완전 분리가 성립한다(질문 13).
|
|
11
|
-
import { rawActions, } from './controller.js';
|
|
11
|
+
import { isJsonResult, isNotFound, isRedirect, isRendered, notFoundError, rawActions, ValidationError, } from './controller.js';
|
|
12
12
|
import { inertiaRedirect, renderInertia } from './inertia.js';
|
|
13
13
|
import { serializeProps } from './serialize.js';
|
|
14
14
|
import { isUnauthorized, unauthorizedError } from './auth.js';
|
|
15
|
-
|
|
15
|
+
import { coerceParams, mergeSources } from './params.js';
|
|
16
|
+
async function parseMultipart(request) {
|
|
17
|
+
const fields = {};
|
|
18
|
+
const files = [];
|
|
19
|
+
// @fastify/multipart 의 parts() 비동기 이터레이터 — 파일은 즉시 버퍼로 흡수한다.
|
|
20
|
+
const parts = request.parts();
|
|
21
|
+
for await (const part of parts) {
|
|
22
|
+
if (part.type === 'file') {
|
|
23
|
+
const buffer = await part.toBuffer();
|
|
24
|
+
files.push({
|
|
25
|
+
field: part.fieldname,
|
|
26
|
+
filename: part.filename,
|
|
27
|
+
mimetype: part.mimetype,
|
|
28
|
+
buffer,
|
|
29
|
+
size: buffer.length,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
fields[part.fieldname] = part.value;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { fields, files };
|
|
37
|
+
}
|
|
38
|
+
function buildContext(request, reply, multipart) {
|
|
16
39
|
// 세션·CSRF 는 @fastify/session · csrf-protection 이 전역 증강으로 붙이지만,
|
|
17
40
|
// 세션 배터리를 등록한 앱에서만 런타임에 존재한다(JWT/API 앱은 undefined).
|
|
18
41
|
const req = request;
|
|
@@ -31,6 +54,24 @@ function buildContext(request, reply) {
|
|
|
31
54
|
delete sess.userId;
|
|
32
55
|
},
|
|
33
56
|
};
|
|
57
|
+
// 세 출처를 미리 뽑아 둔다 — this.params/body/query 가 모두 같은 값을 본다.
|
|
58
|
+
const routeParams = request.params ?? undefined;
|
|
59
|
+
const bodyObj = multipart
|
|
60
|
+
? multipart.fields
|
|
61
|
+
: (request.body ?? undefined);
|
|
62
|
+
const queryObj = (() => {
|
|
63
|
+
const q = request.query;
|
|
64
|
+
if (!q || typeof q !== 'object')
|
|
65
|
+
return undefined;
|
|
66
|
+
// Fastify 기본 qs 파서가 배열/스칼라를 갈라 준다. 문자열화만 통일.
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(q)) {
|
|
69
|
+
if (v === undefined || v === null)
|
|
70
|
+
continue;
|
|
71
|
+
out[k] = Array.isArray(v) ? v.map((x) => String(x)) : String(v);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
})();
|
|
34
75
|
return {
|
|
35
76
|
render(page, props) {
|
|
36
77
|
return { page, props };
|
|
@@ -41,8 +82,27 @@ function buildContext(request, reply) {
|
|
|
41
82
|
json(data, status) {
|
|
42
83
|
return { json: data, status };
|
|
43
84
|
},
|
|
44
|
-
|
|
45
|
-
|
|
85
|
+
// 병합 우선순위: 라우트 > body > query (errata E-3 §5.1). 라우트 파라미터는
|
|
86
|
+
// body/query 로 절대 덮어써지지 않는다. 인자 없이 부르면 모든 병합값 그대로.
|
|
87
|
+
params: ((form) => {
|
|
88
|
+
const { merged, sources } = mergeSources(routeParams, bodyObj, queryObj);
|
|
89
|
+
return coerceParams(form, merged, sources);
|
|
90
|
+
}),
|
|
91
|
+
// 탈출구: body 만 (§5.4).
|
|
92
|
+
body: ((form) => {
|
|
93
|
+
const { merged, sources } = mergeSources(undefined, bodyObj, undefined);
|
|
94
|
+
return coerceParams(form, merged, sources);
|
|
95
|
+
}),
|
|
96
|
+
// 탈출구: query 만 (§5.4). 중복 키 규칙은 params 와 동일.
|
|
97
|
+
query: ((form) => {
|
|
98
|
+
const { merged, sources } = mergeSources(undefined, undefined, queryObj);
|
|
99
|
+
return coerceParams(form, merged, sources);
|
|
100
|
+
}),
|
|
101
|
+
files() {
|
|
102
|
+
return multipart?.files ?? [];
|
|
103
|
+
},
|
|
104
|
+
file(field) {
|
|
105
|
+
return multipart?.files.find((f) => f.field === field);
|
|
46
106
|
},
|
|
47
107
|
session: sess,
|
|
48
108
|
csrfToken: () => (rep.generateCsrf ? rep.generateCsrf() : ''),
|
|
@@ -53,27 +113,41 @@ function buildContext(request, reply) {
|
|
|
53
113
|
throw unauthorizedError();
|
|
54
114
|
return this.currentUser;
|
|
55
115
|
},
|
|
116
|
+
notFound(message) {
|
|
117
|
+
throw notFoundError(message);
|
|
118
|
+
},
|
|
56
119
|
request,
|
|
57
120
|
reply,
|
|
58
121
|
};
|
|
59
122
|
}
|
|
60
123
|
function finish(request, reply, result, version) {
|
|
61
|
-
if (result
|
|
124
|
+
if (result === undefined || result === null) {
|
|
62
125
|
reply.code(204).send();
|
|
63
126
|
return;
|
|
64
127
|
}
|
|
65
|
-
|
|
128
|
+
// 응답 분기 — 렌더 → Inertia, 리다이렉트 → 302/303, JsonResult(this.json) → JSON,
|
|
129
|
+
// 그 외 객체·배열·원시값(errata E-3) → JSON 자동. 반환값이 곧 응답이다.
|
|
130
|
+
if (isRedirect(result)) {
|
|
66
131
|
inertiaRedirect(request, reply, result.redirect);
|
|
67
132
|
return;
|
|
68
133
|
}
|
|
69
|
-
if (
|
|
134
|
+
if (isJsonResult(result)) {
|
|
70
135
|
reply
|
|
71
136
|
.code(result.status ?? 200)
|
|
72
137
|
.header('Content-Type', 'application/json; charset=utf-8')
|
|
73
138
|
.send(JSON.stringify(serializeProps(result.json)));
|
|
74
139
|
return;
|
|
75
140
|
}
|
|
76
|
-
|
|
141
|
+
if (isRendered(result)) {
|
|
142
|
+
renderInertia(request, reply, result.page, result.props, { version });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
// JSON 액션 (errata E-3): 액션이 render/redirect/json 없이 평범한 객체·배열·
|
|
146
|
+
// 원시값을 반환하면 자동 JSON 응답. serializeProps 로 bigint/Date/hidden 처리.
|
|
147
|
+
reply
|
|
148
|
+
.code(200)
|
|
149
|
+
.header('Content-Type', 'application/json; charset=utf-8')
|
|
150
|
+
.send(JSON.stringify(serializeProps(result)));
|
|
77
151
|
}
|
|
78
152
|
/**
|
|
79
153
|
* 한 앱의 라우트를 (프리픽스가 이미 걸린) Fastify 스코프에 등록한다.
|
|
@@ -89,7 +163,13 @@ export function registerApp(app, dispatch, opts = {}) {
|
|
|
89
163
|
if (typeof fn !== 'function')
|
|
90
164
|
continue;
|
|
91
165
|
const handler = async (request, reply) => {
|
|
92
|
-
|
|
166
|
+
// multipart 업로드면 파일·필드를 먼저 파싱해 컨텍스트에 싣는다(§7).
|
|
167
|
+
// isMultipart 는 요청 메서드다 — 분리 호출하면 this 가 유실되므로 그대로 부른다.
|
|
168
|
+
const reqMp = request;
|
|
169
|
+
const multipart = typeof reqMp.isMultipart === 'function' && reqMp.isMultipart()
|
|
170
|
+
? await parseMultipart(request)
|
|
171
|
+
: undefined;
|
|
172
|
+
const ctx = buildContext(request, reply, multipart);
|
|
93
173
|
if (opts.augment)
|
|
94
174
|
await opts.augment(ctx, request, reply);
|
|
95
175
|
let result;
|
|
@@ -105,6 +185,23 @@ export function registerApp(app, dispatch, opts = {}) {
|
|
|
105
185
|
reply.code(401).send({ error: 'Unauthorized' });
|
|
106
186
|
return reply;
|
|
107
187
|
}
|
|
188
|
+
if (isNotFound(err)) {
|
|
189
|
+
// this.notFound(): 리소스 부재 → 404 JSON. Inertia 요청이면 클라이언트가
|
|
190
|
+
// 비-2xx 를 에러 모달로 처리한다(ValidationError 422 와 동형 마감).
|
|
191
|
+
reply
|
|
192
|
+
.code(404)
|
|
193
|
+
.header('Content-Type', 'application/json; charset=utf-8')
|
|
194
|
+
.send(JSON.stringify({ error: 'Not Found', message: err.message }));
|
|
195
|
+
return reply;
|
|
196
|
+
}
|
|
197
|
+
if (err instanceof ValidationError) {
|
|
198
|
+
// errata E-3: 검증 실패 → 422 JSON 자동. issues 는 필드·출처·사유 배열.
|
|
199
|
+
reply
|
|
200
|
+
.code(422)
|
|
201
|
+
.header('Content-Type', 'application/json; charset=utf-8')
|
|
202
|
+
.send(JSON.stringify({ error: 'ValidationError', message: err.message, issues: err.issues }));
|
|
203
|
+
return reply;
|
|
204
|
+
}
|
|
108
205
|
throw err;
|
|
109
206
|
}
|
|
110
207
|
if (reply.sent)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** 앱 하나에 대한 라우팅 관례. */
|
|
2
|
+
export interface AppHostBinding {
|
|
3
|
+
readonly name: string;
|
|
4
|
+
/** URL 프리픽스(관례: web → '/', 그 외 → '/<name>'). */
|
|
5
|
+
readonly prefix: string;
|
|
6
|
+
/** app.config.ts 의 hosts: 배열(운영 도메인). 소문자로 정규화된 값. */
|
|
7
|
+
readonly hosts: readonly string[];
|
|
8
|
+
}
|
|
9
|
+
/** onRequest 훅이 호출자에게 판단을 위임하기 위해 쓰는 최소 뷰. */
|
|
10
|
+
export interface RequestView {
|
|
11
|
+
/** Host 헤더 그대로(포트 포함 가능). */
|
|
12
|
+
readonly host: string | undefined;
|
|
13
|
+
/** 현재 URL(예: '/foo?bar=1'). */
|
|
14
|
+
readonly url: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* 요청 하나에 대한 URL 재작성을 계산한다(순수 함수 — 테스트 편의).
|
|
18
|
+
* 재작성이 필요 없으면 undefined 를 낸다.
|
|
19
|
+
*/
|
|
20
|
+
export declare function resolveHostRewrite(req: RequestView, bindings: readonly AppHostBinding[]): string | undefined;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @gaonjs/web · Host 헤더 기반 앱 라우팅 (v0.16 §3.3 · v0.11 확정)
|
|
2
|
+
//
|
|
3
|
+
// v0.16 §3.3 정본:
|
|
4
|
+
// "개발 모드에서는 로컬 도메인 매핑을 기본 지원한다(v0.11 확정) — 앱은
|
|
5
|
+
// admin.localhost 처럼 <앱이름>.localhost 로도 접근되며, 프리픽스
|
|
6
|
+
// 접근(/admin)도 함께 동작한다."
|
|
7
|
+
//
|
|
8
|
+
// 구현 전략: Fastify 라우트는 이미 앱별 프리픽스(admin → /admin) 아래 등록
|
|
9
|
+
// 돼 있다(app.ts createApp). 이 훅은 요청 URL 을 **먼저** 재작성한다:
|
|
10
|
+
//
|
|
11
|
+
// Host: admin.localhost:3000, URL: /foo
|
|
12
|
+
// → URL: /admin/foo (기존 프리픽스 라우팅이 받는다)
|
|
13
|
+
//
|
|
14
|
+
// 그러면 앱별 세션·라우트 스코프가 그대로 성립한다 — 새 라우팅 층을 만드는
|
|
15
|
+
// 대신 한 층만 추가한다(The One Way · §1.1).
|
|
16
|
+
//
|
|
17
|
+
// 규칙:
|
|
18
|
+
// 1) Host 가 정확히 `<앱이름>.localhost` 또는 `<앱이름>.localhost:<port>`
|
|
19
|
+
// 이고, 그 앱이 실제로 등록돼 있으면 URL 을 `/<앱이름>` 프리픽스로 재작성.
|
|
20
|
+
// 2) 이미 그 프리픽스가 URL 에 있으면(예: 사용자가 이미 명시적으로 접근)
|
|
21
|
+
// 재작성하지 않는다 — 중복 프리픽스 방지.
|
|
22
|
+
// 3) app.config.ts 의 hosts: 배열에 지정된 도메인도 동일하게 처리(운영 도메인
|
|
23
|
+
// 매핑까지 자연스럽게 확장).
|
|
24
|
+
// 4) web 앱(프리픽스 '/')은 재작성 대상이 아니다 — web.localhost 로 오면
|
|
25
|
+
// 원래 URL 이 그대로 web 앱 라우팅(프리픽스 '/')에 도달한다.
|
|
26
|
+
// 5) 매칭 실패는 조용히 통과(no-op) — 다른 앱 프리픽스 접근이 계속 동작한다.
|
|
27
|
+
/**
|
|
28
|
+
* 요청 하나에 대한 URL 재작성을 계산한다(순수 함수 — 테스트 편의).
|
|
29
|
+
* 재작성이 필요 없으면 undefined 를 낸다.
|
|
30
|
+
*/
|
|
31
|
+
export function resolveHostRewrite(req, bindings) {
|
|
32
|
+
const host = normalizeHost(req.host);
|
|
33
|
+
if (!host)
|
|
34
|
+
return undefined;
|
|
35
|
+
// 1) 명시 hosts 우선 — 운영 도메인(예: 'admin.example.com').
|
|
36
|
+
// 2) 그다음 <앱이름>.localhost 관례.
|
|
37
|
+
const match = matchApp(host, bindings);
|
|
38
|
+
if (!match)
|
|
39
|
+
return undefined;
|
|
40
|
+
// web 앱(프리픽스 '/')은 URL 을 건들지 않는다.
|
|
41
|
+
if (match.prefix === '/' || match.prefix === '')
|
|
42
|
+
return undefined;
|
|
43
|
+
// 이미 프리픽스가 붙어 있으면 no-op(중복 방지).
|
|
44
|
+
if (urlStartsWithPrefix(req.url, match.prefix))
|
|
45
|
+
return undefined;
|
|
46
|
+
return joinPrefix(match.prefix, req.url);
|
|
47
|
+
}
|
|
48
|
+
/** Host 헤더에서 포트를 벗기고 소문자화한다. IPv6([::1]:port)는 대상 외. */
|
|
49
|
+
function normalizeHost(host) {
|
|
50
|
+
if (!host)
|
|
51
|
+
return undefined;
|
|
52
|
+
const trimmed = host.trim().toLowerCase();
|
|
53
|
+
if (!trimmed)
|
|
54
|
+
return undefined;
|
|
55
|
+
// IPv6 는 브래킷 형태 · 개발 모드 대상이 아님(관례는 localhost 서브도메인).
|
|
56
|
+
if (trimmed.startsWith('['))
|
|
57
|
+
return trimmed;
|
|
58
|
+
const colon = trimmed.lastIndexOf(':');
|
|
59
|
+
// 콜론이 있고 뒤가 숫자만이면 포트로 간주해 제거.
|
|
60
|
+
if (colon > 0 && /^\d+$/.test(trimmed.slice(colon + 1))) {
|
|
61
|
+
return trimmed.slice(0, colon);
|
|
62
|
+
}
|
|
63
|
+
return trimmed;
|
|
64
|
+
}
|
|
65
|
+
/** host 가 어떤 앱에 속하는지 찾는다. */
|
|
66
|
+
function matchApp(host, bindings) {
|
|
67
|
+
// 1) hosts: 명시 매칭 — 가장 강함(운영 정합).
|
|
68
|
+
for (const b of bindings) {
|
|
69
|
+
if (b.hosts.some((h) => h === host))
|
|
70
|
+
return b;
|
|
71
|
+
}
|
|
72
|
+
// 2) <이름>.localhost 관례.
|
|
73
|
+
// 예: 'admin.localhost' → 앱 이름 'admin'.
|
|
74
|
+
if (host.endsWith('.localhost')) {
|
|
75
|
+
const sub = host.slice(0, -'.localhost'.length);
|
|
76
|
+
// 서브도메인이 정확히 앱 이름과 같아야 한다(다단계 서브도메인은 제외).
|
|
77
|
+
if (sub && !sub.includes('.')) {
|
|
78
|
+
const found = bindings.find((b) => b.name === sub);
|
|
79
|
+
if (found)
|
|
80
|
+
return found;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
/** URL 이 특정 프리픽스(경로 부분)로 시작하는지 안전 검사. */
|
|
86
|
+
function urlStartsWithPrefix(url, prefix) {
|
|
87
|
+
// 프리픽스 정규화: 앞 '/' 보장 · 끝 '/' 제거.
|
|
88
|
+
const p = normalizePrefix(prefix);
|
|
89
|
+
if (p === '/')
|
|
90
|
+
return true;
|
|
91
|
+
// URL 은 쿼리·해시를 앞뒤로 가질 수 있어 pathname 만 잘라 비교.
|
|
92
|
+
const q = url.indexOf('?');
|
|
93
|
+
const pathname = q >= 0 ? url.slice(0, q) : url;
|
|
94
|
+
if (pathname === p)
|
|
95
|
+
return true;
|
|
96
|
+
return pathname.startsWith(p + '/');
|
|
97
|
+
}
|
|
98
|
+
/** URL 앞에 프리픽스를 붙인다(중복 슬래시 방지). */
|
|
99
|
+
function joinPrefix(prefix, url) {
|
|
100
|
+
const p = normalizePrefix(prefix);
|
|
101
|
+
if (p === '/')
|
|
102
|
+
return url;
|
|
103
|
+
if (url.startsWith('/'))
|
|
104
|
+
return p + url;
|
|
105
|
+
return p + '/' + url;
|
|
106
|
+
}
|
|
107
|
+
function normalizePrefix(prefix) {
|
|
108
|
+
if (!prefix || prefix === '/')
|
|
109
|
+
return '/';
|
|
110
|
+
let p = prefix.startsWith('/') ? prefix : '/' + prefix;
|
|
111
|
+
if (p.length > 1 && p.endsWith('/'))
|
|
112
|
+
p = p.slice(0, -1);
|
|
113
|
+
return p;
|
|
114
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export declare const version: string;
|
|
2
2
|
export { routes, type RouteBuilder, type RouteDef, type RouteEntry, type HttpMethod, } from "./routes.js";
|
|
3
|
-
export { controller, rawActions, RAW_ACTIONS, type Rendered, type Redirect, type JsonResult, type ActionResult, type ControllerContext, type AuthApi, type JwtApi, type JwtTokens, type GaonCurrentUser, } from "./controller.js";
|
|
3
|
+
export { controller, rawActions, RAW_ACTIONS, isRendered, isRedirect, isJsonResult, isNotFound, notFoundError, ValidationError, type Rendered, type Redirect, type JsonResult, type ActionResult, type ControllerContext, type AuthApi, type JwtApi, type JwtTokens, type GaonCurrentUser, type ValidationIssue, } from "./controller.js";
|
|
4
|
+
export { mergeSources, coerceParams, normalizeQuery, type ParsedQuery, type Source, } from "./params.js";
|
|
4
5
|
export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, type DiscoveredRoute, } from "./generator.js";
|
|
5
6
|
export { createApp, type AppSpec, type SecurityOptions, type CreateAppOptions, } from "./app.js";
|
|
7
|
+
export { resolveHostRewrite, type AppHostBinding, type RequestView, } from "./host-router.js";
|
|
6
8
|
export { registerApp, prefixFor, type AppDispatch, type DispatchOptions, type ContextExtras, } from "./dispatch.js";
|
|
7
9
|
export { registerSession, type AppSessionOptions, } from "./session.js";
|
|
8
10
|
export { redisSessionStore, type RedisStoreOptions, } from "./redisStore.js";
|
package/dist/index.js
CHANGED
|
@@ -10,11 +10,15 @@ export const version = VERSION;
|
|
|
10
10
|
// 라우팅 DSL (§5.1) — 타입 브리지 + 실행 레이어 라우트 표
|
|
11
11
|
export { routes, } from "./routes.js";
|
|
12
12
|
// controller() — this.render/redirect/params/currentUser (§5.2·§7)
|
|
13
|
-
export { controller, rawActions, RAW_ACTIONS, } from "./controller.js";
|
|
13
|
+
export { controller, rawActions, RAW_ACTIONS, isRendered, isRedirect, isJsonResult, isNotFound, notFoundError, ValidationError, } from "./controller.js";
|
|
14
|
+
// this.params 안전 규칙 (errata E-3 §5.1~5.4)
|
|
15
|
+
export { mergeSources, coerceParams, normalizeQuery, } from "./params.js";
|
|
14
16
|
// routes.d.ts 생성기 (앱별 타입 브리지)
|
|
15
17
|
export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, } from "./generator.js";
|
|
16
18
|
// HTTP 실행 레이어 (Fastify 앱 팩토리·보안 기본값)
|
|
17
19
|
export { createApp, } from "./app.js";
|
|
20
|
+
// Host 헤더 기반 앱 라우팅 (v0.16 §3.3 · <앱>.localhost 매핑)
|
|
21
|
+
export { resolveHostRewrite, } from "./host-router.js";
|
|
18
22
|
// 라우트 디스패치 (라우트 표 → Fastify 등록)
|
|
19
23
|
export { registerApp, prefixFor, } from "./dispatch.js";
|
|
20
24
|
// 세션 배터리 (앱별 분리 + CSRF · Redis 스토어)
|
package/dist/params.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** 파싱된 쿼리 표현 — 배열 값(?a=1&a=2)을 원본대로 보존한다. */
|
|
2
|
+
export type ParsedQuery = Record<string, string | string[] | undefined>;
|
|
3
|
+
/** 어느 출처에서 값이 왔는지 추적한다(에러 메시지·감시용). */
|
|
4
|
+
export type Source = 'route' | 'body' | 'query';
|
|
5
|
+
/**
|
|
6
|
+
* 세 출처를 우선순위로 병합한다. 라우트 파라미터가 최우선(§5.1) — body/query 로
|
|
7
|
+
* 덮어써지지 않는다. 반환 객체는 병합값만 담고, 출처 지도는 별도로 반환한다.
|
|
8
|
+
*/
|
|
9
|
+
export declare function mergeSources(route: Record<string, unknown> | undefined, body: Record<string, unknown> | undefined, query: ParsedQuery | undefined): {
|
|
10
|
+
merged: Record<string, unknown>;
|
|
11
|
+
sources: Record<string, Source>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* 원본 문자열 쿼리(?tag=a&tag=b)를 ParsedQuery 로 정규화. Fastify 는 기본
|
|
15
|
+
* qs 파서로 이미 배열/스칼라를 갈라 준다 — 단, 스칼라 값을 스키마 배열
|
|
16
|
+
* 필드에 태우거나 배열 값을 스칼라 필드에 태우는 상황을 coerceParams 에서
|
|
17
|
+
* 함께 다룬다. 여기선 형태 통일만.
|
|
18
|
+
*/
|
|
19
|
+
export declare function normalizeQuery(raw: unknown): ParsedQuery;
|
|
20
|
+
/**
|
|
21
|
+
* 병합된 입력을 스키마(form._row 뒤에 있는 컬럼 정의)에 맞춰 강제 변환·검증한다.
|
|
22
|
+
* 스키마에 없는 키는 결과에서 제거해 대량 할당을 차단한다(§5.3).
|
|
23
|
+
*
|
|
24
|
+
* form 은 두 모양을 지원한다:
|
|
25
|
+
* · 완전한 TableDef({ name, defs }): model.form 또는 스키마 자체.
|
|
26
|
+
* · `{ _row: T }` 팬텀 만 있는 폼(레거시 · 스캐폴드): 컬럼 정보가 없으므로
|
|
27
|
+
* 타입 통과용으로 병합값을 그대로 반환(대량 할당은 스키마 있는 폼에서만 차단).
|
|
28
|
+
*/
|
|
29
|
+
export declare function coerceParams<T>(form: unknown, merged: Record<string, unknown>, sources: Record<string, Source>): T;
|
package/dist/params.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// @gaonjs/web · this.params 안전 규칙 (errata E-3 §5.1~5.4)
|
|
2
|
+
//
|
|
3
|
+
// 병합 우선순위(고정 · 설정 불가): **라우트 파라미터 > body > query**.
|
|
4
|
+
// 라우트 파라미터는 body/query 로 절대 덮어써지지 않아 파라미터 오염
|
|
5
|
+
// 공격이 원천 차단된다.
|
|
6
|
+
//
|
|
7
|
+
// 중복 키 규칙(`?tag=a&tag=b`):
|
|
8
|
+
// · 스키마 필드가 배열 타입이면 배열로 수집
|
|
9
|
+
// · 아니면 마지막 값(last-wins)
|
|
10
|
+
// this.params('key') 단일 접근도 같은 규칙.
|
|
11
|
+
//
|
|
12
|
+
// 탈출구(§5.4): this.body(schema) · this.query(schema) 로 한 출처만 읽는다.
|
|
13
|
+
//
|
|
14
|
+
// 스키마 검증 실패는 ValidationError 로 던지고, 디스패처가 422 JSON 으로
|
|
15
|
+
// 응답한다. 메시지는 "어떤 키가 어느 출처에서 왔고 왜 거부됐는지 → 스키마
|
|
16
|
+
// 어느 파일 어떻게 고치라"까지 담는다(§7.5.3).
|
|
17
|
+
//
|
|
18
|
+
// 이 모듈은 controller/dispatch 에서 분리해 단위 테스트가 용이하도록 했다
|
|
19
|
+
// — 병합·강제 변환·검증은 순수 함수다.
|
|
20
|
+
import { ValidationError } from './controller.js';
|
|
21
|
+
/**
|
|
22
|
+
* 세 출처를 우선순위로 병합한다. 라우트 파라미터가 최우선(§5.1) — body/query 로
|
|
23
|
+
* 덮어써지지 않는다. 반환 객체는 병합값만 담고, 출처 지도는 별도로 반환한다.
|
|
24
|
+
*/
|
|
25
|
+
export function mergeSources(route, body, query) {
|
|
26
|
+
const merged = {};
|
|
27
|
+
const sources = {};
|
|
28
|
+
// 우선순위 낮은 순으로 채우고, 높은 순으로 덮는다.
|
|
29
|
+
if (query) {
|
|
30
|
+
for (const [k, v] of Object.entries(query)) {
|
|
31
|
+
if (v === undefined)
|
|
32
|
+
continue;
|
|
33
|
+
merged[k] = v;
|
|
34
|
+
sources[k] = 'query';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (body) {
|
|
38
|
+
for (const [k, v] of Object.entries(body)) {
|
|
39
|
+
if (v === undefined)
|
|
40
|
+
continue;
|
|
41
|
+
merged[k] = v;
|
|
42
|
+
sources[k] = 'body';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (route) {
|
|
46
|
+
for (const [k, v] of Object.entries(route)) {
|
|
47
|
+
if (v === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
merged[k] = v;
|
|
50
|
+
sources[k] = 'route';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { merged, sources };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 원본 문자열 쿼리(?tag=a&tag=b)를 ParsedQuery 로 정규화. Fastify 는 기본
|
|
57
|
+
* qs 파서로 이미 배열/스칼라를 갈라 준다 — 단, 스칼라 값을 스키마 배열
|
|
58
|
+
* 필드에 태우거나 배열 값을 스칼라 필드에 태우는 상황을 coerceParams 에서
|
|
59
|
+
* 함께 다룬다. 여기선 형태 통일만.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeQuery(raw) {
|
|
62
|
+
const out = {};
|
|
63
|
+
if (typeof raw !== 'object' || raw === null)
|
|
64
|
+
return out;
|
|
65
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
66
|
+
if (v === undefined || v === null)
|
|
67
|
+
continue;
|
|
68
|
+
if (Array.isArray(v))
|
|
69
|
+
out[k] = v.map((x) => String(x));
|
|
70
|
+
else
|
|
71
|
+
out[k] = String(v);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 병합된 입력을 스키마(form._row 뒤에 있는 컬럼 정의)에 맞춰 강제 변환·검증한다.
|
|
77
|
+
* 스키마에 없는 키는 결과에서 제거해 대량 할당을 차단한다(§5.3).
|
|
78
|
+
*
|
|
79
|
+
* form 은 두 모양을 지원한다:
|
|
80
|
+
* · 완전한 TableDef({ name, defs }): model.form 또는 스키마 자체.
|
|
81
|
+
* · `{ _row: T }` 팬텀 만 있는 폼(레거시 · 스캐폴드): 컬럼 정보가 없으므로
|
|
82
|
+
* 타입 통과용으로 병합값을 그대로 반환(대량 할당은 스키마 있는 폼에서만 차단).
|
|
83
|
+
*/
|
|
84
|
+
export function coerceParams(form, merged, sources) {
|
|
85
|
+
const defs = tableDefsOf(form);
|
|
86
|
+
if (!defs) {
|
|
87
|
+
// 스키마 정보 없음 — 병합값 그대로. 레거시 호환.
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
const out = {};
|
|
91
|
+
const issues = [];
|
|
92
|
+
for (const [name, def] of Object.entries(defs)) {
|
|
93
|
+
const raw = merged[name];
|
|
94
|
+
if (raw === undefined || raw === null || raw === '') {
|
|
95
|
+
if (!def._meta.hasDefault && !def._meta.nullable) {
|
|
96
|
+
issues.push({
|
|
97
|
+
field: name,
|
|
98
|
+
source: sources[name] ?? 'none',
|
|
99
|
+
reason: `필수 값이 없습니다.`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
out[name] = coerceOne(def, raw);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
109
|
+
issues.push({ field: name, source: sources[name] ?? 'none', reason: msg });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (issues.length > 0)
|
|
113
|
+
throw makeValidationError(form, issues);
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
function tableDefsOf(form) {
|
|
117
|
+
if (typeof form !== 'object' || form === null)
|
|
118
|
+
return undefined;
|
|
119
|
+
const t = form;
|
|
120
|
+
if (t.defs && typeof t.defs === 'object')
|
|
121
|
+
return t.defs;
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
function coerceOne(def, raw) {
|
|
125
|
+
const t = def._meta.sqlType;
|
|
126
|
+
// 배열 값(중복 키)이 스칼라 컬럼에 들어오면 마지막 값을 취한다 — 스키마 상
|
|
127
|
+
// 배열 필드는 아직 없으므로(§4.1 데이터 타입 v1) 항상 스칼라화한다.
|
|
128
|
+
const v = Array.isArray(raw) ? raw[raw.length - 1] : raw;
|
|
129
|
+
switch (t) {
|
|
130
|
+
case 'bigserial':
|
|
131
|
+
case 'bigint':
|
|
132
|
+
return coerceBigInt(v);
|
|
133
|
+
case 'boolean':
|
|
134
|
+
return coerceBoolean(v);
|
|
135
|
+
case 'timestamptz':
|
|
136
|
+
return coerceDate(v);
|
|
137
|
+
case 'varchar':
|
|
138
|
+
case 'text':
|
|
139
|
+
return String(v);
|
|
140
|
+
default:
|
|
141
|
+
return v;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function coerceBigInt(v) {
|
|
145
|
+
if (typeof v === 'bigint')
|
|
146
|
+
return v;
|
|
147
|
+
if (typeof v === 'number' && Number.isInteger(v))
|
|
148
|
+
return BigInt(v);
|
|
149
|
+
if (typeof v === 'string' && /^-?\d+$/.test(v.trim()))
|
|
150
|
+
return BigInt(v.trim());
|
|
151
|
+
throw new Error(`정수(bigint) 로 변환할 수 없습니다: ${JSON.stringify(v)}`);
|
|
152
|
+
}
|
|
153
|
+
function coerceBoolean(v) {
|
|
154
|
+
if (typeof v === 'boolean')
|
|
155
|
+
return v;
|
|
156
|
+
if (v === 'true' || v === '1' || v === 1)
|
|
157
|
+
return true;
|
|
158
|
+
if (v === 'false' || v === '0' || v === 0)
|
|
159
|
+
return false;
|
|
160
|
+
throw new Error(`불리언으로 변환할 수 없습니다: ${JSON.stringify(v)}`);
|
|
161
|
+
}
|
|
162
|
+
function coerceDate(v) {
|
|
163
|
+
if (v instanceof Date)
|
|
164
|
+
return v;
|
|
165
|
+
const d = new Date(String(v));
|
|
166
|
+
if (isNaN(d.getTime()))
|
|
167
|
+
throw new Error(`날짜로 변환할 수 없습니다: ${JSON.stringify(v)}`);
|
|
168
|
+
return d;
|
|
169
|
+
}
|
|
170
|
+
function makeValidationError(form, issues) {
|
|
171
|
+
const tblName = typeof form === 'object' && form !== null && 'name' in form
|
|
172
|
+
? String(form.name)
|
|
173
|
+
: '(unknown)';
|
|
174
|
+
const lines = issues.map((i) => ` · ${i.field} ← ${i.source}: ${i.reason}`).join('\n');
|
|
175
|
+
const msg = `[@gaonjs/web] 요청 검증 실패 (${issues.length}건 · 테이블 '${tblName}'):\n${lines}\n` +
|
|
176
|
+
`→ 위 필드를 확인하거나, domain/schema/${tblName}.ts 의 컬럼 정의를 요청과 맞추세요.\n` +
|
|
177
|
+
`→ 라우트 파라미터로 받는 값이면 라우트 경로(:${issues[0]?.field ?? 'x'})가 존재하는지도 확인하세요.`;
|
|
178
|
+
return new ValidationError(msg, issues);
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Gaon 웹 레이어: Fastify 통합·파일 기반 라우팅·보안 기본값 (구현 예정)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,21 +23,22 @@
|
|
|
23
23
|
"dist",
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
|
|
28
|
+
},
|
|
26
29
|
"dependencies": {
|
|
27
30
|
"@fastify/cookie": "^11.1.2",
|
|
28
31
|
"@fastify/cors": "^11.3.0",
|
|
29
32
|
"@fastify/csrf-protection": "^8.0.0",
|
|
33
|
+
"@fastify/multipart": "^10.1.0",
|
|
30
34
|
"@fastify/rate-limit": "^11.1.0",
|
|
31
35
|
"@fastify/session": "^11.1.2",
|
|
32
36
|
"@fastify/websocket": "^11.0.2",
|
|
37
|
+
"@gaonjs/async": "workspace:*",
|
|
38
|
+
"@gaonjs/core": "workspace:*",
|
|
33
39
|
"bcryptjs": "^3.0.3",
|
|
34
40
|
"fastify": "^5.10.0",
|
|
35
41
|
"ioredis": "^5.11.1",
|
|
36
|
-
"jose": "^6.2.4"
|
|
37
|
-
"@gaonjs/async": "0.2.1",
|
|
38
|
-
"@gaonjs/core": "0.1.3"
|
|
39
|
-
},
|
|
40
|
-
"scripts": {
|
|
41
|
-
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
|
|
42
|
+
"jose": "^6.2.4"
|
|
42
43
|
}
|
|
43
|
-
}
|
|
44
|
+
}
|