@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 +65 -0
- package/dist/app.js +123 -0
- package/dist/auth.d.ts +38 -0
- package/dist/auth.js +78 -0
- package/dist/controller.d.ts +93 -4
- package/dist/controller.js +51 -13
- package/dist/dispatch.d.ts +30 -0
- package/dist/dispatch.js +121 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +25 -7
- package/dist/inertia.d.ts +24 -0
- package/dist/inertia.js +74 -0
- package/dist/jwt.d.ts +7 -0
- package/dist/jwt.js +29 -0
- package/dist/password.d.ts +4 -0
- package/dist/password.js +17 -0
- package/dist/redisStore.d.ts +10 -0
- package/dist/redisStore.js +37 -0
- package/dist/routes.d.ts +13 -8
- package/dist/routes.js +55 -22
- package/dist/serialize.d.ts +5 -0
- package/dist/serialize.js +46 -0
- package/dist/session.d.ts +27 -0
- package/dist/session.js +76 -0
- package/dist/websocket.d.ts +28 -0
- package/dist/websocket.js +103 -0
- package/package.json +13 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
export declare const version: string;
|
|
2
|
-
export
|
|
3
|
-
export {
|
|
4
|
-
export { controller, type Rendered, type ControllerContext, } from "./controller.js";
|
|
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";
|
|
5
4
|
export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, type DiscoveredRoute, } from "./generator.js";
|
|
5
|
+
export { createApp, type AppSpec, type SecurityOptions, type CreateAppOptions, } from "./app.js";
|
|
6
|
+
export { registerApp, prefixFor, type AppDispatch, type DispatchOptions, type ContextExtras, } from "./dispatch.js";
|
|
7
|
+
export { registerSession, type AppSessionOptions, } from "./session.js";
|
|
8
|
+
export { redisSessionStore, type RedisStoreOptions, } from "./redisStore.js";
|
|
9
|
+
export { sessionAuthAugment, jwtAuthAugment, unauthorizedError, isUnauthorized, type AuthOptions, type JwtAuthOptions, } from "./auth.js";
|
|
10
|
+
export { signToken, verifyToken, type TokenType } from "./jwt.js";
|
|
11
|
+
export { hashPassword, verifyPassword } from "./password.js";
|
|
12
|
+
export { renderInertia, inertiaRedirect, type InertiaPage, type InertiaOptions, } from "./inertia.js";
|
|
13
|
+
export { serializeProps } from "./serialize.js";
|
|
14
|
+
export { registerChannels, registerWebSocketSupport, type ChannelMap, type MemberIdentity, type MemberResolver, type RegisterChannelsOptions, } from "./websocket.js";
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/web — Gaon 웹 레이어 (Fastify 통합·라우팅·보안 기본값).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 타입 브리지 이음새(M3: routes() DSL·controller() 헬퍼·routes.d.ts 생성기)와
|
|
5
|
+
* HTTP 실행 레이어(M5: Fastify 앱 팩토리·라우트 디스패치·Inertia 응답·보안
|
|
6
|
+
* 기본값)를 함께 제공한다.
|
|
7
7
|
*/
|
|
8
8
|
import { VERSION } from "@gaonjs/core";
|
|
9
9
|
export const version = VERSION;
|
|
10
|
-
|
|
11
|
-
// 라우팅 DSL (M3 최소 — 타입 브리지용)
|
|
10
|
+
// 라우팅 DSL (§5.1) — 타입 브리지 + 실행 레이어 라우트 표
|
|
12
11
|
export { routes, } from "./routes.js";
|
|
13
|
-
// controller()
|
|
14
|
-
export { controller, } from "./controller.js";
|
|
12
|
+
// controller() — this.render/redirect/params/currentUser (§5.2·§7)
|
|
13
|
+
export { controller, rawActions, RAW_ACTIONS, } from "./controller.js";
|
|
15
14
|
// routes.d.ts 생성기 (앱별 타입 브리지)
|
|
16
15
|
export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, } from "./generator.js";
|
|
16
|
+
// HTTP 실행 레이어 (Fastify 앱 팩토리·보안 기본값)
|
|
17
|
+
export { createApp, } from "./app.js";
|
|
18
|
+
// 라우트 디스패치 (라우트 표 → Fastify 등록)
|
|
19
|
+
export { registerApp, prefixFor, } from "./dispatch.js";
|
|
20
|
+
// 세션 배터리 (앱별 분리 + CSRF · Redis 스토어)
|
|
21
|
+
export { registerSession, } from "./session.js";
|
|
22
|
+
export { redisSessionStore, } from "./redisStore.js";
|
|
23
|
+
// 인증 코어 (currentUser · requireAuth · 세션 로그인 · JWT)
|
|
24
|
+
export { sessionAuthAugment, jwtAuthAugment, unauthorizedError, isUnauthorized, } from "./auth.js";
|
|
25
|
+
// JWT 토큰 크립토 (API 앱 · HS256)
|
|
26
|
+
export { signToken, verifyToken } from "./jwt.js";
|
|
27
|
+
// 비밀번호 해싱 (bcrypt · hidden passwordDigest §4.2)
|
|
28
|
+
export { hashPassword, verifyPassword } from "./password.js";
|
|
29
|
+
// Inertia 프로토콜 (서버측 렌더링 계약)
|
|
30
|
+
export { renderInertia, inertiaRedirect, } from "./inertia.js";
|
|
31
|
+
// 응답 경계 직렬화 (hidden 제외 §4.2)
|
|
32
|
+
export { serializeProps } from "./serialize.js";
|
|
33
|
+
// 실시간 채널 전송 (WebSocket · §7 M6)
|
|
34
|
+
export { registerChannels, registerWebSocketSupport, } from "./websocket.js";
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FastifyReply, FastifyRequest } from 'fastify';
|
|
2
|
+
/** Inertia 페이지 객체 — 응답 본문(JSON) 또는 data-page(HTML)에 실린다. */
|
|
3
|
+
export interface InertiaPage {
|
|
4
|
+
readonly component: string;
|
|
5
|
+
readonly props: unknown;
|
|
6
|
+
readonly url: string;
|
|
7
|
+
readonly version: string;
|
|
8
|
+
}
|
|
9
|
+
export interface InertiaOptions {
|
|
10
|
+
/** 에셋 버전 — 클라이언트와 불일치하면 풀 리로드를 강제한다. 기본 ''. */
|
|
11
|
+
readonly version?: string;
|
|
12
|
+
/** 최초(비-Inertia) 요청에 쓰는 HTML 문서 셸. 기본 셸은 #app + data-page. */
|
|
13
|
+
readonly rootTemplate?: (page: InertiaPage) => string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* render 결과(page + props)를 Inertia 응답으로 내보낸다.
|
|
17
|
+
* props 는 응답 경계에서 직렬화된다(Date/bigint→string, hidden 제외 §4.2).
|
|
18
|
+
*/
|
|
19
|
+
export declare function renderInertia(request: FastifyRequest, reply: FastifyReply, page: string, props: unknown, opts?: InertiaOptions): void;
|
|
20
|
+
/**
|
|
21
|
+
* Inertia 규약에 맞는 리다이렉트. 변경 메서드(PUT/PATCH/DELETE) 뒤에는 303
|
|
22
|
+
* 이어야 브라우저·클라이언트가 GET 으로 팔로우한다(그 외 302).
|
|
23
|
+
*/
|
|
24
|
+
export declare function inertiaRedirect(request: FastifyRequest, reply: FastifyReply, to: string): void;
|
package/dist/inertia.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// @gaonjs/web · Inertia 프로토콜 (서버측 렌더링 계약)
|
|
2
|
+
//
|
|
3
|
+
// 컨트롤러의 render('posts/Index', props) 는 프론트엔드를 모른다 — 서버 계약은
|
|
4
|
+
// "페이지 이름 + 직렬화된 props" 뿐이다(§6.1·§6.4). 그 계약을 HTTP 로 실어
|
|
5
|
+
// 나르는 것이 Inertia 프로토콜이다:
|
|
6
|
+
//
|
|
7
|
+
// · X-Inertia 요청(SPA 네비게이션): JSON { component, props, url, version } 응답.
|
|
8
|
+
// · 최초 요청(브라우저 문서 로드): HTML 셸에 페이지 객체를 data-page 로 임베드.
|
|
9
|
+
// 실제 마운트는 프론트 어댑터(@gaonjs/vue)와 앱 엔트리가 수행한다.
|
|
10
|
+
// · 리다이렉트: PUT/PATCH/DELETE 뒤에는 303(그래야 브라우저가 GET 으로 팔로우),
|
|
11
|
+
// 그 외 302. 버전 불일치 GET 은 409 + X-Inertia-Location 으로 풀 리로드 유도.
|
|
12
|
+
//
|
|
13
|
+
// 참조: Inertia.js 프로토콜(https://inertiajs.com/the-protocol) — Laravel 어댑터가
|
|
14
|
+
// 검증한 규약을 그대로 따른다.
|
|
15
|
+
import { serializeProps } from './serialize.js';
|
|
16
|
+
function isInertia(request) {
|
|
17
|
+
return request.headers['x-inertia'] === 'true';
|
|
18
|
+
}
|
|
19
|
+
/** data-page 속성에 안전하게 넣기 위한 HTML 속성 이스케이프. */
|
|
20
|
+
function escapeAttr(json) {
|
|
21
|
+
return json
|
|
22
|
+
.replace(/&/g, '&')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''')
|
|
25
|
+
.replace(/</g, '<')
|
|
26
|
+
.replace(/>/g, '>');
|
|
27
|
+
}
|
|
28
|
+
function defaultRoot(page) {
|
|
29
|
+
const data = escapeAttr(JSON.stringify(page));
|
|
30
|
+
// 앱 엔트리(스캐폴드가 생성)가 #app 을 마운트하고 data-page 를 읽는다.
|
|
31
|
+
return ('<!doctype html>\n' +
|
|
32
|
+
'<html>\n<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>\n' +
|
|
33
|
+
`<body><div id="app" data-page="${data}"></div></body>\n</html>\n`);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* render 결과(page + props)를 Inertia 응답으로 내보낸다.
|
|
37
|
+
* props 는 응답 경계에서 직렬화된다(Date/bigint→string, hidden 제외 §4.2).
|
|
38
|
+
*/
|
|
39
|
+
export function renderInertia(request, reply, page, props, opts = {}) {
|
|
40
|
+
const version = opts.version ?? '';
|
|
41
|
+
const pageObject = {
|
|
42
|
+
component: page,
|
|
43
|
+
props: serializeProps(props),
|
|
44
|
+
url: request.url,
|
|
45
|
+
version,
|
|
46
|
+
};
|
|
47
|
+
// 버전 불일치(X-Inertia GET): 409 + Location 으로 클라이언트가 풀 리로드.
|
|
48
|
+
if (isInertia(request) && request.method === 'GET') {
|
|
49
|
+
const clientVersion = request.headers['x-inertia-version'];
|
|
50
|
+
if (clientVersion !== undefined && clientVersion !== version) {
|
|
51
|
+
reply.header('X-Inertia-Location', request.url).code(409).send();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (isInertia(request)) {
|
|
56
|
+
reply
|
|
57
|
+
.header('X-Inertia', 'true')
|
|
58
|
+
.header('Vary', 'X-Inertia')
|
|
59
|
+
.header('Content-Type', 'application/json; charset=utf-8')
|
|
60
|
+
.send(JSON.stringify(pageObject));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const root = opts.rootTemplate ?? defaultRoot;
|
|
64
|
+
reply.header('Content-Type', 'text/html; charset=utf-8').send(root(pageObject));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Inertia 규약에 맞는 리다이렉트. 변경 메서드(PUT/PATCH/DELETE) 뒤에는 303
|
|
68
|
+
* 이어야 브라우저·클라이언트가 GET 으로 팔로우한다(그 외 302).
|
|
69
|
+
*/
|
|
70
|
+
export function inertiaRedirect(request, reply, to) {
|
|
71
|
+
const m = request.method;
|
|
72
|
+
const code = m === 'PUT' || m === 'PATCH' || m === 'DELETE' ? 303 : 302;
|
|
73
|
+
reply.header('Location', to).code(code).send();
|
|
74
|
+
}
|
package/dist/jwt.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type JWTPayload } from 'jose';
|
|
2
|
+
/** typ 클레임으로 액세스/리프레시를 구분한다. */
|
|
3
|
+
export type TokenType = 'access' | 'refresh';
|
|
4
|
+
/** sub(사용자 id) + typ 을 담은 HS256 토큰을 만든다. ttl 예: '15m', '7d'. */
|
|
5
|
+
export declare function signToken(secret: string, sub: string, typ: TokenType, ttl: string): Promise<string>;
|
|
6
|
+
/** 토큰을 검증한다. 유효하면 payload, 만료·위조·불일치면 null(예외를 삼킨다). */
|
|
7
|
+
export declare function verifyToken(secret: string, token: string): Promise<JWTPayload | null>;
|
package/dist/jwt.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @gaonjs/web · JWT 토큰 (API 앱 옵션 · §7 질문 4)
|
|
2
|
+
//
|
|
3
|
+
// 세션 쿠키가 기본이고, JWT 는 API 전용 앱을 위한 옵션이다(app.config 에서 선택).
|
|
4
|
+
// 액세스 토큰(짧은 수명)으로 요청을 인증하고, 리프레시 토큰(긴 수명)으로 액세스
|
|
5
|
+
// 토큰을 재발급한다. 서명은 HS256(jose) — 앱마다 secret 이 달라 토큰이 앱을
|
|
6
|
+
// 넘지 못한다(질문 13 — API 앱의 앱별 분리).
|
|
7
|
+
import { SignJWT, jwtVerify } from 'jose';
|
|
8
|
+
function keyOf(secret) {
|
|
9
|
+
return new TextEncoder().encode(secret);
|
|
10
|
+
}
|
|
11
|
+
/** sub(사용자 id) + typ 을 담은 HS256 토큰을 만든다. ttl 예: '15m', '7d'. */
|
|
12
|
+
export function signToken(secret, sub, typ, ttl) {
|
|
13
|
+
return new SignJWT({ typ })
|
|
14
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
15
|
+
.setSubject(sub)
|
|
16
|
+
.setIssuedAt()
|
|
17
|
+
.setExpirationTime(ttl)
|
|
18
|
+
.sign(keyOf(secret));
|
|
19
|
+
}
|
|
20
|
+
/** 토큰을 검증한다. 유효하면 payload, 만료·위조·불일치면 null(예외를 삼킨다). */
|
|
21
|
+
export async function verifyToken(secret, token) {
|
|
22
|
+
try {
|
|
23
|
+
const { payload } = await jwtVerify(token, keyOf(secret));
|
|
24
|
+
return payload;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
package/dist/password.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// @gaonjs/web · 비밀번호 해싱 (§7 인증)
|
|
2
|
+
//
|
|
3
|
+
// 해시는 bcrypt 로 한다(설계 §7 "bcrypt or argon2"). 순수 JS 구현인 bcryptjs
|
|
4
|
+
// 를 쓰는 이유: 네이티브 빌드(node-gyp)가 없어 어느 환경에서나 설치가 확실하다
|
|
5
|
+
// (AI 첫 시도 성공률 §1.2). 해시 결과는 스키마의 hidden 컬럼(passwordDigest)에
|
|
6
|
+
// 담기며, 직렬화 경계에서 타입·런타임 양쪽으로 페이지 노출이 막힌다(§4.2).
|
|
7
|
+
import bcrypt from 'bcryptjs';
|
|
8
|
+
/** bcrypt cost. 10 은 보안/지연의 통상 균형점. */
|
|
9
|
+
const COST = 10;
|
|
10
|
+
/** 평문 비밀번호를 bcrypt 다이제스트로 해싱한다(passwordDigest 컬럼에 저장). */
|
|
11
|
+
export function hashPassword(plain) {
|
|
12
|
+
return bcrypt.hash(plain, COST);
|
|
13
|
+
}
|
|
14
|
+
/** 평문과 저장된 다이제스트를 상수시간 비교한다. */
|
|
15
|
+
export function verifyPassword(plain, digest) {
|
|
16
|
+
return bcrypt.compare(plain, digest);
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
import type { SessionStore } from '@fastify/session';
|
|
3
|
+
export interface RedisStoreOptions {
|
|
4
|
+
/** 앱별 세션 키 prefix — 완전 분리의 축. 예: 'gaon:sess:web:'. */
|
|
5
|
+
readonly prefix: string;
|
|
6
|
+
/** 세션 만료(초). session.cookie.maxAge 가 있으면 그 값을 우선한다. */
|
|
7
|
+
readonly ttlSeconds: number;
|
|
8
|
+
}
|
|
9
|
+
/** ioredis 인스턴스를 @fastify/session Store 로 감싼다. */
|
|
10
|
+
export declare function redisSessionStore(redis: Redis, opts: RedisStoreOptions): SessionStore;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @gaonjs/web · Redis 세션 스토어 (@fastify/session Store 구현)
|
|
2
|
+
//
|
|
3
|
+
// 세션은 Redis 에 둔다(§7.4 — 캐시/세션 기본 Redis, 개발·테스트도 실 Redis).
|
|
4
|
+
// 앱별 키 prefix 로 세션 저장 공간을 완전히 분리한다(§ 질문 13 — 앱 간 로그인
|
|
5
|
+
// 공유 불허). 인메모리 기본 스토어는 프로세스가 여럿이면 세션이 새므로
|
|
6
|
+
// 운영 경로가 아니다(@fastify/session 문서도 경고).
|
|
7
|
+
/** ioredis 인스턴스를 @fastify/session Store 로 감싼다. */
|
|
8
|
+
export function redisSessionStore(redis, opts) {
|
|
9
|
+
const keyOf = (sid) => `${opts.prefix}${sid}`;
|
|
10
|
+
const ttlOf = (session) => {
|
|
11
|
+
const maxAge = session.cookie?.maxAge;
|
|
12
|
+
if (typeof maxAge === 'number' && maxAge > 0)
|
|
13
|
+
return Math.ceil(maxAge / 1000);
|
|
14
|
+
return opts.ttlSeconds;
|
|
15
|
+
};
|
|
16
|
+
return {
|
|
17
|
+
set(sessionId, session, callback) {
|
|
18
|
+
const payload = JSON.stringify(session);
|
|
19
|
+
redis
|
|
20
|
+
.set(keyOf(sessionId), payload, 'EX', ttlOf(session))
|
|
21
|
+
.then(() => callback(null))
|
|
22
|
+
.catch((err) => callback(err));
|
|
23
|
+
},
|
|
24
|
+
get(sessionId, callback) {
|
|
25
|
+
redis
|
|
26
|
+
.get(keyOf(sessionId))
|
|
27
|
+
.then((raw) => callback(null, raw ? JSON.parse(raw) : null))
|
|
28
|
+
.catch((err) => callback(err));
|
|
29
|
+
},
|
|
30
|
+
destroy(sessionId, callback) {
|
|
31
|
+
redis
|
|
32
|
+
.del(keyOf(sessionId))
|
|
33
|
+
.then(() => callback(null))
|
|
34
|
+
.catch((err) => callback(err));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/routes.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
2
|
+
/** 실행 레이어가 소비하는 라우트 엔트리. path 는 앱 프리픽스 이전의 상대 경로. */
|
|
3
|
+
export interface RouteEntry {
|
|
4
|
+
readonly method: HttpMethod;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
readonly controller: string;
|
|
7
|
+
readonly action: string;
|
|
8
|
+
}
|
|
2
9
|
export interface RouteBuilder {
|
|
3
|
-
/** Rails 식 리소스
|
|
10
|
+
/** Rails 식 복수 리소스 — index/new/create/show/edit/update/destroy. */
|
|
4
11
|
resources(name: string): void;
|
|
5
|
-
/** 단수 리소스(세션 등). */
|
|
12
|
+
/** 단수 리소스(세션 등) — new/create/show/update/destroy (index·:id 없음). */
|
|
6
13
|
resource(name: string): void;
|
|
7
14
|
get(path: string, target: string): void;
|
|
8
15
|
post(path: string, target: string): void;
|
|
@@ -11,12 +18,10 @@ export interface RouteBuilder {
|
|
|
11
18
|
delete(path: string, target: string): void;
|
|
12
19
|
}
|
|
13
20
|
export interface RouteDef {
|
|
14
|
-
/**
|
|
21
|
+
/** 참조 컨트롤러 이름들(정렬·중복제거). 타입 브리지 입력. */
|
|
15
22
|
readonly controllers: readonly string[];
|
|
23
|
+
/** 실행 레이어용 라우트 엔트리(선언 순서). */
|
|
24
|
+
readonly entries: readonly RouteEntry[];
|
|
16
25
|
}
|
|
17
|
-
/**
|
|
18
|
-
* 라우트 정의 빌더를 실행해 참조 컨트롤러 목록을 수집한다.
|
|
19
|
-
* `get('/about', 'pages#about')` 처럼 `controller#action` 대상은
|
|
20
|
-
* `#` 앞부분(컨트롤러)만 취한다.
|
|
21
|
-
*/
|
|
26
|
+
/** 라우트 정의 빌더를 실행해 컨트롤러 목록과 라우트 엔트리를 수집한다. */
|
|
22
27
|
export declare function routes(build: (r: RouteBuilder) => void): RouteDef;
|
package/dist/routes.js
CHANGED
|
@@ -1,30 +1,63 @@
|
|
|
1
|
-
// @gaonjs/web · 라우팅 DSL (
|
|
1
|
+
// @gaonjs/web · 라우팅 DSL (§5.1)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 타입 브리지(routes.d.ts)
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
// `routes((r) => { r.resources('posts') })` 형태다. 두 소비자가 있다:
|
|
4
|
+
// · 타입 브리지(routes.d.ts 생성기): 어느 컨트롤러가 참조되는지(controllers).
|
|
5
|
+
// · 실행 레이어(dispatch): method·path·controller·action 엔트리(entries).
|
|
6
|
+
//
|
|
7
|
+
// resources/resource 는 Rails 관례로 표준 액션을 전개한다. 실제로 어떤
|
|
8
|
+
// 액션이 등록되는지는 디스패처가 컨트롤러에 그 액션이 구현돼 있는지로
|
|
9
|
+
// 거른다 — 라우트 표는 후보이고, 없는 액션은 라우팅되지 않는다.
|
|
10
|
+
function resourcesEntries(name) {
|
|
11
|
+
return [
|
|
12
|
+
{ method: 'get', path: `/${name}`, controller: name, action: 'index' },
|
|
13
|
+
{ method: 'get', path: `/${name}/new`, controller: name, action: 'new' },
|
|
14
|
+
{ method: 'post', path: `/${name}`, controller: name, action: 'create' },
|
|
15
|
+
{ method: 'get', path: `/${name}/:id`, controller: name, action: 'show' },
|
|
16
|
+
{ method: 'get', path: `/${name}/:id/edit`, controller: name, action: 'edit' },
|
|
17
|
+
{ method: 'patch', path: `/${name}/:id`, controller: name, action: 'update' },
|
|
18
|
+
{ method: 'delete', path: `/${name}/:id`, controller: name, action: 'destroy' },
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
function resourceEntries(name) {
|
|
22
|
+
return [
|
|
23
|
+
{ method: 'get', path: `/${name}/new`, controller: name, action: 'new' },
|
|
24
|
+
{ method: 'post', path: `/${name}`, controller: name, action: 'create' },
|
|
25
|
+
{ method: 'get', path: `/${name}`, controller: name, action: 'show' },
|
|
26
|
+
{ method: 'patch', path: `/${name}`, controller: name, action: 'update' },
|
|
27
|
+
{ method: 'delete', path: `/${name}`, controller: name, action: 'destroy' },
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
/** `controller#action` 대상을 파싱한다. action 생략 시 관례로 채우지 않고 그대로 둔다. */
|
|
31
|
+
function parseTarget(target) {
|
|
32
|
+
const [controller, action] = target.split('#');
|
|
33
|
+
return { controller: (controller ?? '').trim(), action: (action ?? '').trim() };
|
|
34
|
+
}
|
|
35
|
+
/** 라우트 정의 빌더를 실행해 컨트롤러 목록과 라우트 엔트리를 수집한다. */
|
|
12
36
|
export function routes(build) {
|
|
13
37
|
const controllers = new Set();
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
38
|
+
const entries = [];
|
|
39
|
+
const manual = (method, path, target) => {
|
|
40
|
+
const { controller, action } = parseTarget(target);
|
|
41
|
+
if (controller) {
|
|
42
|
+
controllers.add(controller);
|
|
43
|
+
entries.push({ method, path, controller, action });
|
|
44
|
+
}
|
|
18
45
|
};
|
|
19
46
|
const r = {
|
|
20
|
-
resources: (name) =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
47
|
+
resources: (name) => {
|
|
48
|
+
controllers.add(name);
|
|
49
|
+
entries.push(...resourcesEntries(name));
|
|
50
|
+
},
|
|
51
|
+
resource: (name) => {
|
|
52
|
+
controllers.add(name);
|
|
53
|
+
entries.push(...resourceEntries(name));
|
|
54
|
+
},
|
|
55
|
+
get: (p, t) => manual('get', p, t),
|
|
56
|
+
post: (p, t) => manual('post', p, t),
|
|
57
|
+
put: (p, t) => manual('put', p, t),
|
|
58
|
+
patch: (p, t) => manual('patch', p, t),
|
|
59
|
+
delete: (p, t) => manual('delete', p, t),
|
|
27
60
|
};
|
|
28
61
|
build(r);
|
|
29
|
-
return { controllers: [...controllers].sort() };
|
|
62
|
+
return { controllers: [...controllers].sort(), entries };
|
|
30
63
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @gaonjs/web · 응답 경계 직렬화 (render → Inertia props 변환)
|
|
2
|
+
//
|
|
3
|
+
// 컨트롤러가 render(page, props) 로 넘긴 props 는 그대로 JSON 이 아니다(§4.4):
|
|
4
|
+
// 모델 레코드에는 메서드·관계 함수가 붙어 있고, Date·bigint 는 JSON 위에서
|
|
5
|
+
// 문자열이 되며(bigint 는 JSON.stringify 가 던진다), hidden 컬럼(예:
|
|
6
|
+
// passwordDigest)은 노출되면 안 된다(§4.2).
|
|
7
|
+
//
|
|
8
|
+
// 이 변환은 프론트엔드 중립이다(§6.4 — 서버 계약은 "페이지 이름 + 직렬화된
|
|
9
|
+
// props" 뿐). 따라서 render→응답을 소유한 web 이 런타임을 갖고, @gaonjs/vue
|
|
10
|
+
// 는 이 JSON 모양에 대응하는 타입 매핑(Serialized<T>)과 클라이언트 이음새를
|
|
11
|
+
// 담당한다. hidden 판정 지식(HIDDEN_COLUMNS 마커)의 출처는 @gaonjs/core 다.
|
|
12
|
+
import { HIDDEN_COLUMNS } from '@gaonjs/core';
|
|
13
|
+
/**
|
|
14
|
+
* 컨트롤러 props 를 JSON-safe 페이지 props 로 변환한다.
|
|
15
|
+
* Date→ISO 문자열, bigint→문자열, 함수 제거, hidden 컬럼 제외, 중첩 재귀.
|
|
16
|
+
*/
|
|
17
|
+
export function serializeProps(value) {
|
|
18
|
+
return serialize(value);
|
|
19
|
+
}
|
|
20
|
+
function serialize(value) {
|
|
21
|
+
if (value === null || value === undefined)
|
|
22
|
+
return value;
|
|
23
|
+
if (typeof value === 'bigint')
|
|
24
|
+
return value.toString();
|
|
25
|
+
if (value instanceof Date)
|
|
26
|
+
return value.toISOString();
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return value.map(serialize);
|
|
29
|
+
if (typeof value === 'object') {
|
|
30
|
+
// 모델 레코드는 HIDDEN_COLUMNS 마커(비열거)로 제외할 컬럼명을 싣는다.
|
|
31
|
+
const hidden = value[HIDDEN_COLUMNS];
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const key of Object.keys(value)) {
|
|
34
|
+
if (hidden && hidden.includes(key))
|
|
35
|
+
continue;
|
|
36
|
+
const v = value[key];
|
|
37
|
+
if (typeof v === 'function')
|
|
38
|
+
continue; // 메서드·관계 함수는 직렬화하지 않는다
|
|
39
|
+
out[key] = serialize(v);
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
if (typeof value === 'function')
|
|
44
|
+
return undefined;
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { Redis } from 'ioredis';
|
|
3
|
+
export interface AppSessionOptions {
|
|
4
|
+
/** 세션 스토어 백엔드(Redis 인스턴스). redisUrl 과 택일. 앱별 prefix 로 분리된다. */
|
|
5
|
+
readonly redis?: Redis;
|
|
6
|
+
/** Redis 접속 URL. redis 인스턴스 대신 이걸 주면 프레임웍이 클라이언트를 만든다(스캐폴드 DX). */
|
|
7
|
+
readonly redisUrl?: string;
|
|
8
|
+
/** 서명 secret — 32자 이상(@fastify/session 요구). 앱마다 달라야 분리가 완성된다. */
|
|
9
|
+
readonly secret: string;
|
|
10
|
+
/** 세션 쿠키 이름. 생략 시 `<app>_sid`. */
|
|
11
|
+
readonly cookieName?: string;
|
|
12
|
+
/** Redis 키 prefix. 생략 시 `gaon:sess:<app>:`. */
|
|
13
|
+
readonly redisPrefix?: string;
|
|
14
|
+
/** 세션 만료(초). 기본 7일. */
|
|
15
|
+
readonly ttlSeconds?: number;
|
|
16
|
+
/** 쿠키 Secure. 기본 NODE_ENV==='production'. */
|
|
17
|
+
readonly secure?: boolean;
|
|
18
|
+
/** SameSite. 기본 'lax'. */
|
|
19
|
+
readonly sameSite?: 'lax' | 'strict' | 'none';
|
|
20
|
+
/** CSRF 보호. 기본 켬(§2.5.1). 명시적으로만 끈다. */
|
|
21
|
+
readonly csrf?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* 한 앱 스코프에 세션(+CSRF)을 등록한다. createApp 이 앱별 캡슐화 스코프
|
|
25
|
+
* 안에서 호출하므로, 세션·쿠키·CSRF 가 그 앱의 라우트에만 적용된다.
|
|
26
|
+
*/
|
|
27
|
+
export declare function registerSession(scope: FastifyInstance, appName: string, prefix: string, opts: AppSessionOptions): Promise<void>;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @gaonjs/web · 세션 배터리 (앱별 완전 분리 + CSRF)
|
|
2
|
+
//
|
|
3
|
+
// 세션 쿠키가 기본 인증 방식이다(§7 · 질문 4). 앱별로 세션을 **완전히
|
|
4
|
+
// 분리**한다(질문 13 — 앱 간 로그인 공유 불허): 각 앱은 자기만의
|
|
5
|
+
// · 세션 쿠키 이름(`<app>_sid`)
|
|
6
|
+
// · 서명 secret
|
|
7
|
+
// · Redis 키 prefix
|
|
8
|
+
// · 쿠키 path(앱 프리픽스)
|
|
9
|
+
// 를 갖는다. 이는 Fastify 캡슐화 스코프(createApp 이 앱마다 register)에서
|
|
10
|
+
// 세션 플러그인을 앱 단위로 등록해 달성한다.
|
|
11
|
+
//
|
|
12
|
+
// CSRF 는 보안 기본값으로 켠다(§2.5.1). 세션에 시크릿을 두고(@fastify/session
|
|
13
|
+
// 연동), 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 토큰 검증을 강제한다.
|
|
14
|
+
// 토큰은 X-CSRF-TOKEN 헤더(Inertia/axios 관례) 또는 폼 _csrf 로 받는다.
|
|
15
|
+
import { Redis } from 'ioredis';
|
|
16
|
+
import session from '@fastify/session';
|
|
17
|
+
import csrf from '@fastify/csrf-protection';
|
|
18
|
+
import { redisSessionStore } from './redisStore.js';
|
|
19
|
+
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60; // 7일
|
|
20
|
+
const UNSAFE = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
21
|
+
function csrfToken(req) {
|
|
22
|
+
const header = req.headers['x-csrf-token'];
|
|
23
|
+
if (typeof header === 'string')
|
|
24
|
+
return header;
|
|
25
|
+
const body = req.body;
|
|
26
|
+
if (body && typeof body._csrf === 'string')
|
|
27
|
+
return body._csrf;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 한 앱 스코프에 세션(+CSRF)을 등록한다. createApp 이 앱별 캡슐화 스코프
|
|
31
|
+
* 안에서 호출하므로, 세션·쿠키·CSRF 가 그 앱의 라우트에만 적용된다.
|
|
32
|
+
*/
|
|
33
|
+
export async function registerSession(scope, appName, prefix, opts) {
|
|
34
|
+
const ttlSeconds = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
35
|
+
const secure = opts.secure ?? process.env.NODE_ENV === 'production';
|
|
36
|
+
// redis 인스턴스 우선, 없으면 redisUrl 로 만든다(프레임웍 소유 → onClose 에서 정리).
|
|
37
|
+
let redis = opts.redis;
|
|
38
|
+
if (!redis) {
|
|
39
|
+
if (!opts.redisUrl) {
|
|
40
|
+
throw new Error(`[@gaonjs/web] 세션 설정에 redis 인스턴스나 redisUrl 이 필요합니다.\n` +
|
|
41
|
+
`→ app.config 의 session 에 { redisUrl: 'redis://127.0.0.1:6379' } 를 추가하세요.`);
|
|
42
|
+
}
|
|
43
|
+
redis = new Redis(opts.redisUrl);
|
|
44
|
+
scope.addHook('onClose', async () => {
|
|
45
|
+
await redis.quit();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
await scope.register(session, {
|
|
49
|
+
secret: opts.secret,
|
|
50
|
+
cookieName: opts.cookieName ?? `${appName}_sid`,
|
|
51
|
+
saveUninitialized: false,
|
|
52
|
+
store: redisSessionStore(redis, {
|
|
53
|
+
prefix: opts.redisPrefix ?? `gaon:sess:${appName}:`,
|
|
54
|
+
ttlSeconds,
|
|
55
|
+
}),
|
|
56
|
+
cookie: {
|
|
57
|
+
httpOnly: true,
|
|
58
|
+
secure,
|
|
59
|
+
sameSite: opts.sameSite ?? 'lax',
|
|
60
|
+
path: prefix, // 쿠키를 앱 프리픽스로 스코프 — 분리 방어 한 겹 더
|
|
61
|
+
maxAge: ttlSeconds * 1000,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
if (opts.csrf !== false) {
|
|
65
|
+
await scope.register(csrf, {
|
|
66
|
+
sessionPlugin: '@fastify/session',
|
|
67
|
+
getToken: csrfToken,
|
|
68
|
+
});
|
|
69
|
+
// 상태 변경 메서드에 토큰 검증을 강제한다(안전 메서드는 통과).
|
|
70
|
+
scope.addHook('preHandler', (req, reply, done) => {
|
|
71
|
+
if (!UNSAFE.has(req.method))
|
|
72
|
+
return done();
|
|
73
|
+
scope.csrfProtection(req, reply, done);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
2
|
+
import type { ChannelDef, ChannelRuntime } from '@gaonjs/async';
|
|
3
|
+
/**
|
|
4
|
+
* @fastify/websocket 을 등록한다. 전역 upgrade 훅을 걸므로 서버당 **한 번만**
|
|
5
|
+
* (루트에서) 호출해야 한다. 라우트(registerChannels)는 이후 어느 스코프에서든
|
|
6
|
+
* 정의할 수 있다.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerWebSocketSupport(app: FastifyInstance): Promise<void>;
|
|
9
|
+
export type ChannelMap = Record<string, ChannelDef<unknown>>;
|
|
10
|
+
/** 연결 주체(멤버 식별자 + 인증 사용자). */
|
|
11
|
+
export interface MemberIdentity {
|
|
12
|
+
readonly member: string;
|
|
13
|
+
readonly user: unknown;
|
|
14
|
+
}
|
|
15
|
+
export type MemberResolver = (request: FastifyRequest) => Promise<MemberIdentity> | MemberIdentity;
|
|
16
|
+
export interface RegisterChannelsOptions {
|
|
17
|
+
readonly runtime: ChannelRuntime;
|
|
18
|
+
readonly channels: ChannelMap;
|
|
19
|
+
/** 연결 주체 해석(세션·JWT). 생략 시 세션 userId 또는 익명. */
|
|
20
|
+
readonly resolveMember?: MemberResolver;
|
|
21
|
+
/** ws 라우트 경로(스코프 프리픽스 이후). 기본 '/gaon/ws/:channel'. */
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* 앱 스코프에 채널 ws 라우트를 등록한다. 연결마다 멤버를 해석하고 런타임에
|
|
26
|
+
* join 을 위임하며, 소켓 메시지를 채널 정의의 onMessage 로 흘린다.
|
|
27
|
+
*/
|
|
28
|
+
export declare function registerChannels(scope: FastifyInstance, opts: RegisterChannelsOptions): void;
|