@gaonjs/cli 0.40.0 → 0.41.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/commands/new.js +21 -5
- package/dist/db/diff.js +2 -14
- package/dist/db/migrate.js +27 -16
- package/dist/doctor/dependency-direction.d.ts +1 -1
- package/dist/doctor/dependency-direction.js +34 -3
- package/dist/doctor/fixers/index.d.ts +6 -0
- package/dist/doctor/fixers/index.js +76 -0
- package/dist/doctor/route-registration.d.ts +9 -1
- package/dist/doctor/route-registration.js +43 -9
- package/dist/doctor/types.js +1 -1
- package/dist/index.d.ts +13 -0
- package/dist/index.js +81 -31
- package/dist/port.d.ts +6 -0
- package/dist/port.js +21 -0
- package/dist/serve.js +5 -3
- package/dist/templates/project/.env.example.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/CLAUDE.md.tpl +2 -2
- package/dist/templates/project/agents/async.md.tpl +5 -0
- package/dist/templates/project/agents/data.md.tpl +47 -5
- package/dist/templates/project/agents/frontend.md.tpl +6 -2
- package/dist/templates/project/agents/mail.md.tpl +3 -1
- package/dist/templates/project/agents/realtime.md.tpl +47 -1
- package/dist/templates/project/agents/seal.md.tpl +20 -3
- package/dist/templates/project/agents/storage.md.tpl +9 -1
- package/dist/templates/project/agents/web.md.tpl +3 -2
- package/dist/templates/project/apps/web/index.html.tpl +3 -3
- package/dist/templates/project/package.json.tpl +0 -1
- package/package.json +8 -8
package/dist/commands/new.js
CHANGED
|
@@ -40,7 +40,13 @@ function validateProjectName(name) {
|
|
|
40
40
|
` → 예: gaon new my-app · gaon new demo`);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* 파사드(gaonjs) 패키지의 실 버전을 찾는다 — package.json 을 위로 훑는다.
|
|
45
|
+
* 정상 설치(node_modules/gaonjs)·개발 트리(packages/gaonjs) 모두에서 인접
|
|
46
|
+
* package.json 을 상향 탐색하면 반드시 잡힌다. 못 찾으면 **fail-loud**(결정 242):
|
|
47
|
+
* 옛 폴백 `^0.5.0` 은 2년 전 스텁 라인을 조용히 핀해 첫 화면부터 API 불일치로
|
|
48
|
+
* 깨지는 스캐폴드를 낳았다 — 조용한 스테일 핀보다 명확한 실패가 낫다.
|
|
49
|
+
*/
|
|
44
50
|
function detectGaonjsVersion() {
|
|
45
51
|
// 가장 안전한 방법: 이 파일 위치를 기준으로 packages/gaonjs/package.json 을
|
|
46
52
|
// 상향 탐색. 개발 트리(src)와 배포 트리(dist) 모두에서 동작한다.
|
|
@@ -63,8 +69,10 @@ function detectGaonjsVersion() {
|
|
|
63
69
|
break;
|
|
64
70
|
dir = parent;
|
|
65
71
|
}
|
|
66
|
-
//
|
|
67
|
-
|
|
72
|
+
// 결정 242: 스테일 폴백 제거 — 감지 실패는 즉시 알린다(§7.5.3 · 조용한 실패 금지).
|
|
73
|
+
throw new Error('gaonjs 파사드 버전을 자동 감지하지 못했습니다.\n' +
|
|
74
|
+
' → gaon 을 gaonjs 설치본(node_modules/gaonjs)을 통해 실행했는지 확인하세요.\n' +
|
|
75
|
+
' → 정상 설치라면 이 오류는 나지 않습니다(스캐폴드가 인접 package.json 에서 버전을 읽습니다).');
|
|
68
76
|
}
|
|
69
77
|
/** 대상 폴더가 비어 있는지(생성 대상으로 안전한지) 확인. */
|
|
70
78
|
function isEmptyOrMissing(dir) {
|
|
@@ -171,7 +179,14 @@ export async function runNewCommand(name, opts = {}) {
|
|
|
171
179
|
}
|
|
172
180
|
// 파일 생성 — 실패 시 부분 생성물이 남지 않도록 폴더를 정리하지는 않는다
|
|
173
181
|
// (사용자가 원인 파악 후 rm -rf 로 지우도록). 정상 흐름에서는 문제 없음.
|
|
174
|
-
|
|
182
|
+
// 결정 242: 버전 감지 실패는 fail-loud — --json 계약을 지키려 emitError 로 라우팅한다.
|
|
183
|
+
let gaonjsVersion;
|
|
184
|
+
try {
|
|
185
|
+
gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
|
|
186
|
+
}
|
|
187
|
+
catch (err) {
|
|
188
|
+
return emitError(err instanceof Error ? err.message : String(err), Date.now() - t0);
|
|
189
|
+
}
|
|
175
190
|
// 결정 169: packageManager 필드 = 선택한 pm 의 corepack 핀. 항상 pnpm 을
|
|
176
191
|
// 적던 옛 관례는 --pm yarn 시 yarn 1.22 corepack 이 install 을 거부시켰다.
|
|
177
192
|
const files = renderProjectFiles({
|
|
@@ -298,7 +313,8 @@ export async function runNewCommand(name, opts = {}) {
|
|
|
298
313
|
lines.push('');
|
|
299
314
|
lines.push(' 다음 단계:');
|
|
300
315
|
lines.push(` 1) cd ${name}`);
|
|
301
|
-
|
|
316
|
+
// 결정 198: .env 는 스캐폴드가 이미 생성한다(.env.example 사본) — cp 재안내는 모순.
|
|
317
|
+
lines.push(' 2) .env 편집 # 이미 생성됨(.env.example 사본) · 필요한 값만 수정');
|
|
302
318
|
lines.push(' 3) gaon dev # Docker + 타입 브리지 + serve 통합');
|
|
303
319
|
lines.push(' 4) http://localhost:3000 # 홈 페이지 확인 (60초 실측 · v0.15 §13.5 M9)');
|
|
304
320
|
lines.push('');
|
package/dist/db/diff.js
CHANGED
|
@@ -2,21 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 순수 diff — 아무것도 실행하지 않는다(SQL 만 출력). computeMigration 이
|
|
4
4
|
// 방언별 up/down 을 이미 만든다(@gaonjs/data). 이 함수는 배선·표현 담당.
|
|
5
|
-
import { computeMigration } from '@gaonjs/data';
|
|
5
|
+
import { computeMigration, summarizeOp } from '@gaonjs/data';
|
|
6
6
|
import { resolveDbTarget } from './resolve.js';
|
|
7
7
|
import { listMigrationFiles } from './replay.js';
|
|
8
|
-
function opSummary(op) {
|
|
9
|
-
switch (op.kind) {
|
|
10
|
-
case 'createTable':
|
|
11
|
-
return { kind: op.kind, table: op.table.name };
|
|
12
|
-
case 'dropTable':
|
|
13
|
-
return { kind: op.kind, table: op.name };
|
|
14
|
-
case 'addColumn':
|
|
15
|
-
case 'dropColumn':
|
|
16
|
-
case 'alterColumn':
|
|
17
|
-
return { kind: op.kind, table: op.table, column: op.column };
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
8
|
/**
|
|
21
9
|
* `gaon db diff` — 커넥션 대비 스키마 diff 를 계산해 텍스트·JSON 을 낳는다.
|
|
22
10
|
* 아무것도 적용하지 않는다. exitCode 는 항상 0(변경 없음도 성공 · CI 에서
|
|
@@ -30,7 +18,7 @@ export async function runDbDiff(opts) {
|
|
|
30
18
|
});
|
|
31
19
|
try {
|
|
32
20
|
const plan = await computeMigration(target.db, target.tables, target.dialect);
|
|
33
|
-
const ops = plan.ops.map(
|
|
21
|
+
const ops = plan.ops.map(summarizeOp);
|
|
34
22
|
// diff 는 스키마↔DB 차이만 미리 보여준다(적용 X). db/migrations/*.ts 는
|
|
35
23
|
// migrate 의 replay 단계가 실제로 실행한다(§4.8) — 여기선 목록만 참고로 싣는다.
|
|
36
24
|
const migrationFiles = listMigrationFiles(opts.cwd).map((f) => `db/migrations/${f}`);
|
package/dist/db/migrate.js
CHANGED
|
@@ -13,20 +13,23 @@
|
|
|
13
13
|
// 트랜잭션: postgres 는 트랜잭셔널 DDL 이라 각 단계가 원자적. mysql/mariadb 는
|
|
14
14
|
// DDL 이 autocommit 이라 순차 실행하고 이력만 남긴다(§4.5 방언 차이).
|
|
15
15
|
import { sql } from 'kysely';
|
|
16
|
-
import { computeMigration, renderUp, renderDown } from '@gaonjs/data';
|
|
16
|
+
import { computeMigration, renderUp, renderDown, summarizeOp, DEFERRED_DROP_KINDS } from '@gaonjs/data';
|
|
17
17
|
import { resolveDbTarget } from './resolve.js';
|
|
18
18
|
import { ensureJournal, journalExists, recordEntry } from './journal.js';
|
|
19
19
|
import { listMigrationFiles, replayPending, rollbackLast } from './replay.js';
|
|
20
|
-
|
|
20
|
+
/** drop 계열 부속 op 을 사람이 읽는 한 줄로 — 크게 알리는 note 용. */
|
|
21
|
+
function describeDeferred(op) {
|
|
21
22
|
switch (op.kind) {
|
|
22
|
-
case '
|
|
23
|
-
return {
|
|
24
|
-
case '
|
|
25
|
-
return {
|
|
26
|
-
case '
|
|
27
|
-
|
|
28
|
-
case '
|
|
29
|
-
return {
|
|
23
|
+
case 'dropUnique':
|
|
24
|
+
return `unique(${op.name}: ${op.columns.join(', ')})`;
|
|
25
|
+
case 'dropIndex':
|
|
26
|
+
return `index(${op.name})`;
|
|
27
|
+
case 'dropCheck':
|
|
28
|
+
return `check(${op.name})`;
|
|
29
|
+
case 'dropDefault':
|
|
30
|
+
return `default(${op.table}.${op.column})`;
|
|
31
|
+
default:
|
|
32
|
+
return op.kind;
|
|
30
33
|
}
|
|
31
34
|
}
|
|
32
35
|
/** 배치 식별자 — 초 단위 epoch + 적용 문수. 사람도 읽고 정렬도 된다. */
|
|
@@ -55,24 +58,31 @@ export async function runDbMigrate(opts) {
|
|
|
55
58
|
dryRun: opts.dryRun,
|
|
56
59
|
});
|
|
57
60
|
// 2) schema-diff — replay 이후 상태를 다시 읽어 나머지를 계산.
|
|
58
|
-
// 합성형(결정 39): migrate 는
|
|
59
|
-
// — replay
|
|
60
|
-
// 손작성
|
|
61
|
+
// 합성형(결정 39·220): migrate 는 **drop 계열(테이블·unique·index·check·default 제거)을
|
|
62
|
+
// 자동 적용하지 않는다** — replay 가 만든/외부 객체·수동 추가한 제약을 보호한다. 제거는
|
|
63
|
+
// 손작성 마이그로 하고, 'gaon db diff' 가 계속 보여준다(조용히 넘어가지 않는다). ADD·SET
|
|
64
|
+
// (unique·index·check·default 추가)은 자동 적용해 "조용한 no-op"(결정 220 배경)을 없앤다.
|
|
61
65
|
const full = await computeMigration(target.db, target.tables, target.dialect);
|
|
62
66
|
const droppedTables = full.ops
|
|
63
67
|
.filter((o) => o.kind === 'dropTable')
|
|
64
68
|
.map((o) => o.name);
|
|
65
|
-
const
|
|
69
|
+
const deferredDrops = full.ops.filter((o) => o.kind !== 'dropTable' && DEFERRED_DROP_KINDS.has(o.kind));
|
|
70
|
+
const schemaOps = full.ops.filter((o) => !DEFERRED_DROP_KINDS.has(o.kind));
|
|
66
71
|
const plan = {
|
|
67
72
|
ops: schemaOps,
|
|
68
73
|
up: renderUp(schemaOps, target.dialect),
|
|
69
74
|
down: renderDown(schemaOps, target.dialect),
|
|
70
75
|
};
|
|
71
|
-
const ops = plan.ops.map(
|
|
72
|
-
const
|
|
76
|
+
const ops = plan.ops.map(summarizeOp);
|
|
77
|
+
const tableDropNote = droppedTables.length > 0
|
|
73
78
|
? ` ℹ 스키마에 없는 테이블 ${droppedTables.length}개는 자동 DROP 하지 않았습니다: ${droppedTables.join(', ')}\n` +
|
|
74
79
|
` → 제거하려면 손작성 마이그(down 포함)를 쓰거나, 'gaon db diff' 로 계획을 확인하세요.`
|
|
75
80
|
: '';
|
|
81
|
+
const auxDropNote = deferredDrops.length > 0
|
|
82
|
+
? ` ℹ 스키마에서 사라진 제약·인덱스·기본값 ${deferredDrops.length}개는 자동 제거하지 않았습니다: ${deferredDrops.map(describeDeferred).join(', ')}\n` +
|
|
83
|
+
` → 제거하려면 'gaon db diff' 로 down SQL 을 확인해 손작성 마이그로 적용하세요.`
|
|
84
|
+
: '';
|
|
85
|
+
const dropNote = [tableDropNote, auxDropNote].filter(Boolean).join('\n');
|
|
76
86
|
const replayLine = replay.applied.length > 0
|
|
77
87
|
? ` [${opts.dbKey}] 마이그레이션 파일 ${replay.applied.length}개 실행: ${replay.applied.join(', ')}`
|
|
78
88
|
: replay.pending.length > 0 && opts.dryRun
|
|
@@ -85,6 +95,7 @@ export async function runDbMigrate(opts) {
|
|
|
85
95
|
pendingMigrations: replay.pending,
|
|
86
96
|
migrationFiles: replay.all,
|
|
87
97
|
skippedDropTables: droppedTables,
|
|
98
|
+
skippedDrops: deferredDrops.map(summarizeOp),
|
|
88
99
|
};
|
|
89
100
|
const prefixLines = (body) => (replayLine ? replayLine + '\n' : '') + (dropNote ? dropNote + '\n' : '') + body;
|
|
90
101
|
// schema 변경이 없다 — replay 만 있었을 수 있다. 성공으로 취급.
|
|
@@ -4,7 +4,7 @@ interface ImportUse {
|
|
|
4
4
|
readonly typeOnly: boolean;
|
|
5
5
|
readonly resolvedAbs: string;
|
|
6
6
|
}
|
|
7
|
-
/** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점). */
|
|
7
|
+
/** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점 · .ts·.vue 공통). */
|
|
8
8
|
export declare function extractRelativeImports(file: string, source: string): ImportUse[];
|
|
9
9
|
/** 프로젝트 전체 의존 방향 검사. */
|
|
10
10
|
export declare function checkDependencyDirection(cwd: string): Promise<RuleReport>;
|
|
@@ -13,9 +13,37 @@
|
|
|
13
13
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
14
14
|
import { join, relative, resolve, dirname } from 'node:path';
|
|
15
15
|
import ts from 'typescript';
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* .vue SFC 를 파싱용 TS 소스로 바꾼다. `<script>`·`<script setup>` 블록 안
|
|
18
|
+
* 텍스트만 남기고, 나머지(`<template>`·`<style>`·태그 자체)는 공백/개행으로
|
|
19
|
+
* 치환해 **원본과 동일한 줄·칸 위치를 보존**한다 — 위반 line 번호가 .vue 파일
|
|
20
|
+
* 기준으로 정확히 찍히게(§7.5.3). script 밖에는 import 가 없으므로 파싱에서
|
|
21
|
+
* 자연히 사라진다. 두 블록(일반+setup)이 다 있어도 각자 제자리에 남는다.
|
|
22
|
+
*/
|
|
23
|
+
function vueToTsPreservingLines(source) {
|
|
24
|
+
const chars = source.split('');
|
|
25
|
+
const keep = new Uint8Array(source.length);
|
|
26
|
+
const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
|
|
27
|
+
let m;
|
|
28
|
+
while ((m = re.exec(source)) !== null) {
|
|
29
|
+
const content = m[1] ?? '';
|
|
30
|
+
// 여는 태그 길이 = 전체 매치 − content − 닫는 태그. content 가 비거나 반복돼도 정확.
|
|
31
|
+
const start = m.index + (m[0].length - content.length - '</script>'.length);
|
|
32
|
+
for (let i = start; i < start + content.length; i++)
|
|
33
|
+
keep[i] = 1;
|
|
34
|
+
}
|
|
35
|
+
for (let i = 0; i < chars.length; i++) {
|
|
36
|
+
if (!keep[i] && chars[i] !== '\n' && chars[i] !== '\r')
|
|
37
|
+
chars[i] = ' ';
|
|
38
|
+
}
|
|
39
|
+
return chars.join('');
|
|
40
|
+
}
|
|
41
|
+
/** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점 · .ts·.vue 공통). */
|
|
17
42
|
export function extractRelativeImports(file, source) {
|
|
18
|
-
const
|
|
43
|
+
const isVue = file.endsWith('.vue');
|
|
44
|
+
const text = isVue ? vueToTsPreservingLines(source) : source;
|
|
45
|
+
// .vue 는 TS 가 확장자로 스크립트 종류를 못 잡으므로 TS 로 명시한다.
|
|
46
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, isVue ? ts.ScriptKind.TS : undefined);
|
|
19
47
|
const uses = [];
|
|
20
48
|
const baseDir = dirname(file);
|
|
21
49
|
const record = (specifierText, node, typeOnly) => {
|
|
@@ -180,7 +208,10 @@ async function collectSourceFiles(root, out) {
|
|
|
180
208
|
else if (e.isFile()) {
|
|
181
209
|
if (name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts'))
|
|
182
210
|
out.push(full);
|
|
183
|
-
// .vue
|
|
211
|
+
// .vue SFC 도 검사한다(결정 226) — <script> 블록 안 import 로 apps/shared 의
|
|
212
|
+
// app→app·shared→app 값 참조를 잡는다. 헤더 주석(.ts/.vue)과 정합.
|
|
213
|
+
else if (name.endsWith('.vue'))
|
|
214
|
+
out.push(full);
|
|
184
215
|
}
|
|
185
216
|
}
|
|
186
217
|
}
|
|
@@ -13,5 +13,11 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
|
|
|
13
13
|
/**
|
|
14
14
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
15
15
|
* 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
|
|
16
|
+
*
|
|
17
|
+
* **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 27종을 **빠짐없이** 담는다 —
|
|
18
|
+
* fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
|
|
19
|
+
* 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
|
|
20
|
+
* 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
|
|
21
|
+
* (`fixers/capabilities.test.ts` · ALL_RULES ↔ 카탈로그 대칭).
|
|
16
22
|
*/
|
|
17
23
|
export declare const FIXER_CAPABILITIES: readonly FixerCapability[];
|
|
@@ -27,6 +27,12 @@ export const FIXERS = {
|
|
|
27
27
|
/**
|
|
28
28
|
* 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
|
|
29
29
|
* 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
|
|
30
|
+
*
|
|
31
|
+
* **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 27종을 **빠짐없이** 담는다 —
|
|
32
|
+
* fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
|
|
33
|
+
* 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
|
|
34
|
+
* 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
|
|
35
|
+
* (`fixers/capabilities.test.ts` · ALL_RULES ↔ 카탈로그 대칭).
|
|
30
36
|
*/
|
|
31
37
|
export const FIXER_CAPABILITIES = [
|
|
32
38
|
{
|
|
@@ -89,9 +95,79 @@ export const FIXER_CAPABILITIES = [
|
|
|
89
95
|
hasFixer: false,
|
|
90
96
|
note: "수동 · 페이지는 this.render('...') 문자열·Inertia glob 로 해석돼 import 참조 갱신만으론 부족합니다(결정 32·46 · PascalCase 로 rename 후 render 키 확인).",
|
|
91
97
|
},
|
|
98
|
+
{
|
|
99
|
+
rule: 'auth-wiring',
|
|
100
|
+
hasFixer: false,
|
|
101
|
+
note: '수동 · requireAuth 를 쓰는데 app.config.ts 에 auth 미배선 — auth 블록·세션 설정은 앱 정책이라 자동 주입이 안전하지 않습니다(결정 59 · `gaon g auth` 로 스캐폴드).',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
rule: 'ui-kit-wiring',
|
|
105
|
+
hasFixer: false,
|
|
106
|
+
note: '수동 · UI 킷 import 가 있는데 style.css 에 Tailwind 미배선 — `gaon g ui-kit` 로 킷과 style.css 를 함께 심으세요(결정 76).',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
rule: 'route-registration',
|
|
110
|
+
hasFixer: false,
|
|
111
|
+
note: '수동 · 고아 컨트롤러의 URL·HTTP 메서드·액션은 추론할 수 없습니다 — routes.ts 에 라우트를 추가하거나 쓰지 않는 파일을 지우세요(결정 79).',
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
rule: 'static-collision',
|
|
115
|
+
hasFixer: false,
|
|
116
|
+
note: '수동 · 정적 파일이 라우트/에셋에 가려집니다 — 파일명을 바꾸거나 충돌 라우트를 조정하세요(결정 85).',
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
rule: 'method-override',
|
|
120
|
+
hasFixer: false,
|
|
121
|
+
note: '수동 · _method HTTP 스푸핑은 설계 기피 대상 — router.delete()/put() 등 실 HTTP 메서드로 전환하세요(결정 89).',
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
rule: 'csrf-wiring',
|
|
125
|
+
hasFixer: false,
|
|
126
|
+
note: '수동 · 비-GET 라우트 + session 미배선 — 세션·CSRF 배선은 앱 보안 정책이라 자동 주입이 안전하지 않습니다(결정 93).',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
rule: 'internal-anchor',
|
|
130
|
+
hasFixer: false,
|
|
131
|
+
note: '수동 · 앱 내부 이동 <a> → <Link> 전환은 템플릿·import 편집이 필요합니다(결정 96 · 풀 리로드 방지).',
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
rule: 'pageprops-destructure',
|
|
135
|
+
hasFixer: false,
|
|
136
|
+
note: '수동 · pageProps() 구조분해는 반응성을 끊습니다 — pageProps().x 접근이나 computed 로 재작성하세요(결정 99).',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
rule: 'async-offload',
|
|
140
|
+
hasFixer: false,
|
|
141
|
+
note: '수동 · 컨트롤러 인라인 메일·이미지·외부 HTTP 는 응답을 지연시킵니다 — 잡으로 오프로드하세요(결정 102·103 · 재설계).',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
rule: 'page-layout-breakpoint',
|
|
145
|
+
hasFixer: false,
|
|
146
|
+
note: '수동 · 페이지가 레이아웃 브레이크포인트를 직접 다룹니다 — 레이아웃(Default.vue)으로 옮기세요(결정 107).',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
rule: 'link-button-nesting',
|
|
150
|
+
hasFixer: false,
|
|
151
|
+
note: '수동 · <Link><Button> 중첩은 <a><button> 을 낳습니다 — Link(as) 또는 Button 하나로 재구성하세요(결정 113).',
|
|
152
|
+
},
|
|
92
153
|
{
|
|
93
154
|
rule: 'seal-security',
|
|
94
155
|
hasFixer: true,
|
|
95
156
|
note: 'seal:true 앱의 main.ts 에 @gaonjs/seal/client 정적 import + createGaonApp sealClient 전달을 자동 배선(결정 124). 방어 역전 warning 은 설계 결정이라 수동.',
|
|
96
157
|
},
|
|
158
|
+
{
|
|
159
|
+
rule: 'schema-relations',
|
|
160
|
+
hasFixer: false,
|
|
161
|
+
note: '수동 · 커넥션을 가로지르는 belongsTo·관계는 도메인 재설계가 필요합니다(§4.5 · 결정 134).',
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
rule: 'no-import-meta-env',
|
|
165
|
+
hasFixer: false,
|
|
166
|
+
note: '수동 · .vue 의 import.meta.env(TS1470)는 env 접근자로 전환하세요 — 접근자 도입은 코드 편집이 필요합니다(결정 198).',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
rule: 'locale-parity',
|
|
170
|
+
hasFixer: false,
|
|
171
|
+
note: '수동 · 로케일 간 누락 키는 각 카탈로그(locales/*.json)에 채우세요 — 번역문은 사람이 작성합니다(결정 216).',
|
|
172
|
+
},
|
|
97
173
|
];
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { RuleReport } from './types.js';
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점).
|
|
4
|
+
*
|
|
5
|
+
* 정규식 스캔이 아니라 TS AST 를 파싱한다(doctor 의 지배 관례 · 결정 243). 이유:
|
|
6
|
+
* 주석 안의 `'ctrl#action'` 을 참조로 오인해 진짜 고아를 놓치던(false-negative)
|
|
7
|
+
* 정규식 취약점을 제거하고, 실행 레이어 `routes()`(@gaonjs/web)의 `parseTarget`
|
|
8
|
+
* 의미(‘#’ 앞 부분·trim)를 그대로 따른다. 소스를 **실행하지 않고** 파싱만 하므로
|
|
9
|
+
* (createSourceFile) doctor 의 "routes.ts 를 실행하지 않는다" 계약은 유지된다.
|
|
10
|
+
*/
|
|
3
11
|
export declare function referencedControllers(routesSource: string): Set<string>;
|
|
4
12
|
/** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
|
|
5
13
|
export declare function checkRouteRegistration(cwd: string): Promise<RuleReport>;
|
|
@@ -7,19 +7,53 @@
|
|
|
7
7
|
// 컨트롤러 파일은 배선을 깜빡한 흔한 실수 · §7.5.3 수리 안내).
|
|
8
8
|
//
|
|
9
9
|
// routes.ts 가 참조하는데 파일이 없는 반대 경우는 이미 generator 가 하드 에러로
|
|
10
|
-
// 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은
|
|
11
|
-
//
|
|
10
|
+
// 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은 TS AST 파싱
|
|
11
|
+
// (가벼운 정적 검사 · 결정 243) — routes.ts 를 실행하지 않는다(파싱만).
|
|
12
12
|
import { readdir, readFile } from 'node:fs/promises';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
-
|
|
14
|
+
import ts from 'typescript';
|
|
15
|
+
/** DSL 메서드 대상: r.get/post/put/patch/delete(path, 'ctrl#action'). */
|
|
16
|
+
const METHOD_CALLS = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
17
|
+
/** 문자열 리터럴(따옴표·백틱 무치환)이면 그 텍스트를, 아니면 undefined. */
|
|
18
|
+
function literalText(node) {
|
|
19
|
+
if (node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)))
|
|
20
|
+
return node.text;
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점).
|
|
25
|
+
*
|
|
26
|
+
* 정규식 스캔이 아니라 TS AST 를 파싱한다(doctor 의 지배 관례 · 결정 243). 이유:
|
|
27
|
+
* 주석 안의 `'ctrl#action'` 을 참조로 오인해 진짜 고아를 놓치던(false-negative)
|
|
28
|
+
* 정규식 취약점을 제거하고, 실행 레이어 `routes()`(@gaonjs/web)의 `parseTarget`
|
|
29
|
+
* 의미(‘#’ 앞 부분·trim)를 그대로 따른다. 소스를 **실행하지 않고** 파싱만 하므로
|
|
30
|
+
* (createSourceFile) doctor 의 "routes.ts 를 실행하지 않는다" 계약은 유지된다.
|
|
31
|
+
*/
|
|
15
32
|
export function referencedControllers(routesSource) {
|
|
16
33
|
const names = new Set();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
const sf = ts.createSourceFile('routes.ts', routesSource, ts.ScriptTarget.Latest, true);
|
|
35
|
+
const visit = (node) => {
|
|
36
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
37
|
+
const method = node.expression.name.text;
|
|
38
|
+
if (method === 'resources' || method === 'resource') {
|
|
39
|
+
// r.resource('posts') · r.resources('posts') → 컨트롤러 = 첫 인자.
|
|
40
|
+
const name = literalText(node.arguments[0])?.trim();
|
|
41
|
+
if (name)
|
|
42
|
+
names.add(name);
|
|
43
|
+
}
|
|
44
|
+
else if (METHOD_CALLS.has(method)) {
|
|
45
|
+
// r.get(path, 'ctrl#action') → 컨트롤러 = 둘째 인자의 '#' 앞부분(trim).
|
|
46
|
+
const target = literalText(node.arguments[1]);
|
|
47
|
+
if (target !== undefined) {
|
|
48
|
+
const controller = (target.split('#')[0] ?? '').trim();
|
|
49
|
+
if (controller)
|
|
50
|
+
names.add(controller);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
ts.forEachChild(node, visit);
|
|
55
|
+
};
|
|
56
|
+
visit(sf);
|
|
23
57
|
return names;
|
|
24
58
|
}
|
|
25
59
|
/** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
|
package/dist/doctor/types.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 모든 doctor 검사(정본 목록·개수는 doctor.ts `ALL_RULES` · AGENTS §2.2)가 이 DoctorCheck
|
|
4
4
|
// 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
5
5
|
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
6
6
|
// errors.length > 0 이면 fail 로 판단한다.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type DevCommandOptions } from "./commands/dev.js";
|
|
2
|
+
import { type ServeCommandOptions } from "./serve.js";
|
|
1
3
|
import { type DoctorRule } from "./doctor.js";
|
|
2
4
|
export { startDev, resolveDevLayout, regenerateGaonOnce, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type RegenDeps, type RegenResult, } from "./dev.js";
|
|
3
5
|
export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
|
|
@@ -70,5 +72,16 @@ export interface ParsedNewArgs {
|
|
|
70
72
|
* npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
|
|
71
73
|
*/
|
|
72
74
|
export declare function parseNewArgs(rest: readonly string[]): ParsedNewArgs;
|
|
75
|
+
/**
|
|
76
|
+
* `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
|
|
77
|
+
* undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
|
|
78
|
+
* 플래그면 fail-loud, 값이 있으면 parsePort 로 1~65535 정수를 강제한다 —
|
|
79
|
+
* `Number('abc')=NaN` 이 listen 까지 조용히 흐르던 것을 파싱 경계에서 막는다(결정 240).
|
|
80
|
+
*/
|
|
81
|
+
export declare function readPortFlag(argv: readonly string[]): number | undefined;
|
|
82
|
+
/** `gaon serve` argv → ServeCommandOptions. 포트 검증은 fail-loud(결정 240). */
|
|
83
|
+
export declare function parseServeArgs(argv: readonly string[]): ServeCommandOptions;
|
|
84
|
+
/** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
|
|
85
|
+
export declare function parseDevArgs(argv: readonly string[]): DevCommandOptions;
|
|
73
86
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
74
87
|
export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { MILESTONES, VERSION, HOMEPAGE, loadDotEnv } from "@gaonjs/core";
|
|
14
14
|
import { runDevCommand } from "./commands/dev.js";
|
|
15
|
+
import { runServeCommand } from "./serve.js";
|
|
16
|
+
import { parsePort } from "./port.js";
|
|
15
17
|
import { runCheckCommand } from "./commands/check.js";
|
|
16
18
|
import { runGenCommand } from "./commands/gen.js";
|
|
17
19
|
import { runBuildCommand } from "./commands/build.js";
|
|
@@ -22,7 +24,6 @@ import { runGenerateAuthCommand } from "./generate.js";
|
|
|
22
24
|
import { runGenerateUiKitCommand } from "./uikit.js";
|
|
23
25
|
import { runGenerateCommand } from "./commands/g.js";
|
|
24
26
|
import { runHubCommand } from "./hub.js";
|
|
25
|
-
import { runServeCommand } from "./serve.js";
|
|
26
27
|
import { runWorkCommand } from "./work.js";
|
|
27
28
|
import { runJobsCommand } from "./jobs.js";
|
|
28
29
|
import { runDbCommand } from "./commands/db.js";
|
|
@@ -98,7 +99,7 @@ function renderHelp(version = VERSION) {
|
|
|
98
99
|
" gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --package-manager <pnpm|npm|yarn>)",
|
|
99
100
|
" gaon dev 개발 스택 통합 (Docker · .gaon · serve · work · hub · tsc/vue-tsc · 재시작 워처)",
|
|
100
101
|
" gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
|
|
101
|
-
" gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker|--no-work|--no-hub 개별 debug 옵션",
|
|
102
|
+
" gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker|--no-vite|--no-work|--no-hub 개별 debug 옵션",
|
|
102
103
|
" gaon dev --port <n> --host <h> serve 리슨 지정",
|
|
103
104
|
" gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
|
|
104
105
|
" gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
|
|
@@ -109,7 +110,7 @@ function renderHelp(version = VERSION) {
|
|
|
109
110
|
" gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
|
|
110
111
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
111
112
|
" gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
|
|
112
|
-
|
|
113
|
+
` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지)`,
|
|
113
114
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
114
115
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
115
116
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -196,6 +197,61 @@ export function parseNewArgs(rest) {
|
|
|
196
197
|
}
|
|
197
198
|
return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
|
|
198
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
|
|
202
|
+
* undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
|
|
203
|
+
* 플래그면 fail-loud, 값이 있으면 parsePort 로 1~65535 정수를 강제한다 —
|
|
204
|
+
* `Number('abc')=NaN` 이 listen 까지 조용히 흐르던 것을 파싱 경계에서 막는다(결정 240).
|
|
205
|
+
*/
|
|
206
|
+
export function readPortFlag(argv) {
|
|
207
|
+
const idx = argv.indexOf("--port");
|
|
208
|
+
if (idx < 0)
|
|
209
|
+
return undefined;
|
|
210
|
+
const raw = argv[idx + 1];
|
|
211
|
+
if (raw === undefined || raw.startsWith("-")) {
|
|
212
|
+
throw new Error("--port 값이 없습니다.\n" +
|
|
213
|
+
" → 예: gaon serve --port 3000 (1~65535 정수). 지정하지 않으면 기본 포트를 씁니다.");
|
|
214
|
+
}
|
|
215
|
+
return parsePort(raw, "--port");
|
|
216
|
+
}
|
|
217
|
+
/** `gaon serve` argv → ServeCommandOptions. 포트 검증은 fail-loud(결정 240). */
|
|
218
|
+
export function parseServeArgs(argv) {
|
|
219
|
+
const hostIdx = argv.indexOf("--host");
|
|
220
|
+
const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
|
|
221
|
+
const workersIdx = argv.indexOf("--workers");
|
|
222
|
+
// --workers <n|auto>: node:cluster 다중화(결정 84). env WEB_CONCURRENCY 도 가능.
|
|
223
|
+
const workersRaw = workersIdx >= 0 ? argv[workersIdx + 1] : undefined;
|
|
224
|
+
const workers = workersRaw === "auto" ? "auto" : workersRaw !== undefined ? Number(workersRaw) : undefined;
|
|
225
|
+
return {
|
|
226
|
+
json: argv.includes("--json"),
|
|
227
|
+
port: readPortFlag(argv),
|
|
228
|
+
host,
|
|
229
|
+
workers,
|
|
230
|
+
// --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식 serve 에 넘긴다(결정 69).
|
|
231
|
+
dev: argv.includes("--dev"),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
|
|
235
|
+
export function parseDevArgs(argv) {
|
|
236
|
+
const hostIdx = argv.indexOf("--host");
|
|
237
|
+
const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
|
|
238
|
+
return {
|
|
239
|
+
json: argv.includes("--json"),
|
|
240
|
+
stopDocker: argv.includes("--stop-docker"),
|
|
241
|
+
noWatch: argv.includes("--no-watch"),
|
|
242
|
+
noTsc: argv.includes("--no-tsc"),
|
|
243
|
+
noVueTsc: argv.includes("--no-vue-tsc"),
|
|
244
|
+
noDocker: argv.includes("--no-docker"),
|
|
245
|
+
// --no-vite: 프론트 watch 빌드(vite build --watch) 끄기(결정 239). vite.ts 안내가
|
|
246
|
+
// 이 플래그를 광고했으나 파싱이 없어 조용히 무시되던 드리프트를 닫는다.
|
|
247
|
+
noVite: argv.includes("--no-vite"),
|
|
248
|
+
noWork: argv.includes("--no-work"),
|
|
249
|
+
noHub: argv.includes("--no-hub"),
|
|
250
|
+
timestamp: argv.includes("--timestamp"),
|
|
251
|
+
port: readPortFlag(argv),
|
|
252
|
+
host,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
199
255
|
/** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
|
|
200
256
|
export function runCli(argv, opts = {}) {
|
|
201
257
|
const version = opts.version ?? VERSION;
|
|
@@ -212,23 +268,17 @@ export function runCli(argv, opts = {}) {
|
|
|
212
268
|
// (serve·work·hub → tsc → 워처 → Docker[--stop-docker 시]).
|
|
213
269
|
// 개별 debug 옵션은 --no-watch / --no-tsc / --no-vue-tsc / --no-docker / --no-work / --no-hub.
|
|
214
270
|
if (argv[0] === "dev") {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
noWork: argv.includes("--no-work"),
|
|
227
|
-
noHub: argv.includes("--no-hub"),
|
|
228
|
-
timestamp: argv.includes("--timestamp"),
|
|
229
|
-
port,
|
|
230
|
-
host,
|
|
231
|
-
}).catch((err) => {
|
|
271
|
+
let devOpts;
|
|
272
|
+
try {
|
|
273
|
+
devOpts = parseDevArgs(argv);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
// 포트 등 인자 파싱 오류는 부팅 전에 즉시 알린다(결정 240 · fail-loud).
|
|
277
|
+
process.stderr.write(` ✗ gaon dev: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
void runDevCommand(devOpts).catch((err) => {
|
|
232
282
|
const msg = err instanceof Error ? err.message : String(err);
|
|
233
283
|
process.stderr.write(` ✗ gaon dev 실패: ${msg}\n`);
|
|
234
284
|
process.exitCode = 1;
|
|
@@ -237,17 +287,17 @@ export function runCli(argv, opts = {}) {
|
|
|
237
287
|
}
|
|
238
288
|
// `gaon serve` — 웹 서버 부팅(§7 · M9-A). gaon.config.ts 자동 배선 후 listen.
|
|
239
289
|
if (argv[0] === "serve") {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
void runServeCommand(
|
|
290
|
+
let serveOpts;
|
|
291
|
+
try {
|
|
292
|
+
serveOpts = parseServeArgs(argv);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
// 포트 등 인자 파싱 오류는 부팅 전에 즉시 알린다(결정 240 · fail-loud).
|
|
296
|
+
process.stderr.write(` ✗ gaon serve: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
297
|
+
process.exitCode = 1;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
void runServeCommand(serveOpts).catch((err) => {
|
|
251
301
|
const msg = err instanceof Error ? err.message : String(err);
|
|
252
302
|
process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
|
|
253
303
|
process.exitCode = 1;
|
package/dist/port.d.ts
ADDED
package/dist/port.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// @gaonjs/cli · 포트 파싱/검증 (감사 P2 D3 · 결정 240)
|
|
2
|
+
//
|
|
3
|
+
// `--port <n>` (serve·dev)·env PORT 를 1~65535 정수로만 받는다. 비숫자·
|
|
4
|
+
// 범위 밖은 조용한 NaN 전파(Fastify listen 이 알 수 없는 포트로 뜨거나
|
|
5
|
+
// 실패) 대신 즉시 throw 한다 — fail-loud(§7.5.3 · 에러 = 수리 안내서).
|
|
6
|
+
//
|
|
7
|
+
// 미지정(플래그·env 부재)은 검증 대상이 아니다 — 호출부가 기본 포트로
|
|
8
|
+
// 폴백한다. 검증은 "값이 주어졌는데 유효하지 않을 때" 만 개입한다.
|
|
9
|
+
/**
|
|
10
|
+
* 포트 문자열을 1~65535 정수로 검증한다. 유효하지 않으면 throw.
|
|
11
|
+
* @param raw 검증할 값(플래그/ env 에서 온 문자열). 반드시 존재하는 값이어야 한다.
|
|
12
|
+
* @param source 에러 문맥 라벨(예 `--port` · `env PORT`).
|
|
13
|
+
*/
|
|
14
|
+
export function parsePort(raw, source) {
|
|
15
|
+
const n = Number(raw);
|
|
16
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
17
|
+
throw new Error(`유효하지 않은 포트(${source}): '${raw}'.\n` +
|
|
18
|
+
` → 포트는 1~65535 사이의 정수여야 합니다(예: 3000). 지정하지 않으면 기본 포트를 씁니다.`);
|
|
19
|
+
}
|
|
20
|
+
return n;
|
|
21
|
+
}
|
package/dist/serve.js
CHANGED
|
@@ -18,6 +18,7 @@ import { availableParallelism } from 'node:os';
|
|
|
18
18
|
import { loadDotEnv } from '@gaonjs/core';
|
|
19
19
|
import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
|
|
20
20
|
import { registerTsResolve } from './tsResolve.js';
|
|
21
|
+
import { parsePort } from './port.js';
|
|
21
22
|
import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
|
|
22
23
|
/**
|
|
23
24
|
* 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
|
|
@@ -135,9 +136,10 @@ export async function runServeCommand(opts = {}) {
|
|
|
135
136
|
const wired = await wireGaon(config, cwd);
|
|
136
137
|
emit({ kind: 'starting', configPath, apps: wired.apps.map((a) => a.name) });
|
|
137
138
|
const host = opts.host ?? config.web?.host ?? '0.0.0.0';
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
139
|
+
// opts.port(프로그램적)는 검증하지 않는다 — port:0(임의 포트) 같은 정당한 값이 있다.
|
|
140
|
+
// env PORT 는 사용자 입력이라 비숫자면 NaN 대신 fail-loud(결정 240 · §7.5.3).
|
|
141
|
+
const envPort = process.env.PORT != null && process.env.PORT !== '' ? parsePort(process.env.PORT, 'env PORT') : undefined;
|
|
142
|
+
const port = opts.port ?? config.web?.port ?? envPort ?? 3000;
|
|
141
143
|
// dev 전용 진단 라우트(결정 69). listen 전에 등록한다 — 운영 serve 는
|
|
142
144
|
// opts.dev 가 없어 등록되지 않으므로 /_gaon/health 는 production 에 없다.
|
|
143
145
|
// wired.app 은 여기서 FastifyInstance 로 해상되므로(cli 는 fastify 타입을
|
|
@@ -76,8 +76,9 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
76
76
|
`router.delete(...)` 등). REST + `fetch()` 는 **API 앱(JWT) 전용**.
|
|
77
77
|
7. **한 액션은 한 종류 응답만** (render 또는 JSON 또는 redirect —
|
|
78
78
|
혼용 금지 · doctor response-mixing).
|
|
79
|
-
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`tables.d.ts
|
|
80
|
-
는 `gaon check
|
|
79
|
+
8. **`.gaon/` 자동 생성 파일 편집 금지** — `routes.d.ts`·`tables.d.ts`·
|
|
80
|
+
`messages.d.ts`(3축 · `locales/` 있을 때 · 결정 158) 는 `gaon check`/
|
|
81
|
+
`gaon dev` 가 재생성한다.
|
|
81
82
|
|
|
82
83
|
### 2.1 파일 네이밍 표 (2026-07-24 승인 · 벤치마크 R1 실측 고정)
|
|
83
84
|
|
|
@@ -77,7 +77,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
77
77
|
│ └─ composables/ shared 컴포저블 (인자로만 · E-5)
|
|
78
78
|
├─ gaon.config.ts 루트 설정 (DB · Redis · NATS · ...)
|
|
79
79
|
├─ docker-compose.yaml 개발 인프라 (gaon dev 자동 기동)
|
|
80
|
-
├─ .env.example env 템플릿 (
|
|
80
|
+
├─ .env.example env 템플릿 (gaon new 가 .env 로 자동 복제 · 결정 198)
|
|
81
81
|
└─ package.json 개발자는 gaonjs 하나만 설치
|
|
82
82
|
```
|
|
83
83
|
|
|
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
|
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
87
|
gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
|
|
88
|
-
gaon doctor # 정적 검사
|
|
88
|
+
gaon doctor # 정적 검사 27종 (상세 AGENTS §2.2)
|
|
89
89
|
npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -256,6 +256,11 @@ wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **
|
|
|
256
256
|
타임존으로 띄운다(예: `TZ=Asia/Seoul gaon work`). 여러 인스턴스는 같은 TZ 로
|
|
257
257
|
맞춘다(리더가 어느 인스턴스든 같은 wall-clock 을 봐야 한다).
|
|
258
258
|
|
|
259
|
+
`gaon.config.ts` 의 `timezone` 을 두면 `gaon work`(부팅 시 wireDomain)가 그
|
|
260
|
+
값을 `process.env.TZ` 로 세팅한다 — 이 경우 **config.timezone 이 런치 `TZ`
|
|
261
|
+
환경변수보다 우선**한다(결정 230). 앱 전역에 한 타임존을 못박는 One Way 로,
|
|
262
|
+
serve·work 가 같은 wall-clock 을 보게 하려면 런치별 `TZ` 대신 config 를 쓴다.
|
|
263
|
+
|
|
259
264
|
#### 중첩 방지 (결정 202)
|
|
260
265
|
|
|
261
266
|
스케줄러는 매 주기 **발행만** 한다(§7 리더). 한 주기보다 오래 걸리는 잡이
|
|
@@ -348,7 +348,14 @@ const rows = await Post.query()
|
|
|
348
348
|
대상을 **에러**로 잡는다(배포 후 raw postgres 에러 대신 `gaon doctor` 에서).
|
|
349
349
|
커넥션 키 등록 정합은 **connections** 검사가 본다.
|
|
350
350
|
- **`service()` 트랜잭션은 단일 커넥션에서만 원자적** — Gaon 은 분산
|
|
351
|
-
트랜잭션을 흉내 내지 않는다(§9 정본).
|
|
351
|
+
트랜잭션을 흉내 내지 않는다(§9 정본). 열린 트랜잭션 안에서 다른
|
|
352
|
+
커넥션에 **쓰기**(create·update·delete)를 하면 **런타임에 throw**된다
|
|
353
|
+
(조용한 부분 커밋 방지 · fail-closed · 결정 221). **읽기는 예외**(다른
|
|
354
|
+
커넥션 조회는 자유). 커넥션 간 쓰기는 아래처럼 커밋 뒤 `afterCommit`
|
|
355
|
+
으로 잇는다. 중첩 `service({ db })`·`transaction:false`·`afterCommit`
|
|
356
|
+
은 자기 경계라 정상이다. (한계: model·`Post.query()`·`getConnection()`
|
|
357
|
+
쓰기는 가드가 잡지만, `kysely` 에서 직접 import 한 `sql``` raw SQL 쓰기는
|
|
358
|
+
못 잡는다 — 크로스커넥션 raw 쓰기는 직접 피한다.)
|
|
352
359
|
- **마이그레이션은 전 커넥션에 걸린다** — `gaon db migrate`(인자 없음)는 등록된
|
|
353
360
|
**모든** 커넥션을 순회 적용한다(결정 139 · One Way — 커넥션 하나를 잊어 빈 채
|
|
354
361
|
배포하는 사고 방지). 한 커넥션만 좁히려면 `gaon db migrate --db legacy`.
|
|
@@ -465,11 +472,14 @@ export const Post = model(posts, {
|
|
|
465
472
|
})
|
|
466
473
|
```
|
|
467
474
|
|
|
468
|
-
- **여러 모델을 조합하는 읽기 질의도 이름을 붙인다 (§5.3 읽기 규칙 · 결정 114).**
|
|
475
|
+
- **여러 모델을 조합하는 읽기 질의도 이름을 붙인다 (§5.3 읽기 규칙 · 결정 114·228).**
|
|
469
476
|
검색·태그 필터처럼 여러 모델/서브쿼리를 엮는 읽기는 컨트롤러에 인라인 조립하지
|
|
470
|
-
|
|
471
|
-
`
|
|
472
|
-
|
|
477
|
+
않는다. **다중 컬럼 검색(whereAny)만이면 스코프**로 이름 붙인다(아래 ✅). 그러나
|
|
478
|
+
**`join()` 이 끼는 조합(관계·태그를 조인으로 좁히는 읽기)은 스코프에 못 담는다** —
|
|
479
|
+
`join()` 은 `JoinChain` 을 반환하는데 스코프 함수(ScopeFn)는 `Chain` 반환을 요구해
|
|
480
|
+
타입이 안 맞는다(`Chain` 에 `JoinChain` 대입 = TS2322). 이때 정본은 **`domain/services/`
|
|
481
|
+
에 이름 붙인 서비스**다(결정 228). 컨트롤러에 허용되는 쿼리는 **스코프/서비스 호출
|
|
482
|
+
한 줄**까지다(`await Post.searchPublished(term).latest().all()` · `await searchPostsByTag(..)`).
|
|
473
483
|
```ts
|
|
474
484
|
// ❌ 컨트롤러 인라인 조립 (검색 교집합을 컨트롤러가 조립)
|
|
475
485
|
// const ids = await Post.where('title','ilike',p).orWhere('body','ilike',p).pluck('id')
|
|
@@ -484,6 +494,20 @@ export const Post = model(posts, {
|
|
|
484
494
|
}
|
|
485
495
|
// 컨트롤러: const rows = await Post.searchPublished(term).latest().all()
|
|
486
496
|
```
|
|
497
|
+
**조인이 끼는 조합(태그 필터 + 검색)은 서비스로** — 스코프는 타입 불가(결정 228):
|
|
498
|
+
```ts
|
|
499
|
+
// domain/services/searchPostsByTag.ts — join 은 스코프에 못 담아 서비스가 정본.
|
|
500
|
+
export async function searchPostsByTag(input: { q?: string; tagId: string; page?: number }) {
|
|
501
|
+
const term = String(input.q ?? '').trim()
|
|
502
|
+
return await Post
|
|
503
|
+
.join('posts_tags', 'posts_tags.postId', 'posts.id') // 태그 필터
|
|
504
|
+
.where('posts_tags.tagId', '=', BigInt(input.tagId))
|
|
505
|
+
.where('published', '=', true)
|
|
506
|
+
.whereAny(['title', 'body'], 'ilike', `%${term}%`) // 검색(괄호로 묶임)
|
|
507
|
+
.paginate(input.page ?? 1, 20)
|
|
508
|
+
}
|
|
509
|
+
// 컨트롤러 index 는 위임만: const result = await searchPostsByTag({ q, tagId, page })
|
|
510
|
+
```
|
|
487
511
|
|
|
488
512
|
### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
|
|
489
513
|
|
|
@@ -662,6 +686,24 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
|
|
|
662
686
|
테이블 보호 · 자동 apply 의 데이터 손실 배제). 테이블 제거는 손작성 마이그의
|
|
663
687
|
`down`(또는 명시적 마이그)으로 한다. `gaon db diff` 는 dropTable 을 계속
|
|
664
688
|
미리 보여주고, migrate 는 건너뛴 테이블을 크게 알린다(조용한 무시 방지).
|
|
689
|
+
- **스키마 diff 는 컬럼 타입·널뿐 아니라 제약·인덱스·기본값 변경도 잡는다**
|
|
690
|
+
(결정 220). 기존 테이블 컬럼에 `.unique()`·`.index()`·`.default()`·`.check()`
|
|
691
|
+
추가나 `t.enum([...])` 값 목록 변경을 하면 diff 가 감지해 **추가(ADD·SET)는
|
|
692
|
+
migrate 가 자동 적용**한다(예: `email` 에 `.unique()` 추가 → `ADD CONSTRAINT
|
|
693
|
+
… UNIQUE` 생성). **제거(제약·인덱스·기본값을 스키마에서 뗌)는 자동 적용하지
|
|
694
|
+
않고** dropTable 처럼 크게 알린다 — 제거는 `gaon db diff` 의 down SQL 을 보고
|
|
695
|
+
손작성 마이그로 적용한다(수동/외부 제약 보호 · 결정 39 정합). **한계**: 임의
|
|
696
|
+
`.check(expr)` 의 **표현식만** 바꾸는 변경(같은 컬럼·같은 제약, 식만 수정)은
|
|
697
|
+
Postgres 가 표현식을 정규화해 신뢰 비교가 불가능하므로 **감지하지 못한다** —
|
|
698
|
+
이 경우 컬럼을 갈거나 손작성 마이그로 CHECK 를 drop→add 한다. (enum 값 변경과
|
|
699
|
+
제약 **추가/제거** 는 정상 감지된다.) 마찬가지로 **기본값의 값 변경** 중 일부는
|
|
700
|
+
미검출된다 — **시간 타입**(`t.datetime()`·`t.date()`·`t.time()`)의 기본값 값 변경과
|
|
701
|
+
선행 0 문자열(`'007'` 류)의 값 변경은, Postgres 가 리터럴을 재포맷·정규화해 신뢰
|
|
702
|
+
비교가 안 되므로 **존재만 비교**한다(값이 바뀌어도 no-op). 허위 diff(멱등 붕괴)보다
|
|
703
|
+
미검출(undershoot)이 안전하다는 원칙 — 값 변경이 필요하면 손작성 마이그로 `ALTER
|
|
704
|
+
COLUMN … SET DEFAULT` 를 쓴다. (기본값 **추가/제거**·스칼라(bool·정수·문자열·enum)
|
|
705
|
+
값 변경은 정상 감지된다.) 이 제약·기본값 diff 는 postgres 커넥션 기준이다
|
|
706
|
+
(mysql=legacy §4.5 는 aux introspect 미지원 → 이 diff 생략).
|
|
665
707
|
- 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
|
|
666
708
|
|
|
667
709
|
### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
|
|
@@ -103,8 +103,11 @@ async function search(q: string) {
|
|
|
103
103
|
반환하면 그 타입 그대로, render 액션이면 render props 타입이 온다.
|
|
104
104
|
- **실패** — 4xx/5xx 는 예외로 던진다. 422(`ValidationError`)는
|
|
105
105
|
`err.body` 에 검증 이슈가 실려 있다.
|
|
106
|
-
- **CSRF** — 세션 앱은
|
|
107
|
-
`X-CSRF-Token` 헤더에 붙인다
|
|
106
|
+
- **CSRF** — 세션 앱은 상태 변경 요청(POST/PUT/PATCH/DELETE)에 CSRF 토큰을
|
|
107
|
+
자동으로 `X-CSRF-Token` 헤더에 붙인다(손수 넘길 필요 없음 · 결정 166). 정본
|
|
108
|
+
출처는 data-page 의 `props.csrf` 공유 prop(결정 116 · `useForm({ _csrf: shared.csrf })`
|
|
109
|
+
와 **같은 단일 출처**)이고, `<meta name="csrf-token">` 은 레거시 폴백이다(표준 셸은
|
|
110
|
+
안 내지만 앱이 손수 넣었으면 존중 · `packages/vue/src/api.ts` · `agents/security.md`).
|
|
108
111
|
|
|
109
112
|
### 3. bigint PK 식별자 — 컨트롤러에서 `String()` 정규화 (결정 37)
|
|
110
113
|
|
|
@@ -458,6 +461,7 @@ async function runSearch(q: string) {
|
|
|
458
461
|
| 결정 109 | 서버 스키마 검증 실패 → `form.errors.<field>` 자동 반영(303 back + 플래시 · `agents/web.md` §4.1) |
|
|
459
462
|
| 결정 113 | 버튼 모양 링크 = `<Button href>`(Link 로 Button 감싸지 않음 · `<a><button>` 중첩 방지 · doctor link-button-nesting) |
|
|
460
463
|
| 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
|
|
464
|
+
| 결정 166 | `api()` CSRF 자동 부착 = data-page `props.csrf`(결정 116 과 같은 단일 출처) · `<meta name="csrf-token">` 은 레거시 폴백(§2 · `packages/vue/src/api.ts`) |
|
|
461
465
|
| 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
|
|
462
466
|
| 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
|
|
463
467
|
| 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
|
|
@@ -28,7 +28,7 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
|
|
|
28
28
|
|---|---|---|
|
|
29
29
|
| 정의 | `mail<T>((data) => MailMessage)` | 파일명 = 이름 |
|
|
30
30
|
| 발송 | `def.deliver(data, { locale?, to? })` | 설정된 SMTP 로 보냄 |
|
|
31
|
-
| 미리보기 | `def.render(data, { locale? })` | 발송 없이 메시지만(테스트·미리보기) |
|
|
31
|
+
| 미리보기 | `def.render(data, { locale?, to? })` | 발송 없이 메시지만(테스트·미리보기) · `to` 는 `deliver` 와 대칭 |
|
|
32
32
|
|
|
33
33
|
### 2. 로케일 메일 — `deliver(data, { locale })` (결정 160)
|
|
34
34
|
|
|
@@ -57,6 +57,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
|
|
|
57
57
|
·`.env.example` 에 mail 블록이 이미 있어(결정 160) `cp .env.example .env` 후 바로 돈다.
|
|
58
58
|
운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
|
|
59
59
|
|
|
60
|
+
- **미리보기 정본 = MailPit** 이다 — 조용히 메모리로 삼키는 `captureTransport`(in-memory 싱크)는 **프레임웍 내부 테스트 전용**이라 파사드(`gaonjs/mail`)에 노출하지 않는다(결정 238 · 프로덕션 오배선 시 무신호 유실 방지).
|
|
61
|
+
|
|
60
62
|
### 5. `gaon.config.ts` 의 `mail` 블록 (env-gated)
|
|
61
63
|
|
|
62
64
|
메일러는 루트 `gaon.config.ts` 의 `mail` 블록으로 배선된다 — `db`·`redis`·`nats`
|
|
@@ -111,6 +111,39 @@ export default controller({
|
|
|
111
111
|
- **realtime(NATS) 미설정 앱**에서 호출하면 수리 안내와 함께 throw · **구독자 없음**이면 조용히
|
|
112
112
|
아무 데도 안 간다(fire-and-forget · 예외 아님).
|
|
113
113
|
|
|
114
|
+
### 2.6 특정/다중 유저 타겟 발송 (결정 227)
|
|
115
|
+
|
|
116
|
+
채널 전체가 아니라 **특정 유저(들)에게만** 밀 때는 `gaonjs/async` 의
|
|
117
|
+
`sendToUsers(name, userIds, data)` 를 쓴다 — `broadcast(name, data)` 와 **대칭**이되
|
|
118
|
+
대상 userId 를 지정한다(`broadcast` = 전체 · `sendToUsers` = 지정 유저). `userIds` 는 한
|
|
119
|
+
명(문자열)이나 여러 명(배열)이고, 대상은 세션 로그인 유저(멤버 `user:<id>`)다.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// apps/web/controllers/notifications.ts — service·job·listener 어디서든 동일
|
|
123
|
+
import { controller } from 'gaonjs/web'
|
|
124
|
+
import { sendToUsers } from 'gaonjs/async'
|
|
125
|
+
|
|
126
|
+
export default controller({
|
|
127
|
+
async ping() {
|
|
128
|
+
// 한 명: 'notifications' 채널의 유저 42 에게만.
|
|
129
|
+
const reached = await sendToUsers('notifications', String(42), { type: 'ping' })
|
|
130
|
+
// 여러 명:
|
|
131
|
+
// await sendToUsers('notifications', ['1', '2', '3'], { type: 'announce' })
|
|
132
|
+
return { reached } // reached = 그 채널에 접속한 대상 유저 수
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **전달 대상** — 대상 유저의 **살아있는 연결에만** 간다. 멀티탭이면 그 유저의 **모든 연결**이
|
|
138
|
+
받고(연결별 전달), 멀티서버여도 대상이 어느 서버에 붙어 있든 받는다(각 서버가 자기 로컬
|
|
139
|
+
연결을 필터). 비대상 유저는 안 받는다.
|
|
140
|
+
- **반환 = 도달한 대상 유저 수**(`Promise<number>`). 그 채널에 접속(present)한 대상 수를
|
|
141
|
+
프레즌스 권위(허브 KV)에서 센다. **대상이 전원 오프라인이면 `0` 을 정상 반환**한다 — throw
|
|
142
|
+
가 아니라 반환값으로 미도달을 알린다(조용히 삼키지 않음). 멀티탭 유저는 연결이 여럿이어도
|
|
143
|
+
present **유저** 기준이라 `1` 로 센다.
|
|
144
|
+
- **아키텍처(errata E-2)** — `broadcast` 와 같은 NATS 채널 subject 를 타므로 새 연결·프로토콜이
|
|
145
|
+
없다. seal 재봉인·authorize 규칙(§2.5)도 그대로다(authorize 는 구독 시점 게이트 · 재실행 없음).
|
|
146
|
+
|
|
114
147
|
### 3. 프레즌스
|
|
115
148
|
|
|
116
149
|
`ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
|
|
@@ -125,6 +158,11 @@ const members = await ctx.presence()
|
|
|
125
158
|
- **read**(`presence()`)는 KV(권위)를 직접 조회한다.
|
|
126
159
|
- leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
|
|
127
160
|
**즉시** 정리한다 (TCP `close`).
|
|
161
|
+
- **멤버는 연결 단위로 refcount 된다(결정 225).** 같은 유저(`user:<id>`)가
|
|
162
|
+
멀티탭·멀티서버로 여러 연결을 열면, **살아 있는 연결이 하나라도 있는 동안
|
|
163
|
+
로스터에 유지**된다 — 탭 하나를 닫아도 이탈이 아니고, 마지막 연결이 끊길
|
|
164
|
+
때만 leave 델타가 나간다. 한 서버가 죽어도(cleanupServer) 같은 유저가 다른
|
|
165
|
+
서버에 붙어 있으면 그 유저는 남는다.
|
|
128
166
|
|
|
129
167
|
### 4. 클라이언트 (`useChannel` · 결정 87)
|
|
130
168
|
|
|
@@ -205,7 +243,8 @@ export function useRoom(roomId: number) {
|
|
|
205
243
|
| 발화 주체 | 표면 | 쓰는 곳 |
|
|
206
244
|
| --- | --- | --- |
|
|
207
245
|
| 클라 메시지에 응답 | `ctx.broadcast(data)` | 채널 훅 `onMessage`(클라 메시지 필요) |
|
|
208
|
-
| 서버가
|
|
246
|
+
| 서버가 채널 전체로 밀기 | `broadcast(name, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (클라 메시지 없이 · 결정 126) |
|
|
247
|
+
| 서버가 특정 유저(들)에게 밀기 | `sendToUsers(name, userIds, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (지정 유저만 · 도달 수 반환 · 결정 227) |
|
|
209
248
|
|
|
210
249
|
```ts
|
|
211
250
|
// apps/web/channels/chatMessages.ts — 파일명 camelCase · 클라 메시지 응답형
|
|
@@ -251,6 +290,11 @@ export default channel({
|
|
|
251
290
|
(E-2). NATS 는 broadcast 팬아웃 전용.
|
|
252
291
|
- **서버 푸시 데이터를 `api()` 폴링으로 대체 금지** — 데이터 경로
|
|
253
292
|
판단표 위반.
|
|
293
|
+
- **특정 유저 발송을 `broadcast` + 클라 필터로 흉내내지 말 것** — 전체
|
|
294
|
+
broadcast 로 밀고 클라가 `if (내 id)` 로 거르면 **민감 페이로드가 전원에게
|
|
295
|
+
샌다**(클라가 버려도 이미 도달). 지정 유저는 `sendToUsers`(서버가 대상
|
|
296
|
+
연결에만 전달 · 결정 227). `sendToUsers` 는 그 **채널에 접속한** 유저만
|
|
297
|
+
대상이다 — 접속 안 한(오프라인) 유저는 `0` 도달로 반환된다.
|
|
254
298
|
- **자기 자신의 join 델타** — 클라이언트는 자기 `presence:join` 델타도
|
|
255
299
|
스냅샷과 별개로 받는다(멱등이라 무해). 필요하면 자기 `id` 로 필터.
|
|
256
300
|
- **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
|
|
@@ -265,6 +309,8 @@ export default channel({
|
|
|
265
309
|
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
266
310
|
| 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
|
|
267
311
|
| 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
|
|
312
|
+
| 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
|
|
313
|
+
| 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
|
|
268
314
|
|
|
269
315
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
270
316
|
|
|
@@ -99,13 +99,22 @@ void createGaonApp({ pages, layouts, /* ... */ sealClient })
|
|
|
99
99
|
네이티브 브라우저 form·정적 자산·HTML 직접 로드는 셋 다 없어 자동 면제된다.
|
|
100
100
|
- **최초 문서 data-page**: 서버가 `<script data-page="app" data-gaon-sealed="1">` 로 봉인 + `<meta gaon-seal-ts>`.
|
|
101
101
|
클라 `createGaonApp` 이 Inertia 마운트 **전**에 wasm 으로 개봉 → 소스 보기·개발자도구에 평문 props 미노출.
|
|
102
|
+
**봉인 대상은 sentinel 로 특정 (결정 224)**: 서버가 주입한 **진짜** data-page 만 `data-gaon-seal-target` sentinel
|
|
103
|
+
로 표시되고 봉인 정규식이 그것만 잡는다 — 스캐폴드 주석·유저 index.html 의 예시 data-page 리터럴(decoy)을 봉인하고
|
|
104
|
+
멈추던 first-match P0(진짜 data-page·props·CSRF 평문 유출)를 근본 차단한다. 봉인 후에도 평문 data-page 가 남으면
|
|
105
|
+
**렌더가 throw**(fail-closed 가드 · 조용한 유출 금지). 커스텀 rootTemplate 을 쓰더라도 data-page script 를 직접
|
|
106
|
+
만들지 말고 서버가 주입하게 둔다.
|
|
102
107
|
- **WS 프레임 (결정 124 · §3.4)**: `useChannel` 이 `setWsFrameCodec`(seal 클라 `wsEncode`/`wsDecode`)로
|
|
103
108
|
채널 송수신을 봉인한다. 송신 `E:<ts>:<base64>` · seal namespace 는 평문 `P:` 프레임 **거부**(requireDecrypt · 결정 121).
|
|
109
|
+
**수신은 서버·클라가 대칭 (결정 222)**: 서버 `SealWsTerminator` 도, 클라 `wsDecode` 도 `E:` 만 개봉하고 `P:`
|
|
110
|
+
평문·무prefix 프레임을 거부한다 — 클라 수신이 평문을 통과시키면(fail-open) 봉인 앱 클라가 주입된 평문을 그대로
|
|
111
|
+
소비해 봉인이 깨진다. 클라 개봉 실패는 조용히 무시하지 않고 소켓을 **4500 종료**(`useChannel` · 아래 fail-closed).
|
|
104
112
|
- **자동 제외 / 옵트아웃**: 정적 자산·헬스체크·multipart 업로드 body·비대상(JSON 도 Inertia 도 아닌 HTML
|
|
105
113
|
직접 로드·네이티브 form)은 **자동 제외**(사람 판단 없이 헤더 기계 판별 · 결정 125). 외부(웹훅 등)가 봉인을
|
|
106
114
|
모르는 경로는 `seal: { except: ['/webhooks/*'] }`.
|
|
107
115
|
- **fail-closed (403 · 결정 121)**: 봉인 강제 경로에 시그널 헤더 없이 온 요청, drift/replay/키 실패는
|
|
108
|
-
**403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 소켓 4500
|
|
116
|
+
**403 SealError** — 평문 통과 절대 없음. WS 개봉 실패는 **서버·클라 모두 소켓 4500 종료**(결정 222 · silent
|
|
117
|
+
fallback 없음). 클라(`useChannel`)는 4500 이후 재연결하지 않는다(개봉 실패 = transient 아님 · 종단).
|
|
109
118
|
|
|
110
119
|
## 3. CSP (결정 124)
|
|
111
120
|
|
|
@@ -135,7 +144,11 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
135
144
|
- **알고리즘**: AES-256-GCM(12-byte nonce · 16-byte tag) + nibble-swap XOR(0x5A) + base64 · 키 유도 =
|
|
136
145
|
`SHA256(hex(HMAC-SHA256(masterSecret, "domain:path:uaSlice:timestamp")))` · per-frame keying(userId 미포함).
|
|
137
146
|
- **replay 방어**: AES-GCM 12-byte nonce 를 `setIfNotExists`(Redis SETNX 대응) 캐시 + timestamp drift(±60s)로
|
|
138
|
-
차단.
|
|
147
|
+
차단. **HTTP 경로는 nonce 검사가 항상 배선된다 (결정 223)** — 앱이 세션 Redis 를 가지면 그걸 재사용(멀티
|
|
148
|
+
인스턴스 안전), 없으면 **in-memory 폴백(단일 인스턴스 전용)**을 쓰고 부팅 시 경고한다(nonce 검사가 조용히
|
|
149
|
+
사라지지 않는다). **in-memory 는 프로세스별 격리라 멀티 워커(`--workers`·`WEB_CONCURRENCY>1`)·멀티 서버에서
|
|
150
|
+
replay 를 완전히 막지 못하므로, 프로덕션 멀티 인스턴스는 세션 Redis 를 구성한다.** WS 기본은 drift 윈도우
|
|
151
|
+
(고빈도라 프레임마다 SETNX 는 비용 과다 · 엄격 nonce 는 옵션 주입).
|
|
139
152
|
- **허브(`gaon hub`)는 손대지 않는다** — 봉인/개봉은 각 웹서버의 소켓 경계에서만. 타 서버 접속자의
|
|
140
153
|
UA·ts 컨텍스트가 없어 허브가 프레임을 복호할 수 없는 것은 구조적 필연(설계상) · 허브·NATS 내부는 평문.
|
|
141
154
|
|
|
@@ -145,6 +158,8 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
145
158
|
`test/integration/seal-browser-e2e.integration.test.ts`(마운트·data-page 개봉·useForm POST·api()·WS `E:` 왕복·
|
|
146
159
|
평문 `P:` 거부·hidden 누출 0·**비-seal 앱 번들 무-wasm 단언**) + `seal-fullstack-e2e`(실 `gaon serve`+`gaon hub`+NATS
|
|
147
160
|
경유 봉인 broadcast).
|
|
161
|
+
- **배포 게이트 편입 (결정 224)**: 이 두 e2e 는 `pnpm test:runtime-e2e` 에 들어 있다 — 이전엔 어느 스크립트에도 없어
|
|
162
|
+
**red 인데도 배포를 통과**해 seal 최초 문서 data-page 평문 유출 P0 를 못 잡았다(결정 224 근본). 배포 전 반드시 green.
|
|
148
163
|
- **목업 e2e 로 대체 금지.** 서버 inject·단위·wasm-parity 는 byte 호환을 잠글 뿐 "실 브라우저에서 마운트되나"를
|
|
149
164
|
못 본다 — 원 wave 가 실-브라우저 e2e 를 미뤄 P0 3건(번들 불가·CSP 차단·Inertia 인터셉터 파손)을 놓친 교훈(결정 124).
|
|
150
165
|
- **봉인 검증은 네트워크 날 바디로만 봐야 한다 (검증법 함정).** seal 앱에서 `page.evaluate(fetch(...))` 나 렌더된
|
|
@@ -162,8 +177,10 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
|
|
|
162
177
|
5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
|
|
163
178
|
6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
|
|
164
179
|
|
|
165
|
-
## 관련 결정
|
|
180
|
+
## 관련 결정 번호
|
|
166
181
|
|
|
167
182
|
- **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
|
|
168
183
|
- **결정 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).
|
|
169
184
|
- **결정 125** — **Inertia 네비게이션 평문 P0** 수정: 봉인 대상 판별기(`isSealTarget`)에 `X-Inertia: true` 를 편입. Inertia GET 방문은 `Accept: text/html` 로 와 application/json 이 없어 자동 면제되던 탓에 응답 props 가 평문으로 새어나갔다(클라 인터셉터는 시그널을 붙였으나 서버가 봉인 안 함). 네비게이션 봉인 e2e 를 seal blocking 게이트에 편입(실 vite+chromium · wire 봉인/`?q=` 왕복 단언).
|
|
185
|
+
- **결정 222** — **클라 WS 수신 fail-open P1** 수정: 클라 `wsDecode`(client.ts)가 `P:` 평문·무prefix 프레임을 throw 없이 원문 통과시켜, 서버는 requireDecrypt 로 거부하는데 클라만 주입된 평문을 소비하던 봉인 파괴. `wsDecode` 를 서버 `SealWsTerminator` 와 대칭으로 만들어 `E:` 만 개봉·`P:`/무prefix 거부. `useChannel` 은 개봉 실패를 조용히 드롭하지 않고 소켓을 **4500 종료**(서버 대칭) + 콘솔 명시 + 재연결 안 함.
|
|
186
|
+
- **결정 223** — **HTTP replay Redis 없으면 조용히 off + 허위 주석 P1** 수정: `normalizeSealConfig` 이 nonceStore 없으면 `replay=null` 로 두어 nonce 검사가 사라지고 drift(±60s)만 남아 60초 내 재전송이 통과했다(`sealBridge` 주석은 "in-memory 폴백" 이라 거짓 단언 — `MemoryNonceStore` 는 export 만·미배선). HTTP replay 를 **항상 배선**한다 — Redis 있으면 재사용(멀티 인스턴스 안전), 없으면 in-memory 폴백(단일 인스턴스 전용) + 부팅 경고. **기각: 부팅 throw(옵션 A)** — 기본 배포가 워커 1(CLAUDE 규칙 6)이라 단일 인스턴스 in-memory 가 정상 경로인데 throw 는 dev·단일 인스턴스 seal 앱을 깨고 문서(§4 "in-memory 는 dev/단일/테스트")와 상충. 폴백+경고가 비파괴적·정본 정합.
|
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
`expiresIn`(초)로 만료를 조절한다. 존재하지 않는 `Attachment.urlFor` 같은
|
|
28
28
|
헬퍼를 만들지 말 것 — 표면은 `Storage.url()` 뿐이다.
|
|
29
29
|
- **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
|
|
30
|
+
- **`contentType` 은 어댑터별로 다르게 반영된다**(결정 236): `s3` 는 객체
|
|
31
|
+
메타데이터로 저장해 다운로드·presign 시 그대로 나가고, **로컬**은 저장하지
|
|
32
|
+
않고 **서빙 시 key 확장자로 Content-Type 이 결정**된다(로컬엔 메타데이터
|
|
33
|
+
채널이 없음). 로컬에서 타입을 보장하려면 **key 에 올바른 확장자**를 쓰거나
|
|
34
|
+
s3 를 쓴다 — 조용한 무시가 아니라 명시된 관례다.
|
|
30
35
|
|
|
31
36
|
### 2. 설정 (`gaon.config.ts`)
|
|
32
37
|
|
|
@@ -63,7 +68,10 @@ storage: process.env.STORAGE_ENDPOINT
|
|
|
63
68
|
export default controller({
|
|
64
69
|
async updateAvatar() {
|
|
65
70
|
const f = this.file('avatar') // UploadedFile | undefined
|
|
66
|
-
if (!f)
|
|
71
|
+
if (!f) {
|
|
72
|
+
this.flash('error', '파일이 필요합니다.') // useShared().flash.error 로 표시(결정 116)
|
|
73
|
+
return this.redirect('/profile')
|
|
74
|
+
}
|
|
67
75
|
const key = `avatars/${this.auth.user!.id}.png`
|
|
68
76
|
await Storage.put(key, f.buffer, { contentType: f.mimetype })
|
|
69
77
|
return this.redirect('/profile')
|
|
@@ -414,7 +414,7 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
414
414
|
```ts
|
|
415
415
|
async new() {
|
|
416
416
|
this.requireAuth() // 미인증이면 로그인으로 리다이렉트
|
|
417
|
-
return this.render('Posts/New', { csrf
|
|
417
|
+
return this.render('Posts/New', {}) // csrf 는 자동 주입 공유 prop — 넘기지 않는다(결정 116·117)
|
|
418
418
|
}
|
|
419
419
|
async create() {
|
|
420
420
|
const user = this.requireAuth() // 반환값을 바로 쓴다
|
|
@@ -438,7 +438,8 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
438
438
|
const { email, password } = this.params({ _row: {} as { email: string; password: string } })
|
|
439
439
|
const user = await User.where('email', '=', email).first()
|
|
440
440
|
if (!user || !(await verifyPassword(password, user.passwordDigest))) {
|
|
441
|
-
|
|
441
|
+
// csrf 는 자동 주입 공유 prop 이므로 컨트롤러가 넘기지 않는다(결정 116·117)
|
|
442
|
+
return this.render('Auth/Login', { error: '이메일 또는 비밀번호가 올바르지 않습니다.' })
|
|
442
443
|
}
|
|
443
444
|
this.auth.login(user) // 세션 확정
|
|
444
445
|
return this.redirect('/dashboard')
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<!--
|
|
10
10
|
Vite 개발 서버가 이 index.html 을 서빙한다(dev). 운영 빌드는
|
|
11
11
|
vite build 가 이 파일을 진입점 삼아 프로덕션 번들을 만든다.
|
|
12
|
-
Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 빈
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
Fastify(gaonjs/web) 는 초기 SPA 응답 셸에서 빈 div#app 뒤에 초기 페이지 객체를
|
|
13
|
+
script[type=application/json][data-page=app] 엘리먼트로 자동 주입한다(data-page 는
|
|
14
|
+
div 속성이 아니라 이 script 태그에 실린다 · 여기에 직접 쓰지 말 것 — 서버가 넣는다).
|
|
15
15
|
개발과 운영이 같은 셸이다.
|
|
16
16
|
-->
|
|
17
17
|
<div id="app"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.41.0",
|
|
4
4
|
"description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/
|
|
31
|
-
"@gaonjs/
|
|
32
|
-
"@gaonjs/
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/web": "0.19.
|
|
30
|
+
"@gaonjs/async": "0.15.0",
|
|
31
|
+
"@gaonjs/config": "0.17.0",
|
|
32
|
+
"@gaonjs/core": "0.2.3",
|
|
33
|
+
"@gaonjs/data": "0.17.0",
|
|
34
|
+
"@gaonjs/mail": "0.3.0",
|
|
35
|
+
"@gaonjs/i18n": "0.2.2",
|
|
36
|
+
"@gaonjs/web": "0.19.2"
|
|
37
37
|
},
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
|