@gaonjs/web 0.1.3 → 0.2.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/app.d.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { type FastifyInstance, type FastifyServerOptions } from 'fastify';
2
+ import { type FastifyCorsOptions } from '@fastify/cors';
3
+ import { type GaonNats } from '@gaonjs/async';
4
+ import type { RouteDef } from './routes.js';
5
+ import { type DispatchOptions } from './dispatch.js';
6
+ import { type AppSessionOptions } from './session.js';
7
+ import { type AuthOptions, type JwtAuthOptions } from './auth.js';
8
+ import { type ChannelMap } from './websocket.js';
9
+ /** 한 앱 정의 — 프리픽스(생략 시 관례)·라우트·컨트롤러 맵. */
10
+ export interface AppSpec {
11
+ readonly name: string;
12
+ /** 생략 시 관례: web→'/', 그 외→'/<name>'(§3.3). */
13
+ readonly prefix?: string;
14
+ readonly routes: RouteDef;
15
+ readonly controllers: Record<string, object>;
16
+ /** 세션 배터리 설정(§7). 생략 시 세션 없음(JWT/API 앱 등). */
17
+ readonly session?: AppSessionOptions;
18
+ /**
19
+ * 인증(§7). 세션 앱은 AuthOptions(기본), API 앱은 JwtAuthOptions(strategy:'jwt').
20
+ * 세션 앱은 requireAuth 실패 시 로그인 페이지로, JWT 앱은 401 로 응답한다.
21
+ */
22
+ readonly auth?: AuthOptions | JwtAuthOptions;
23
+ /** 요청별 컨텍스트 확장(추가 훅). */
24
+ readonly dispatch?: DispatchOptions;
25
+ /**
26
+ * 실시간 채널(§7 · M6). apps/<app>/channels/<이름>.ts 의 default export 를
27
+ * 이름별로 모은 맵. realtime(NATS)이 설정된 경우에만 활성.
28
+ */
29
+ readonly channels?: ChannelMap;
30
+ }
31
+ export interface SecurityOptions {
32
+ /** CORS 설정. 기본은 same-origin 만 허용(origin:false). false 로 완전 비활성. */
33
+ readonly cors?: FastifyCorsOptions | false;
34
+ /** rate limit. 기본 100 req/분/IP. false 로 비활성. */
35
+ readonly rateLimit?: {
36
+ max?: number;
37
+ timeWindow?: string | number;
38
+ } | false;
39
+ }
40
+ export interface CreateAppOptions {
41
+ readonly apps: readonly AppSpec[];
42
+ /** Inertia 에셋 버전(전 앱 공통). 클라이언트 불일치 시 풀 리로드. */
43
+ readonly version?: string;
44
+ /** 서명 쿠키·CSRF 용 비밀. 생략 시 env GAON_COOKIE_SECRET. */
45
+ readonly cookieSecret?: string;
46
+ readonly security?: SecurityOptions;
47
+ readonly fastify?: FastifyServerOptions;
48
+ /**
49
+ * 실시간 백본(§7 · M6). 설정하면 채널(ws)이 활성화된다. nats 커넥션은
50
+ * 호출자가 소유·종료한다(여기선 채널 런타임만 onClose 로 정리). server 는
51
+ * 프레즌스 식별자(생략 시 hostname#pid). heartbeatMs 는 프레즌스 하트비트
52
+ * 주기(생략 시 런타임 기본 10000) — 허브 만료 임계의 1/3 이하로 둔다.
53
+ */
54
+ readonly realtime?: {
55
+ readonly nats: GaonNats;
56
+ readonly server?: string;
57
+ readonly heartbeatMs?: number;
58
+ };
59
+ }
60
+ /**
61
+ * 앱 스펙들로 Fastify 인스턴스를 만든다. 쿠키·CORS·rate limit(보안 기본값)을
62
+ * 켜고, 각 앱의 라우트를 프리픽스 아래 등록한다. listen 은 호출자가 한다.
63
+ */
64
+ export declare function createApp(opts: CreateAppOptions): Promise<FastifyInstance>;
65
+ export type { RouteDef };
package/dist/app.js ADDED
@@ -0,0 +1,123 @@
1
+ // @gaonjs/web · Fastify 앱 팩토리 + 보안 기본값 (§2.5.1)
2
+ //
3
+ // 보안(CORS·rate limit·CSRF)은 코어에 내장하고 **기본으로 켠다** — 별도
4
+ // 패키지를 설치해야 방어가 생기는 방식은 명시적으로 기각했다(§2.5.1). 끄는
5
+ // 것은 명시적 설정으로만 한다.
6
+ //
7
+ // CSRF·세션은 세션 배터리 스텝에서 이 팩토리에 결합된다. 여기서는 HTTP
8
+ // 실행 레이어의 뼈대(쿠키·CORS·rate limit·앱별 라우트 등록)를 놓는다.
9
+ import Fastify from 'fastify';
10
+ import cookie from '@fastify/cookie';
11
+ import cors from '@fastify/cors';
12
+ import rateLimit from '@fastify/rate-limit';
13
+ import { hostname } from 'node:os';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { createChannelRuntime } from '@gaonjs/async';
16
+ import { prefixFor, registerApp } from './dispatch.js';
17
+ import { registerSession } from './session.js';
18
+ import { jwtAuthAugment, sessionAuthAugment } from './auth.js';
19
+ import { registerChannels, registerWebSocketSupport, } from './websocket.js';
20
+ import { verifyToken } from './jwt.js';
21
+ /**
22
+ * 앱 스펙들로 Fastify 인스턴스를 만든다. 쿠키·CORS·rate limit(보안 기본값)을
23
+ * 켜고, 각 앱의 라우트를 프리픽스 아래 등록한다. listen 은 호출자가 한다.
24
+ */
25
+ export async function createApp(opts) {
26
+ const app = Fastify(opts.fastify);
27
+ const security = opts.security ?? {};
28
+ // 실시간 채널 런타임 — 프로세스당 하나(전 앱 공유, NATS 는 호출자 소유).
29
+ let runtime;
30
+ if (opts.realtime) {
31
+ const server = opts.realtime.server ?? `${hostname()}#${process.pid}`;
32
+ runtime = await createChannelRuntime({
33
+ nats: opts.realtime.nats,
34
+ server,
35
+ heartbeatMs: opts.realtime.heartbeatMs,
36
+ });
37
+ // 전역 upgrade 훅은 루트에서 한 번만 — 채널 라우트는 앱 스코프에서 정의.
38
+ await registerWebSocketSupport(app);
39
+ app.addHook('onClose', async () => {
40
+ await runtime?.close();
41
+ });
42
+ }
43
+ // 쿠키 — 세션·CSRF·서명 쿠키의 토대. secret 은 서명 쿠키에만 필요.
44
+ await app.register(cookie, { secret: opts.cookieSecret ?? process.env.GAON_COOKIE_SECRET });
45
+ // CORS — 기본 same-origin(origin:false). 크로스 오리진은 명시 설정으로만.
46
+ if (security.cors !== false) {
47
+ await app.register(cors, security.cors ?? { origin: false });
48
+ }
49
+ // rate limit — 기본 100 req/분/IP.
50
+ if (security.rateLimit !== false) {
51
+ const rl = security.rateLimit ?? {};
52
+ await app.register(rateLimit, { max: rl.max ?? 100, timeWindow: rl.timeWindow ?? '1 minute' });
53
+ }
54
+ // 앱마다 캡슐화 스코프에 등록한다 — 세션·CSRF·라우트가 그 앱 프리픽스에
55
+ // 갇혀 앱별 완전 분리가 성립한다(질문 13). 프리픽스는 스코프가 건다.
56
+ for (const spec of opts.apps) {
57
+ const prefix = spec.prefix ?? prefixFor(spec.name);
58
+ await app.register(async (scope) => {
59
+ if (spec.session)
60
+ await registerSession(scope, spec.name, prefix, spec.session);
61
+ registerApp(scope, { name: spec.name, routes: spec.routes, controllers: spec.controllers }, dispatchOptionsFor(spec, opts.version));
62
+ // 채널(ws)은 세션과 같은 스코프에 등록 — 연결이 앱 세션으로 인증된다.
63
+ if (runtime && spec.channels && Object.keys(spec.channels).length > 0) {
64
+ registerChannels(scope, {
65
+ runtime,
66
+ channels: spec.channels,
67
+ resolveMember: memberResolverFor(spec),
68
+ });
69
+ }
70
+ }, { prefix });
71
+ }
72
+ return app;
73
+ }
74
+ /** 앱의 인증·추가 훅을 디스패치 옵션으로 합성한다. */
75
+ function dispatchOptionsFor(spec, version) {
76
+ const auth = spec.auth;
77
+ // JWT 앱은 401(리다이렉트 없음), 세션 앱은 로그인 페이지로.
78
+ const isJwt = auth != null && 'strategy' in auth && auth.strategy === 'jwt';
79
+ const authAugment = auth ? (isJwt ? jwtAuthAugment(auth) : sessionAuthAugment(auth)) : undefined;
80
+ const loginRedirect = auth && !isJwt ? auth.loginRedirect ?? '/login' : undefined;
81
+ const extra = spec.dispatch?.augment;
82
+ const augment = authAugment || extra
83
+ ? async (ctx, request, reply) => {
84
+ if (authAugment)
85
+ await authAugment(ctx, request, reply);
86
+ if (extra)
87
+ await extra(ctx, request, reply);
88
+ }
89
+ : undefined;
90
+ return { version, loginRedirect, ...spec.dispatch, augment };
91
+ }
92
+ /**
93
+ * 채널 연결의 멤버/사용자를 앱 인증 방식에 맞춰 해석한다. 세션 앱은
94
+ * session.userId 로, JWT 앱은 쿼리(access_token)로 인증한다(웹소켓은
95
+ * 커스텀 헤더를 못 실으므로 API 앱은 쿼리 토큰이 관례다). 비로그인은
96
+ * 익명 연결(공개 채널 허용).
97
+ */
98
+ function memberResolverFor(spec) {
99
+ const auth = spec.auth;
100
+ if (!auth)
101
+ return undefined;
102
+ if ('strategy' in auth && auth.strategy === 'jwt') {
103
+ return async (request) => {
104
+ const token = request.query?.access_token;
105
+ if (typeof token === 'string') {
106
+ const payload = await verifyToken(auth.secret, token);
107
+ if (payload && payload.typ === 'access' && typeof payload.sub === 'string') {
108
+ const user = (await auth.loadUser(payload.sub)) ?? null;
109
+ return { member: `user:${payload.sub}`, user };
110
+ }
111
+ }
112
+ return { member: `conn:${randomUUID()}`, user: null };
113
+ };
114
+ }
115
+ const sessionAuth = auth;
116
+ return async (request) => {
117
+ const uid = request.session?.userId;
118
+ if (uid == null)
119
+ return { member: `conn:${randomUUID()}`, user: null };
120
+ const user = (await sessionAuth.loadUser(uid)) ?? null;
121
+ return { member: `user:${String(uid)}`, user };
122
+ };
123
+ }
package/dist/auth.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ import type { ControllerContext, GaonCurrentUser } from './controller.js';
3
+ /** requireAuth 가 던지는 비로그인 신호. 클래스 대신 마커 프로퍼티를 쓴다. */
4
+ export declare function unauthorizedError(): Error;
5
+ /** 던져진 값이 비로그인 신호인지 판정한다(디스패처가 응답 분기에 사용). */
6
+ export declare function isUnauthorized(e: unknown): boolean;
7
+ export interface AuthOptions {
8
+ /**
9
+ * session.userId → 사용자 로드. 앱이 도메인 모델로 구현한다(예:
10
+ * (id) => User.query().where('id','=',BigInt(id)).first()). falsy 반환은
11
+ * 비로그인 취급. web 이 data 를 직접 import 하지 않게 하는 이음새다.
12
+ */
13
+ loadUser(id: unknown): Promise<GaonCurrentUser | null | undefined> | GaonCurrentUser | null | undefined;
14
+ /** requireAuth 실패 시 리다이렉트 경로(폼/Inertia). 기본 '/login'. */
15
+ loginRedirect?: string;
16
+ }
17
+ /**
18
+ * 컨텍스트에 currentUser 를 채우는 augment 를 만든다. 세션의 userId 로
19
+ * 사용자를 로드해 ctx.currentUser·ctx.auth.user 에 심는다.
20
+ */
21
+ export declare function sessionAuthAugment(opts: AuthOptions): (ctx: ControllerContext, request: FastifyRequest, _reply: FastifyReply) => Promise<void>;
22
+ export interface JwtAuthOptions {
23
+ /** JWT 앱임을 표시하는 판별자. */
24
+ strategy: 'jwt';
25
+ /** HS256 서명 secret. 앱마다 달라야 토큰이 앱을 넘지 못한다(질문 13). */
26
+ secret: string;
27
+ /** 토큰 sub(사용자 id) → 사용자 로드. */
28
+ loadUser(id: unknown): Promise<GaonCurrentUser | null | undefined> | GaonCurrentUser | null | undefined;
29
+ /** 액세스 토큰 수명. 기본 '15m'. */
30
+ accessTtl?: string;
31
+ /** 리프레시 토큰 수명. 기본 '7d'. */
32
+ refreshTtl?: string;
33
+ }
34
+ /**
35
+ * JWT 앱용 augment. Bearer 액세스 토큰으로 currentUser 를 채우고, this.jwt 로
36
+ * 토큰 발급/재발급 API 를 얹는다(로그인·리프레시 컨트롤러가 사용).
37
+ */
38
+ export declare function jwtAuthAugment(opts: JwtAuthOptions): (ctx: ControllerContext, request: FastifyRequest, _reply: FastifyReply) => Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,78 @@
1
+ // @gaonjs/web · 인증 코어 (currentUser · requireAuth · 세션 로그인)
2
+ //
3
+ // 세션 쿠키가 기본 인증이다(§7). 로그인은 세션에 사용자 id 를 심고
4
+ // (this.auth.login), 요청마다 그 id 로 사용자를 로드해 this.currentUser 에
5
+ // 채운다. 보호 라우트는 this.requireAuth() 가드로 지킨다 — 비로그인이면
6
+ // 예외를 던져 디스패처가 로그인 페이지로 보내거나(폼/Inertia) 401(API)로 답한다.
7
+ //
8
+ // web 은 도메인(모델·DB)을 모른다 — 사용자 로드는 앱이 loadUser 로 주입한다
9
+ // (의존 방향 §3.2 보호: web→data 직접 의존 없음).
10
+ import { signToken, verifyToken } from './jwt.js';
11
+ const UNAUTHORIZED = Symbol.for('gaon.web.unauthorized');
12
+ /** requireAuth 가 던지는 비로그인 신호. 클래스 대신 마커 프로퍼티를 쓴다. */
13
+ export function unauthorizedError() {
14
+ const e = new Error('Unauthorized');
15
+ e[UNAUTHORIZED] = true;
16
+ return e;
17
+ }
18
+ /** 던져진 값이 비로그인 신호인지 판정한다(디스패처가 응답 분기에 사용). */
19
+ export function isUnauthorized(e) {
20
+ return typeof e === 'object' && e !== null && e[UNAUTHORIZED] === true;
21
+ }
22
+ /**
23
+ * 컨텍스트에 currentUser 를 채우는 augment 를 만든다. 세션의 userId 로
24
+ * 사용자를 로드해 ctx.currentUser·ctx.auth.user 에 심는다.
25
+ */
26
+ export function sessionAuthAugment(opts) {
27
+ return async (ctx, request, _reply) => {
28
+ const sess = request.session;
29
+ const uid = sess?.userId;
30
+ if (uid == null)
31
+ return;
32
+ const user = (await opts.loadUser(uid)) ?? null;
33
+ ctx.currentUser = user;
34
+ ctx.auth.user = user;
35
+ };
36
+ }
37
+ /** Authorization: Bearer <token> 에서 토큰만 뽑는다. */
38
+ function bearer(request) {
39
+ const h = request.headers.authorization;
40
+ if (typeof h === 'string' && h.startsWith('Bearer '))
41
+ return h.slice(7).trim();
42
+ }
43
+ /**
44
+ * JWT 앱용 augment. Bearer 액세스 토큰으로 currentUser 를 채우고, this.jwt 로
45
+ * 토큰 발급/재발급 API 를 얹는다(로그인·리프레시 컨트롤러가 사용).
46
+ */
47
+ export function jwtAuthAugment(opts) {
48
+ const accessTtl = opts.accessTtl ?? '15m';
49
+ const refreshTtl = opts.refreshTtl ?? '7d';
50
+ const api = {
51
+ async issue(user) {
52
+ const sub = String(user.id);
53
+ return {
54
+ accessToken: await signToken(opts.secret, sub, 'access', accessTtl),
55
+ refreshToken: await signToken(opts.secret, sub, 'refresh', refreshTtl),
56
+ };
57
+ },
58
+ async refresh(refreshToken) {
59
+ const payload = await verifyToken(opts.secret, refreshToken);
60
+ if (!payload || payload.typ !== 'refresh' || typeof payload.sub !== 'string')
61
+ return null;
62
+ return { accessToken: await signToken(opts.secret, payload.sub, 'access', accessTtl) };
63
+ },
64
+ };
65
+ return async (ctx, request, _reply) => {
66
+ ;
67
+ ctx.jwt = api;
68
+ const token = bearer(request);
69
+ if (!token)
70
+ return;
71
+ const payload = await verifyToken(opts.secret, token);
72
+ if (!payload || payload.typ !== 'access' || typeof payload.sub !== 'string')
73
+ return;
74
+ const user = (await opts.loadUser(payload.sub)) ?? null;
75
+ ctx.currentUser = user;
76
+ ctx.auth.user = user;
77
+ };
78
+ }
@@ -1,14 +1,103 @@
1
+ import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ /** 렌더 결과 — 페이지 이름 + props. 타입 브리지 입력(§6.2). */
1
3
  export interface Rendered<P> {
2
4
  readonly page: string;
3
5
  readonly props: P;
4
6
  }
5
- /** 액션 본문의 `this` — M3 최소는 render 만. */
7
+ /**
8
+ * 리다이렉트 결과. Rendered<P>(page·props)와 구조가 겹치지 않아야 한다 —
9
+ * 겹치면 타입 브리지 PropsOf 가 리다이렉트를 렌더로 오인한다. 디스패처는
10
+ * `'redirect' in result` 로 분기한다.
11
+ */
12
+ export interface Redirect {
13
+ readonly redirect: string;
14
+ }
15
+ /** JSON 응답 결과 — API/JWT 앱이 Inertia 대신 순수 데이터를 반환할 때. */
16
+ export interface JsonResult {
17
+ readonly json: unknown;
18
+ readonly status?: number;
19
+ }
20
+ export type ActionResult<P = unknown> = Rendered<P> | Redirect | JsonResult | void;
21
+ /**
22
+ * 현재 로그인 사용자의 모양. 앱이 declaration merging 으로 자기 User 필드를
23
+ * 얹는다(GaonRouteMap·GaonTables 와 동일 관례) — `gaon g auth` 스캐폴드가
24
+ * 이 병합을 생성한다. 빈 인터페이스라 실제 타입 있는 레코드(RowOf 등)를
25
+ * loadUser 반환으로 그대로 받을 수 있고, 병합 후 this.currentUser 필드에
26
+ * 타입 접근이 열린다.
27
+ */
28
+ export interface GaonCurrentUser {
29
+ }
30
+ /** this.auth — 현재 사용자와 로그인/로그아웃(세션 조작). */
31
+ export interface AuthApi {
32
+ readonly user: GaonCurrentUser | null;
33
+ /** 세션에 사용자 id 를 심어 로그인 상태로 만든다. */
34
+ login(user: {
35
+ id: unknown;
36
+ }): void;
37
+ /** 세션에서 사용자를 지운다. */
38
+ logout(): void;
39
+ }
40
+ export interface JwtTokens {
41
+ readonly accessToken: string;
42
+ readonly refreshToken: string;
43
+ }
44
+ /** this.jwt — JWT 앱(API)에서 토큰을 발급·재발급한다. 세션 앱에는 없다. */
45
+ export interface JwtApi {
46
+ /** 로그인 성공 시 액세스+리프레시 토큰을 발급한다(응답 본문으로 반환). */
47
+ issue(user: {
48
+ id: unknown;
49
+ }): Promise<JwtTokens>;
50
+ /** 리프레시 토큰으로 새 액세스 토큰을 발급한다. 무효면 null. */
51
+ refresh(refreshToken: string): Promise<{
52
+ accessToken: string;
53
+ } | null>;
54
+ }
55
+ /**
56
+ * 액션 본문의 `this`. render 는 항상 있고, 실행 레이어에서 request/reply·
57
+ * 세션·인증이 채워진다(세션·인증 스텝에서 확장). 단위 테스트의 기본
58
+ * 컨텍스트는 render/redirect/params 만 순수 구현하고 request/reply 는 없다.
59
+ */
6
60
  export interface ControllerContext {
7
61
  render<P>(page: string, props: P): Rendered<P>;
62
+ redirect(to: string): Redirect;
63
+ /** JSON 응답(API/JWT 앱). 값은 응답 경계에서 직렬화된다(bigint/Date/hidden §4.2). */
64
+ json(data: unknown, status?: number): JsonResult;
65
+ /**
66
+ * 폼 입력을 모델 form 의 Row 모양으로 읽는다. 현재는 요청 본문의 타입
67
+ * 있는 통로다 — 스키마 파생 검증(실패 시 자동 422, §5.2)은 model 의 form
68
+ * 이 런타임 컬럼 메타를 실어야 가능하며(현재 form._row 는 팬텀), 그 확장은
69
+ * data 레이어와 함께 후속으로 둔다.
70
+ */
71
+ params<T>(form: {
72
+ readonly _row: T;
73
+ }): T;
74
+ /**
75
+ * 세션 저장소(§7 세션 배터리). 세션 앱에서만 존재하고, JWT/API 앱은
76
+ * undefined 다. 로그인은 `this.session.userId = ...` 로 사용자를 심는다.
77
+ */
78
+ readonly session: Record<string, unknown> | undefined;
79
+ /** CSRF 토큰을 발급한다(세션에 시크릿 저장). 페이지가 폼/헤더로 되돌려 보낸다. */
80
+ csrfToken(): string;
81
+ /** 현재 로그인 사용자. 비로그인 시 null. 인증 설정이 있는 앱에서 채워진다(§7). */
82
+ currentUser: GaonCurrentUser | null;
83
+ readonly auth: AuthApi;
84
+ /** JWT 앱(API)에서만 존재하는 토큰 발급 API. 세션 앱은 undefined. */
85
+ readonly jwt?: JwtApi;
86
+ /**
87
+ * 보호 라우트 가드. 비로그인이면 예외를 던져 디스패처가 로그인 페이지로
88
+ * 리다이렉트(폼/Inertia)하거나 401(API)로 응답한다. 로그인 상태면 사용자를 돌려준다.
89
+ */
90
+ requireAuth(): GaonCurrentUser;
91
+ readonly request: FastifyRequest;
92
+ readonly reply: FastifyReply;
8
93
  }
94
+ /** 디스패처가 raw(미바인딩) 액션 맵을 꺼내는 키. */
95
+ export declare const RAW_ACTIONS: unique symbol;
96
+ /** 컨트롤러 객체에서 raw 액션 맵을 읽는다(디스패처 전용). */
97
+ export declare function rawActions(ctrl: object): Record<string, (...a: unknown[]) => unknown>;
9
98
  /**
10
- * 액션 객체를 그대로 반환하되, 본문의 `this` ControllerContext
11
- * 준다(ThisType). 런타임에서는 액션을 context 바인딩해, 실제
12
- * 호출 `this.render` 동작하게 한다.
99
+ * 액션 객체를 반환한다. 반환 객체의 액션은 기본 컨텍스트에 바인딩돼
100
+ * 있어(단위 테스트용) c.index() 직접 호출된다. 동시에 raw 액션 맵을
101
+ * 심볼로 실어, 디스패처가 요청별 컨텍스트로 action.call(ctx) 하게 한다.
13
102
  */
14
103
  export declare function controller<A extends object>(actions: A & ThisType<ControllerContext>): A;
@@ -1,29 +1,67 @@
1
- // @gaonjs/web · controller() 최소 런타임 (M3 최소 레이어)
1
+ // @gaonjs/web · controller() 런타임 (M3 타입 브리지 + M5 실행 레이어)
2
2
  //
3
3
  // 설계 §5.2: `controller({ async index() { return this.render(...) } })`.
4
- // 액션은 `this.render(page, props)`페이지와 props 반환하고,
5
- // 반환 타입(Rendered<P>)이 타입 브리지(.gaon/routes.d.ts)를 거쳐 Vue
6
- // 페이지의 pageProps<K>() 로 흐른다(§6.2·§6.3).
4
+ // 액션은 this.render(page, props) 로 페이지·props 를, this.redirect(to)
5
+ // 리다이렉트를 반환한다. 렌더 반환 타입(Rendered<P>)이 타입 브리지
6
+ // (.gaon/routes.d.ts)를 거쳐 Vue 페이지의 pageProps<K>() 로 흐른다(§6.2·§6.3).
7
7
  //
8
- // ThisType 주의(§13.4-1 의 model 교훈과 동형): 액션 제네릭에 제약을
9
- // 걸면 ThisType 추론이 조용히 붕괴한다 → **무제약 A + ThisType 교차**로
10
- // 둔다. 타입 회귀 테스트(controller.test-d.ts)로 고정한다.
8
+ // ThisType 주의(§13.4-1 의 model 교훈과 동형): 액션 제네릭에 제약을 걸면
9
+ // ThisType 추론이 조용히 붕괴한다 → **무제약 A + ThisType 교차**로 둔다.
11
10
  //
12
- // this.params·auth·redirect 나머지 실행 시맨틱은 후속 마일스톤이다.
13
- const context = {
11
+ // 실행 레이어(M5): dispatch.ts 요청마다 컨텍스트를 만들어 액션을
12
+ // 컨텍스트로 호출한다. 그래서 controller() 는 raw(미바인딩) 액션을
13
+ // 심볼로 노출한다. 반환 객체 자체는 기본 컨텍스트에 바인딩돼 있어 단위
14
+ // 테스트에서 c.index() 를 그대로 호출할 수 있다(this.render 동작).
15
+ /** 요청 없이 호출되는 기본 컨텍스트(단위 테스트·타입 브리지). */
16
+ const defaultContext = {
14
17
  render(page, props) {
15
18
  return { page, props };
16
19
  },
20
+ redirect(to) {
21
+ return { redirect: to };
22
+ },
23
+ json(data, status) {
24
+ return { json: data, status };
25
+ },
26
+ params(_form) {
27
+ return undefined;
28
+ },
29
+ session: undefined,
30
+ csrfToken() {
31
+ return '';
32
+ },
33
+ currentUser: null,
34
+ auth: { user: null, login() { }, logout() { } },
35
+ requireAuth() {
36
+ throw new Error('requireAuth: 인증 컨텍스트가 없습니다(단위 테스트 기본 컨텍스트).');
37
+ },
38
+ // request/reply 는 실제 요청에서만 존재한다 — 기본 컨텍스트(단위 테스트·타입
39
+ // 브리지)는 의도적으로 생략하므로 unknown 경유로 캐스트한다.
17
40
  };
41
+ /** 디스패처가 raw(미바인딩) 액션 맵을 꺼내는 키. */
42
+ export const RAW_ACTIONS = Symbol.for('gaon.web.rawActions');
43
+ /** 컨트롤러 객체에서 raw 액션 맵을 읽는다(디스패처 전용). */
44
+ export function rawActions(ctrl) {
45
+ return ctrl[RAW_ACTIONS];
46
+ }
18
47
  /**
19
- * 액션 객체를 그대로 반환하되, 본문의 `this` ControllerContext
20
- * 준다(ThisType). 런타임에서는 액션을 context 바인딩해, 실제
21
- * 호출 `this.render` 동작하게 한다.
48
+ * 액션 객체를 반환한다. 반환 객체의 액션은 기본 컨텍스트에 바인딩돼
49
+ * 있어(단위 테스트용) c.index() 직접 호출된다. 동시에 raw 액션 맵을
50
+ * 심볼로 실어, 디스패처가 요청별 컨텍스트로 action.call(ctx) 하게 한다.
22
51
  */
23
52
  export function controller(actions) {
53
+ const raw = {};
24
54
  const bound = {};
25
55
  for (const [name, fn] of Object.entries(actions)) {
26
- bound[name] = typeof fn === 'function' ? fn.bind(context) : fn;
56
+ if (typeof fn === 'function') {
57
+ const f = fn;
58
+ raw[name] = f;
59
+ bound[name] = f.bind(defaultContext);
60
+ }
61
+ else {
62
+ bound[name] = fn;
63
+ }
27
64
  }
65
+ Object.defineProperty(bound, RAW_ACTIONS, { value: raw, enumerable: false });
28
66
  return bound;
29
67
  }
@@ -0,0 +1,30 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
2
+ import type { RouteDef } from './routes.js';
3
+ import { type ControllerContext } from './controller.js';
4
+ /** 한 앱의 디스패치 재료 — 라우트·컨트롤러 맵. */
5
+ export interface AppDispatch {
6
+ readonly name: string;
7
+ readonly routes: RouteDef;
8
+ /** 컨트롤러 이름 → controller() 객체. */
9
+ readonly controllers: Record<string, object>;
10
+ }
11
+ /** 컨텍스트에 요청별로 얹히는 확장(인증 스텝이 채운다). */
12
+ export interface ContextExtras {
13
+ augment?(ctx: ControllerContext, request: FastifyRequest, reply: FastifyReply): void | Promise<void>;
14
+ }
15
+ export interface DispatchOptions extends ContextExtras {
16
+ /** Inertia 에셋 버전 — 클라이언트와 불일치 시 풀 리로드. */
17
+ readonly version?: string;
18
+ /**
19
+ * requireAuth 실패 시 리다이렉트 경로. 세션 앱은 로그인 페이지로 보내고,
20
+ * 미설정(API/JWT 앱)이면 401 로 응답한다.
21
+ */
22
+ readonly loginRedirect?: string;
23
+ }
24
+ /**
25
+ * 한 앱의 라우트를 (프리픽스가 이미 걸린) Fastify 스코프에 등록한다.
26
+ * 컨트롤러에 없는 액션은 라우팅하지 않는다 — 라우트 표는 후보이고 구현이 진실이다.
27
+ */
28
+ export declare function registerApp(app: FastifyInstance, dispatch: AppDispatch, opts?: DispatchOptions): void;
29
+ /** web 앱은 '/', 그 외는 '/<name>' (§3.3 프리픽스 관례). */
30
+ export declare function prefixFor(name: string): string;
@@ -0,0 +1,121 @@
1
+ // @gaonjs/web · 디스패치 (라우트 표 → Fastify 등록 → 컨트롤러 실행)
2
+ //
3
+ // routes.ts 가 만든 RouteDef.entries 를 돌며, 각 엔트리의 컨트롤러에 해당
4
+ // 액션이 실제로 구현돼 있으면 Fastify 라우트로 등록한다(없는 액션은 건너뜀).
5
+ // 요청마다 새 컨텍스트를 만들어 액션을 그 컨텍스트로 호출하고(§5.2), 결과를
6
+ // Inertia 응답으로 내보낸다.
7
+ //
8
+ // 앱별 프리픽스는 createApp 의 캡슐화 스코프(register({ prefix }))가 담당하므로,
9
+ // 여기서는 라우트를 프리픽스 없는 상대 경로로 등록한다 — 세션·CSRF 도 같은
10
+ // 스코프에 갇혀 앱별 완전 분리가 성립한다(질문 13).
11
+ import { rawActions, } from './controller.js';
12
+ import { inertiaRedirect, renderInertia } from './inertia.js';
13
+ import { serializeProps } from './serialize.js';
14
+ import { isUnauthorized, unauthorizedError } from './auth.js';
15
+ function buildContext(request, reply) {
16
+ // 세션·CSRF 는 @fastify/session · csrf-protection 이 전역 증강으로 붙이지만,
17
+ // 세션 배터리를 등록한 앱에서만 런타임에 존재한다(JWT/API 앱은 undefined).
18
+ const req = request;
19
+ const rep = reply;
20
+ const sess = req.session;
21
+ const auth = {
22
+ user: null,
23
+ login(user) {
24
+ // 세션은 Redis 에 JSON 으로 직렬화된다 — bigint PK 는 JSON 에서 던지므로
25
+ // id 를 문자열로 저장한다. loadUser 가 다시 BigInt 로 되돌린다.
26
+ if (sess)
27
+ sess.userId = String(user.id);
28
+ },
29
+ logout() {
30
+ if (sess)
31
+ delete sess.userId;
32
+ },
33
+ };
34
+ return {
35
+ render(page, props) {
36
+ return { page, props };
37
+ },
38
+ redirect(to) {
39
+ return { redirect: to };
40
+ },
41
+ json(data, status) {
42
+ return { json: data, status };
43
+ },
44
+ params(_form) {
45
+ return (request.body ?? {});
46
+ },
47
+ session: sess,
48
+ csrfToken: () => (rep.generateCsrf ? rep.generateCsrf() : ''),
49
+ currentUser: null,
50
+ auth,
51
+ requireAuth() {
52
+ if (this.currentUser == null)
53
+ throw unauthorizedError();
54
+ return this.currentUser;
55
+ },
56
+ request,
57
+ reply,
58
+ };
59
+ }
60
+ function finish(request, reply, result, version) {
61
+ if (result == null) {
62
+ reply.code(204).send();
63
+ return;
64
+ }
65
+ if ('redirect' in result) {
66
+ inertiaRedirect(request, reply, result.redirect);
67
+ return;
68
+ }
69
+ if ('json' in result) {
70
+ reply
71
+ .code(result.status ?? 200)
72
+ .header('Content-Type', 'application/json; charset=utf-8')
73
+ .send(JSON.stringify(serializeProps(result.json)));
74
+ return;
75
+ }
76
+ renderInertia(request, reply, result.page, result.props, { version });
77
+ }
78
+ /**
79
+ * 한 앱의 라우트를 (프리픽스가 이미 걸린) Fastify 스코프에 등록한다.
80
+ * 컨트롤러에 없는 액션은 라우팅하지 않는다 — 라우트 표는 후보이고 구현이 진실이다.
81
+ */
82
+ export function registerApp(app, dispatch, opts = {}) {
83
+ for (const entry of dispatch.routes.entries) {
84
+ const ctrl = dispatch.controllers[entry.controller];
85
+ if (!ctrl)
86
+ continue;
87
+ const actions = rawActions(ctrl);
88
+ const fn = actions?.[entry.action];
89
+ if (typeof fn !== 'function')
90
+ continue;
91
+ const handler = async (request, reply) => {
92
+ const ctx = buildContext(request, reply);
93
+ if (opts.augment)
94
+ await opts.augment(ctx, request, reply);
95
+ let result;
96
+ try {
97
+ result = (await fn.call(ctx));
98
+ }
99
+ catch (err) {
100
+ if (isUnauthorized(err)) {
101
+ // 세션 앱: 로그인 페이지로. API/JWT 앱: 401.
102
+ if (opts.loginRedirect)
103
+ inertiaRedirect(request, reply, opts.loginRedirect);
104
+ else
105
+ reply.code(401).send({ error: 'Unauthorized' });
106
+ return reply;
107
+ }
108
+ throw err;
109
+ }
110
+ if (reply.sent)
111
+ return reply; // 가드 등이 이미 응답했으면 그대로 둔다
112
+ finish(request, reply, result, opts.version);
113
+ return reply;
114
+ };
115
+ app.route({ method: entry.method.toUpperCase(), url: entry.path, handler });
116
+ }
117
+ }
118
+ /** web 앱은 '/', 그 외는 '/<name>' (§3.3 프리픽스 관례). */
119
+ export function prefixFor(name) {
120
+ return name === 'web' ? '/' : `/${name}`;
121
+ }