@gaonjs/cli 0.28.0 → 0.29.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.
@@ -154,7 +154,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
154
154
  | 상황 | 경로 | 근거 |
155
155
  |---|---|---|
156
156
  | 지금 페이지의 데이터를 다시 받기 (필터 변경·새로고침·무한 스크롤) | **Inertia partial reload** — 같은 액션 재호출, 필요한 props만 | §6.1 |
157
- | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) | §7 |
157
+ | 서버가 먼저 밀어주는 데이터 (알림·채팅·접속자) | **채널/프레즌스** (`agents/realtime.md`) — 서버 개시는 `broadcast(name,data)`, 클라 메시지 응답은 `ctx.broadcast` | §7 |
158
158
  | 페이지와 무관한 데이터 요청 (자동완성·옵션 조회 등 앱 내부용) | **JSON 액션 + `api()` 클라이언트** (`agents/web.md`·`agents/frontend.md`) | E-3 |
159
159
  | 외부에 공개하는 API (모바일 앱·서드파티) | **별도 API 앱 + JWT 옵션** | §3, §7 |
160
160
 
@@ -200,7 +200,7 @@ gaon doctor # 정적 검사 24종 (§2.2)
200
200
  |---|---|
201
201
  | `gaon new <name>` | 프로젝트 스캐폴드 |
202
202
  | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**코드 변경 감시·재시작**) |
203
- | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** |
203
+ | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
204
204
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
205
205
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
206
206
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -217,6 +217,10 @@ gaon doctor # 정적 검사 24종 (§2.2)
217
217
  개발 중이면 `gaon dev`(감시·재시작·`.gaon` 재생성 통합)를 쓴다. `serve` 는
218
218
  비-production 부팅 시 이 안내를 한 줄 출력한다.
219
219
 
220
+ **스케일링 구분** — `serve --workers N`(한 포트 · node:cluster 수직) vs 웹 인스턴스
221
+ 여러 대(각각 다른 `PORT` · 허브 뒤 수평) vs `work`(포트 없음 · 프로세스만)는 서로
222
+ 다르다. 로컬 멀티 인스턴스 레시피·구분표는 `docs/guides/operations.md` "스케일링" 절 참고.
223
+
220
224
  ## 5. npm 배포본 — 패키지 → 역할
221
225
 
222
226
  개발자는 파사드 **`gaonjs`** 하나만 설치한다(CLI 명령 `gaon`). 아래는 내부 패키지의 역할 지도다.
@@ -74,6 +74,37 @@ export default channel({
74
74
 
75
75
  멤버 식별자는 로그인 사용자면 `user:<id>`, 익명이면 `conn:<uuid>` 다.
76
76
 
77
+ ### 2.5 서버 개시 broadcast (결정 126)
78
+
79
+ `ctx.broadcast` 는 클라이언트 연결 훅(`onJoin`/`onMessage`/`onLeave`) **안에서만** 쓸 수 있다 —
80
+ 클라 메시지가 있어야 도는 경로다. **컨트롤러·서비스·잡처럼 서버가 클라 메시지 없이 채널을 밀
81
+ 때는 `gaonjs/async` 의 `broadcast(name, data)`** 를 쓴다 — 이름으로 채널을 지목해 전 서버·전
82
+ 구독자에게 발화한다(여러 인스턴스 자동 팬아웃 · errata E-2).
83
+
84
+ ```ts
85
+ // apps/web/controllers/posts.ts — service·job·listener 어디서든 동일하게 호출
86
+ import { controller } from 'gaonjs/web'
87
+ import { broadcast } from 'gaonjs/async'
88
+
89
+ export default controller({
90
+ async create() {
91
+ const post = await Post.create(this.params(Post.form))
92
+ broadcast('feed', { type: 'new-post', id: String(post.id) }) // 클라 메시지 불필요
93
+ return { id: post.id }
94
+ },
95
+ })
96
+ ```
97
+
98
+ - **`authorize` 재실행 없음** — `authorize` 는 **구독(연결 수립) 시점** 게이트다(§2). 서버 발화
99
+ broadcast 는 authorize 를 **다시 실행하지 않는다** — 이미 접속(= 인가 통과)한 구독자만 받고,
100
+ broadcast 는 신뢰된 서버 코드가 그 대상에게 미는 행위다. 특정 사용자에게만 보내야 하면 **채널을
101
+ 그렇게 분리**(예: `authorize` 로 소유자만 입장)하고 그 채널로 broadcast 한다.
102
+ - **payload = `unknown`**(자유 JSON) · 클라 `useChannel` 이 `{ t:'msg', data }` 로 받는다.
103
+ - **seal(결정 121)** — 재봉인은 각 수신 서버의 소켓 경계에서 일어난다. broadcast 는 기존 전달
104
+ 경로를 그대로 재사용하므로 seal 앱에서도 봉인된다(추가 처리 불필요).
105
+ - **realtime(NATS) 미설정 앱**에서 호출하면 수리 안내와 함께 throw · **구독자 없음**이면 조용히
106
+ 아무 데도 안 간다(fire-and-forget · 예외 아님).
107
+
77
108
  ### 3. 프레즌스
78
109
 
79
110
  `ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
@@ -136,10 +167,15 @@ export function useRoom(roomId: number) {
136
167
 
137
168
  서버가 먼저 밀어주는 데이터(알림·채팅·접속자)는 채널/프레즌스가 정답
138
169
  경로다 (루트 데이터 경로 판단표 2행). 폴링 `api()` 루프로 흉내내지
139
- 않는다.
170
+ 않는다. 그 안에서 **누가 발화하느냐**로 표면이 갈린다:
171
+
172
+ | 발화 주체 | 표면 | 쓰는 곳 |
173
+ | --- | --- | --- |
174
+ | 클라 메시지에 응답 | `ctx.broadcast(data)` | 채널 훅 `onMessage`(클라 메시지 필요) |
175
+ | 서버가 단독으로 밀기 | `broadcast(name, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (클라 메시지 없이 · 결정 126) |
140
176
 
141
177
  ```ts
142
- // apps/web/channels/chatMessages.ts — 파일명 camelCase
178
+ // apps/web/channels/chatMessages.ts — 파일명 camelCase · 클라 메시지 응답형
143
179
  import { channel } from 'gaonjs/async'
144
180
 
145
181
  export default channel({
@@ -149,6 +185,9 @@ export default channel({
149
185
  })
150
186
  ```
151
187
 
188
+ 서버 개시(HTTP 요청·잡 처리 결과 등)로 미는 경우는 §2.5 `broadcast(name, data)` 를 쓴다 —
189
+ "flash 로 클라에 심고 클라가 다시 채널로 중계" 같은 우회는 **반정본**이다(탭 닫힘에 구멍 · 결정 126).
190
+
152
191
  ## 알려진 함정
153
192
 
154
193
  - **채널 파일 위치는 `apps/<앱>/channels/`** — domain 이 아니다 (채널은
@@ -170,6 +209,7 @@ export default channel({
170
209
  |---|---|
171
210
  | E-2 | 웹서버 ↔ 허브 = TCP 지속 연결 · NATS = broadcast 전용 |
172
211
  | §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
212
+ | 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
173
213
 
174
214
  ## `@gaonjs/seal` 켠 앱의 채널
175
215
 
@@ -73,13 +73,17 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
73
73
  - **요청/응답 JSON**: 클라 `installClientSeal()` 이 Inertia XHR 인터셉터(`XMLHttpRequest.prototype`) +
74
74
  `api()`/`fetch` 봉인을 설치한다. 서버는 Fastify **4-stage 훅**(`plugin.ts` · onRequest 분류/fail-closed →
75
75
  preParsing body 개봉+replay → preValidation query `?q=` 개봉+replay → onSend 응답 봉인)으로 대칭 복호.
76
- **JSON-intent 판별**: `Accept`/`Content-Type: application/json` 요청만 봉인 강제(비-JSON HTML·form 은 자동 면제).
76
+ **봉인 대상 판별 (결정 125)**: `Accept`/`Content-Type: application/json` **또는** `X-Inertia: true`(Inertia
77
+ 방문·네비게이션) 요청을 봉인 강제한다. Inertia GET 네비게이션은 `Accept: text/html` + `X-Inertia:true` 로
78
+ 와서 application/json 이 없으므로, 이 헤더까지 봐야 네비게이션 props 가 평문으로 새지 않는다(결정 125 P0).
79
+ 네이티브 브라우저 form·정적 자산·HTML 직접 로드는 셋 다 없어 자동 면제된다.
77
80
  - **최초 문서 data-page**: 서버가 `<script data-page="app" data-gaon-sealed="1">` 로 봉인 + `<meta gaon-seal-ts>`.
78
81
  클라 `createGaonApp` 이 Inertia 마운트 **전**에 wasm 으로 개봉 → 소스 보기·개발자도구에 평문 props 미노출.
79
82
  - **WS 프레임 (결정 124 · §3.4)**: `useChannel` 이 `setWsFrameCodec`(seal 클라 `wsEncode`/`wsDecode`)로
80
83
  채널 송수신을 봉인한다. 송신 `E:<ts>:<base64>` · seal namespace 는 평문 `P:` 프레임 **거부**(requireDecrypt · 결정 121).
81
- - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비-JSON **자동 제외**(사람 판단 없이
82
- Content-Type 기계 판별). 외부(웹훅 등)가 봉인을 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
84
+ - **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON Inertia 아닌 HTML
85
+ 직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
86
+ 모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
83
87
  - **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
84
88
  **403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 소켓 4500 종료(silent fallback 없음).
85
89
 
@@ -129,3 +133,4 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
129
133
 
130
134
  - **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
131
135
  - **결정 124** — §3.1 개정: **app-side 정적 주입**(변수 동적 import 폐기) · **wasm 표면 은닉**(불투명 함수 · domain/ua/path 를 wasm 이 확보 · 미끼 시크릿 내장) · **WS 클라 봉인**(`setWsFrameCodec`) · **seal 앱 한정 CSP** · doctor `seal-security` main.ts 배선 검사 + `seal-client-wiring` fixer · 실 브라우저 e2e 게이트 · 부수 정정(`.wasm` MIME · `session.csrf` forwarding).
136
+ - **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
@@ -88,6 +88,11 @@
88
88
  작동한다. 토큰·다이제스트·개인정보 컬럼은 선언 시점에 hidden.
89
89
  - **`presenceInfo`** — 접속자 목록은 채널 전원에게 공개된다. 공개 메타만
90
90
  (`agents/realtime.md`).
91
+ - **서버 개시 `broadcast(name, data)` 는 `authorize` 를 재실행하지 않는다** (결정 126) —
92
+ 채널 `authorize` 는 **구독(연결) 시점** 게이트다. 서버 발화 broadcast 는 이미 접속(= 인가
93
+ 통과)한 구독자에게만 도달하고 authorize 를 다시 돌리지 않는다. 수신 대상을 제한하려면
94
+ **채널을 그렇게 분리**(`authorize` 로 소유자·역할만 입장)하고 그 채널로 broadcast 한다 —
95
+ broadcast 자체에 대상 필터는 없다(`agents/realtime.md` §2.5).
91
96
 
92
97
  ### 6. 클라이언트 IP · 프록시 신뢰 (결정 120)
93
98
 
@@ -3,6 +3,12 @@
3
3
  #
4
4
  # 기동: docker compose up -d (gaon dev 가 자동 실행)
5
5
  # 정지: docker compose down
6
+ #
7
+ # 한 머신에서 gaon 프로젝트를 **여러 개** 동시에 개발할 때: `name:` 이 프로젝트별로 고유해
8
+ # 컨테이너·볼륨·네트워크는 서로 섞이지 않는다(다른 프로젝트 컨테이너를 recreate 하지 않음).
9
+ # 다만 아래 **호스트 포트**(5432·6379·4222 …)는 머신에 하나뿐이라 두 스택을 동시에 띄우면
10
+ # 충돌한다. 그럴 땐 한쪽에서 왼쪽(호스트) 포트만 바꾸고(예: "5433:5432"), 그 프로젝트의
11
+ # `gaon.config.ts` 접속 URL 포트도 같은 값으로 맞춘다. 오른쪽(컨테이너) 포트는 그대로 둔다.
6
12
  name: {{PROJECT_NAME}}
7
13
 
8
14
  services:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,12 +27,12 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
+ "@gaonjs/async": "0.7.0",
31
+ "@gaonjs/config": "0.9.1",
30
32
  "@gaonjs/data": "0.13.1",
31
- "@gaonjs/async": "0.6.1",
32
- "@gaonjs/core": "0.2.1",
33
- "@gaonjs/web": "0.11.0",
34
- "@gaonjs/config": "0.9.0",
35
- "@gaonjs/mail": "0.1.3"
33
+ "@gaonjs/mail": "0.1.3",
34
+ "@gaonjs/web": "0.12.0",
35
+ "@gaonjs/core": "0.2.1"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""