@gaonjs/web 0.2.0 → 0.2.2

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 CHANGED
@@ -1,9 +1,11 @@
1
1
  import { type FastifyInstance, type FastifyServerOptions } from 'fastify';
2
2
  import { type FastifyCorsOptions } from '@fastify/cors';
3
+ import { type GaonNats } from '@gaonjs/async';
3
4
  import type { RouteDef } from './routes.js';
4
5
  import { type DispatchOptions } from './dispatch.js';
5
6
  import { type AppSessionOptions } from './session.js';
6
7
  import { type AuthOptions, type JwtAuthOptions } from './auth.js';
8
+ import { type ChannelMap } from './websocket.js';
7
9
  /** 한 앱 정의 — 프리픽스(생략 시 관례)·라우트·컨트롤러 맵. */
8
10
  export interface AppSpec {
9
11
  readonly name: string;
@@ -20,6 +22,11 @@ export interface AppSpec {
20
22
  readonly auth?: AuthOptions | JwtAuthOptions;
21
23
  /** 요청별 컨텍스트 확장(추가 훅). */
22
24
  readonly dispatch?: DispatchOptions;
25
+ /**
26
+ * 실시간 채널(§7 · M6). apps/<app>/channels/<이름>.ts 의 default export 를
27
+ * 이름별로 모은 맵. realtime(NATS)이 설정된 경우에만 활성.
28
+ */
29
+ readonly channels?: ChannelMap;
23
30
  }
24
31
  export interface SecurityOptions {
25
32
  /** CORS 설정. 기본은 same-origin 만 허용(origin:false). false 로 완전 비활성. */
@@ -38,6 +45,19 @@ export interface CreateAppOptions {
38
45
  readonly cookieSecret?: string;
39
46
  readonly security?: SecurityOptions;
40
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
+ /** 허브 TCP 주소 'host:port'(errata E-2). 생략 시 NATS KV 공지 발견. */
58
+ readonly hubAddr?: string;
59
+ readonly heartbeatMs?: number;
60
+ };
41
61
  }
42
62
  /**
43
63
  * 앱 스펙들로 Fastify 인스턴스를 만든다. 쿠키·CORS·rate limit(보안 기본값)을
package/dist/app.js CHANGED
@@ -10,9 +10,14 @@ 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 { hostname } from 'node:os';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { createChannelRuntime } from '@gaonjs/async';
13
16
  import { prefixFor, registerApp } from './dispatch.js';
14
17
  import { registerSession } from './session.js';
15
18
  import { jwtAuthAugment, sessionAuthAugment } from './auth.js';
19
+ import { registerChannels, registerWebSocketSupport, } from './websocket.js';
20
+ import { verifyToken } from './jwt.js';
16
21
  /**
17
22
  * 앱 스펙들로 Fastify 인스턴스를 만든다. 쿠키·CORS·rate limit(보안 기본값)을
18
23
  * 켜고, 각 앱의 라우트를 프리픽스 아래 등록한다. listen 은 호출자가 한다.
@@ -20,6 +25,22 @@ import { jwtAuthAugment, sessionAuthAugment } from './auth.js';
20
25
  export async function createApp(opts) {
21
26
  const app = Fastify(opts.fastify);
22
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
+ hubAddr: opts.realtime.hubAddr,
36
+ heartbeatMs: opts.realtime.heartbeatMs,
37
+ });
38
+ // 전역 upgrade 훅은 루트에서 한 번만 — 채널 라우트는 앱 스코프에서 정의.
39
+ await registerWebSocketSupport(app);
40
+ app.addHook('onClose', async () => {
41
+ await runtime?.close();
42
+ });
43
+ }
23
44
  // 쿠키 — 세션·CSRF·서명 쿠키의 토대. secret 은 서명 쿠키에만 필요.
24
45
  await app.register(cookie, { secret: opts.cookieSecret ?? process.env.GAON_COOKIE_SECRET });
25
46
  // CORS — 기본 same-origin(origin:false). 크로스 오리진은 명시 설정으로만.
@@ -39,6 +60,14 @@ export async function createApp(opts) {
39
60
  if (spec.session)
40
61
  await registerSession(scope, spec.name, prefix, spec.session);
41
62
  registerApp(scope, { name: spec.name, routes: spec.routes, controllers: spec.controllers }, dispatchOptionsFor(spec, opts.version));
63
+ // 채널(ws)은 세션과 같은 스코프에 등록 — 연결이 앱 세션으로 인증된다.
64
+ if (runtime && spec.channels && Object.keys(spec.channels).length > 0) {
65
+ registerChannels(scope, {
66
+ runtime,
67
+ channels: spec.channels,
68
+ resolveMember: memberResolverFor(spec),
69
+ });
70
+ }
42
71
  }, { prefix });
43
72
  }
44
73
  return app;
@@ -61,3 +90,35 @@ function dispatchOptionsFor(spec, version) {
61
90
  : undefined;
62
91
  return { version, loginRedirect, ...spec.dispatch, augment };
63
92
  }
93
+ /**
94
+ * 채널 연결의 멤버/사용자를 앱 인증 방식에 맞춰 해석한다. 세션 앱은
95
+ * session.userId 로, JWT 앱은 쿼리(access_token)로 인증한다(웹소켓은
96
+ * 커스텀 헤더를 못 실으므로 API 앱은 쿼리 토큰이 관례다). 비로그인은
97
+ * 익명 연결(공개 채널 허용).
98
+ */
99
+ function memberResolverFor(spec) {
100
+ const auth = spec.auth;
101
+ if (!auth)
102
+ return undefined;
103
+ if ('strategy' in auth && auth.strategy === 'jwt') {
104
+ return async (request) => {
105
+ const token = request.query?.access_token;
106
+ if (typeof token === 'string') {
107
+ const payload = await verifyToken(auth.secret, token);
108
+ if (payload && payload.typ === 'access' && typeof payload.sub === 'string') {
109
+ const user = (await auth.loadUser(payload.sub)) ?? null;
110
+ return { member: `user:${payload.sub}`, user };
111
+ }
112
+ }
113
+ return { member: `conn:${randomUUID()}`, user: null };
114
+ };
115
+ }
116
+ const sessionAuth = auth;
117
+ return async (request) => {
118
+ const uid = request.session?.userId;
119
+ if (uid == null)
120
+ return { member: `conn:${randomUUID()}`, user: null };
121
+ const user = (await sessionAuth.loadUser(uid)) ?? null;
122
+ return { member: `user:${String(uid)}`, user };
123
+ };
124
+ }
package/dist/index.d.ts CHANGED
@@ -11,3 +11,4 @@ export { signToken, verifyToken, type TokenType } from "./jwt.js";
11
11
  export { hashPassword, verifyPassword } from "./password.js";
12
12
  export { renderInertia, inertiaRedirect, type InertiaPage, type InertiaOptions, } from "./inertia.js";
13
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
@@ -30,3 +30,5 @@ export { hashPassword, verifyPassword } from "./password.js";
30
30
  export { renderInertia, inertiaRedirect, } from "./inertia.js";
31
31
  // 응답 경계 직렬화 (hidden 제외 §4.2)
32
32
  export { serializeProps } from "./serialize.js";
33
+ // 실시간 채널 전송 (WebSocket · §7 M6)
34
+ export { registerChannels, registerWebSocketSupport, } from "./websocket.js";
@@ -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;
@@ -0,0 +1,103 @@
1
+ // @gaonjs/web · WebSocket 전송 계층 (§7 실시간 · M6)
2
+ //
3
+ // 채널의 전송을 @fastify/websocket 으로 붙인다. 채널 정의(@gaonjs/async 의
4
+ // channel())는 전송을 모르고, 여기서 raw WebSocket 을 SocketAdapter 로 감싸
5
+ // ChannelRuntime 에 넘긴다. 라우트는 앱 캡슐화 스코프 안에 등록되므로
6
+ // 세션(M5)이 그대로 살아 있어 연결을 인증할 수 있다.
7
+ //
8
+ // 경로 관례: `<앱 프리픽스>/gaon/ws/:channel`. 채널명은 apps/*/channels/
9
+ // 파일명이다(파일=등록). 알 수 없는 채널은 4404 로 닫는다.
10
+ import websocketPlugin from '@fastify/websocket';
11
+ import { randomUUID } from 'node:crypto';
12
+ /**
13
+ * @fastify/websocket 을 등록한다. 전역 upgrade 훅을 걸므로 서버당 **한 번만**
14
+ * (루트에서) 호출해야 한다. 라우트(registerChannels)는 이후 어느 스코프에서든
15
+ * 정의할 수 있다.
16
+ */
17
+ export async function registerWebSocketSupport(app) {
18
+ await app.register(websocketPlugin);
19
+ }
20
+ /** 세션 userId 로 멤버를 정한다. 비로그인은 익명 연결 id. */
21
+ async function defaultResolveMember(request) {
22
+ const sess = request.session;
23
+ const uid = sess?.userId;
24
+ if (uid != null)
25
+ return { member: `user:${String(uid)}`, user: { id: uid } };
26
+ return { member: `conn:${randomUUID()}`, user: null };
27
+ }
28
+ function queryOf(request) {
29
+ const q = request.query;
30
+ const out = {};
31
+ if (q)
32
+ for (const [k, v] of Object.entries(q))
33
+ if (typeof v === 'string')
34
+ out[k] = v;
35
+ return out;
36
+ }
37
+ /**
38
+ * 앱 스코프에 채널 ws 라우트를 등록한다. 연결마다 멤버를 해석하고 런타임에
39
+ * join 을 위임하며, 소켓 메시지를 채널 정의의 onMessage 로 흘린다.
40
+ */
41
+ export function registerChannels(scope, opts) {
42
+ // @fastify/websocket 은 registerWebSocketSupport 로 루트에서 이미 등록됐다고
43
+ // 가정한다(전역 upgrade 훅 중복 방지). 여기서는 채널 라우트만 정의한다.
44
+ const resolve = opts.resolveMember ?? defaultResolveMember;
45
+ const routePath = opts.path ?? '/gaon/ws/:channel';
46
+ scope.get(routePath, { websocket: true }, (socket, request) => {
47
+ const name = request.params.channel ?? '';
48
+ const def = opts.channels[name];
49
+ if (!def) {
50
+ socket.close(4404, 'unknown channel');
51
+ return;
52
+ }
53
+ const adapter = {
54
+ send: (text) => {
55
+ if (socket.readyState === socket.OPEN)
56
+ socket.send(text);
57
+ },
58
+ close: (code, reason) => socket.close(code, reason),
59
+ };
60
+ // join 은 비동기(허브 왕복). 그 사이 도착한 메시지는 버퍼링했다가 재생한다.
61
+ const pending = [];
62
+ let conn = null;
63
+ let ready = false;
64
+ let closed = false;
65
+ socket.on('message', (raw) => {
66
+ const text = typeof raw === 'string' ? raw : String(raw);
67
+ if (ready && conn)
68
+ void conn.receive(text);
69
+ else
70
+ pending.push(text);
71
+ });
72
+ socket.on('close', () => {
73
+ closed = true;
74
+ if (conn)
75
+ void conn.leave();
76
+ });
77
+ void (async () => {
78
+ const { member, user } = await resolve(request);
79
+ const c = await opts.runtime.join({
80
+ channel: name,
81
+ def,
82
+ member,
83
+ user,
84
+ query: queryOf(request),
85
+ socket: adapter,
86
+ });
87
+ if (!c)
88
+ return; // 인가 거부 — 런타임이 소켓을 닫았다.
89
+ conn = c;
90
+ if (closed) {
91
+ void c.leave(); // join 도중 이미 끊겼으면 즉시 정리.
92
+ return;
93
+ }
94
+ ready = true;
95
+ for (const t of pending)
96
+ void c.receive(t);
97
+ pending.length = 0;
98
+ })().catch((err) => {
99
+ adapter.send(JSON.stringify({ t: 'error', message: err instanceof Error ? err.message : String(err) }));
100
+ socket.close(1011, 'join failed');
101
+ });
102
+ });
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/web",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Gaon 웹 레이어: Fastify 통합·파일 기반 라우팅·보안 기본값 (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,10 +29,12 @@
29
29
  "@fastify/csrf-protection": "^8.0.0",
30
30
  "@fastify/rate-limit": "^11.1.0",
31
31
  "@fastify/session": "^11.1.2",
32
+ "@fastify/websocket": "^11.0.2",
32
33
  "bcryptjs": "^3.0.3",
33
34
  "fastify": "^5.10.0",
34
35
  "ioredis": "^5.11.1",
35
36
  "jose": "^6.2.4",
37
+ "@gaonjs/async": "0.2.1",
36
38
  "@gaonjs/core": "0.1.3"
37
39
  },
38
40
  "scripts": {