@gaonjs/cli 0.23.0 → 0.24.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/README.md +10 -3
- package/dist/commands/db.js +2 -2
- package/dist/db.d.ts +7 -2
- package/dist/db.js +30 -17
- package/dist/doctor/async-offload.d.ts +23 -0
- package/dist/doctor/async-offload.js +236 -0
- package/dist/doctor/pageprops-destructure.d.ts +5 -0
- package/dist/doctor/pageprops-destructure.js +84 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor/types.js +1 -1
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +12 -2
- package/dist/index.js +2 -1
- package/dist/templates/auth/Dashboard.vue.tpl +5 -4
- package/dist/templates/auth/Login.vue.tpl +7 -5
- package/dist/templates/auth/Signup.vue.tpl +5 -4
- package/dist/templates/project/AGENTS.md.tpl +24 -2
- package/dist/templates/project/agents/async.md.tpl +55 -0
- package/dist/templates/project/agents/frontend.md.tpl +7 -0
- package/dist/templates/project/agents/web.md.tpl +5 -4
- package/package.json +5 -5
- package/dist/templates/auth/app.ts.tpl +0 -29
- package/dist/templates/auth/server.ts.tpl +0 -14
package/README.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# @gaonjs/cli
|
|
2
2
|
|
|
3
|
-
Gaon CLI
|
|
3
|
+
Gaon CLI — `gaon` 명령. 프로젝트 스캐폴드(`gaon new`), 개발 스택 통합(`gaon dev`),
|
|
4
|
+
제너레이터(`gaon g auth`·scaffold), 마이그레이션(`gaon db …`), 통합 검사
|
|
5
|
+
(`gaon check`), 정적 검사(`gaon doctor` · 20종), 운영 부팅(`gaon serve`). 모든
|
|
6
|
+
명령은 `--json` 출력을 지원합니다.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
이 패키지는 [`gaonjs`](https://www.npmjs.com/package/gaonjs) 파사드에 포함됩니다 —
|
|
9
|
+
`gaonjs` 를 설치하면 `gaon` 명령을 쓸 수 있습니다.
|
|
6
10
|
|
|
7
|
-
|
|
11
|
+
```bash
|
|
12
|
+
gaon new my-app && cd my-app && gaon dev
|
|
13
|
+
```
|
|
8
14
|
|
|
9
15
|
- 홈페이지 / 문서: https://gaonjs.dev
|
|
16
|
+
- 라이선스: MIT
|
package/dist/commands/db.js
CHANGED
|
@@ -57,8 +57,8 @@ export async function runDbCommand(subcommand, opts = {}) {
|
|
|
57
57
|
return r.exitCode;
|
|
58
58
|
}
|
|
59
59
|
if (subcommand === 'seed') {
|
|
60
|
-
//
|
|
61
|
-
const r = await runDbSeedCommand({ root: cwd, json });
|
|
60
|
+
// 결정 101: seed 도 diff/migrate 와 같은 설정 해석 경로(config → env)를 쓴다.
|
|
61
|
+
const r = await runDbSeedCommand({ root: cwd, json, configPath, dbKey });
|
|
62
62
|
emit(r.text, r.json);
|
|
63
63
|
return r.exitCode;
|
|
64
64
|
}
|
package/dist/db.d.ts
CHANGED
|
@@ -3,8 +3,12 @@ export interface DbSeedOptions {
|
|
|
3
3
|
readonly json?: boolean;
|
|
4
4
|
/** 도메인 루트(domain/ 의 부모). 기본 cwd. */
|
|
5
5
|
readonly root?: string;
|
|
6
|
-
/** DB URL
|
|
6
|
+
/** DB URL 오버라이드(reset 내부 호출 등). 생략 시 config → GAON_DATABASE_URL. */
|
|
7
7
|
readonly databaseUrl?: string;
|
|
8
|
+
/** --config <path> 오버라이드. 없으면 root/gaon.config.ts 관례. */
|
|
9
|
+
readonly configPath?: string;
|
|
10
|
+
/** 커넥션 키(§4.5). 기본 'main'. */
|
|
11
|
+
readonly dbKey?: string;
|
|
8
12
|
}
|
|
9
13
|
/** domain/seed.ts 의 default export 를 읽어 SeedDef 를 돌려준다(없으면 안내 에러). */
|
|
10
14
|
export declare function loadSeed(root: string): Promise<SeedDef>;
|
|
@@ -15,6 +19,7 @@ export interface DbSeedResult {
|
|
|
15
19
|
}
|
|
16
20
|
/**
|
|
17
21
|
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
18
|
-
* 커넥션
|
|
22
|
+
* 커넥션 해석은 diff·migrate·status·reset 과 같은 경로(resolveDbTarget)를
|
|
23
|
+
* 쓴다(결정 101). 커넥션 정리는 항상 수행한다.
|
|
19
24
|
*/
|
|
20
25
|
export declare function runDbSeedCommand(opts?: DbSeedOptions): Promise<DbSeedResult>;
|
package/dist/db.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// @gaonjs/cli · `gaon db seed` (§7 M8 — domain/seed.ts 실행)
|
|
2
2
|
//
|
|
3
3
|
// 관례: `domain/seed.ts` 의 default export(seed 정의)를 실행한다. 커넥션은
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// diff·migrate·status·reset 과 **같은 해석 경로**(resolveDbTarget)로 연결한다
|
|
5
|
+
// (결정 101). 우선순위: databaseUrl 오버라이드 > gaon.config.ts db.<키> >
|
|
6
|
+
// GAON_DATABASE_URL. 과거엔 seed 만 GAON_DATABASE_URL 만 봐서, config 에
|
|
7
|
+
// db.main 만 선언한 프로젝트는 migrate 는 되는데 seed 만 실패했다(.env 진입점
|
|
8
|
+
// 통일 wave 동형). 모든 명령은 --json 을 함께 낸다(CLAUDE.md §4).
|
|
7
9
|
import { existsSync } from 'node:fs';
|
|
8
10
|
import { join } from 'node:path';
|
|
9
11
|
import { pathToFileURL } from 'node:url';
|
|
10
|
-
import { env, EnvError } from '@gaonjs/core';
|
|
11
12
|
import { createDb, registerConnection, destroyAllConnections, isSeedDef, } from '@gaonjs/data';
|
|
12
13
|
import { registerTsResolve } from './tsResolve.js';
|
|
14
|
+
import { resolveDbTarget } from './db/resolve.js';
|
|
13
15
|
/** DB URL 에서 어댑터를 추정한다(gaon work 와 동일 규칙). */
|
|
14
16
|
function dbConfigFromUrl(url) {
|
|
15
17
|
if (url.startsWith('mysql://') || url.startsWith('mariadb://')) {
|
|
@@ -34,29 +36,40 @@ export async function loadSeed(root) {
|
|
|
34
36
|
}
|
|
35
37
|
return mod.default;
|
|
36
38
|
}
|
|
39
|
+
const SEED_OK = {
|
|
40
|
+
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
41
|
+
json: { command: 'seed', ok: true },
|
|
42
|
+
};
|
|
37
43
|
/**
|
|
38
44
|
* `gaon db seed` — main 커넥션을 연결하고 domain/seed.ts 를 실행한다.
|
|
39
|
-
* 커넥션
|
|
45
|
+
* 커넥션 해석은 diff·migrate·status·reset 과 같은 경로(resolveDbTarget)를
|
|
46
|
+
* 쓴다(결정 101). 커넥션 정리는 항상 수행한다.
|
|
40
47
|
*/
|
|
41
48
|
export async function runDbSeedCommand(opts = {}) {
|
|
42
49
|
const root = opts.root ?? process.cwd();
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
const dbKey = opts.dbKey ?? 'main';
|
|
51
|
+
// databaseUrl 명시 오버라이드(reset 내부 호출) — URL 로 직접 커넥션을 세운다.
|
|
52
|
+
if (opts.databaseUrl) {
|
|
53
|
+
const cfg = dbConfigFromUrl(opts.databaseUrl);
|
|
54
|
+
registerConnection(dbKey, createDb(cfg), cfg.adapter);
|
|
55
|
+
try {
|
|
56
|
+
const def = await loadSeed(root);
|
|
57
|
+
await def.run();
|
|
58
|
+
return { exitCode: 0, ...SEED_OK };
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
await destroyAllConnections();
|
|
62
|
+
}
|
|
48
63
|
}
|
|
49
|
-
|
|
64
|
+
// 그 외 — migrate 와 같은 해석 경로: gaon.config.ts db.<키> → GAON_DATABASE_URL.
|
|
65
|
+
// config 도 env 도 없으면 resolveDbTarget 이 수리 안내 에러를 던진다(§7.5.3).
|
|
66
|
+
const target = await resolveDbTarget({ cwd: root, dbKey, configPath: opts.configPath });
|
|
50
67
|
try {
|
|
51
68
|
const def = await loadSeed(root);
|
|
52
69
|
await def.run();
|
|
53
|
-
return {
|
|
54
|
-
exitCode: 0,
|
|
55
|
-
text: ` gaon db seed · 완료 — domain/seed.ts 실행됨`,
|
|
56
|
-
json: { command: 'seed', ok: true },
|
|
57
|
-
};
|
|
70
|
+
return { exitCode: 0, ...SEED_OK };
|
|
58
71
|
}
|
|
59
72
|
finally {
|
|
60
|
-
await
|
|
73
|
+
await target.close();
|
|
61
74
|
}
|
|
62
75
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정). */
|
|
3
|
+
export declare function isApiApp(appConfigSource: string): boolean;
|
|
4
|
+
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
5
|
+
export declare function importsMailSdk(source: string): boolean;
|
|
6
|
+
/** 파일이 이미지 처리 라이브러리를 직접 import 하는가(단위 테스트 진입점). */
|
|
7
|
+
export declare function importsImageLib(source: string): boolean;
|
|
8
|
+
/** 액션 본문 텍스트에 리터럴 외부 URL 로의 fetch/axios 호출이 있는가(단위 테스트 진입점). */
|
|
9
|
+
export declare function callsExternalHttp(actionBody: string): boolean;
|
|
10
|
+
/** apps/ 의 컨트롤러를 훑어 요청 경로 인라인 무거운/외부 작업을 경고로 낸다. */
|
|
11
|
+
export declare function checkAsyncOffload(cwd: string): Promise<RuleReport>;
|
|
12
|
+
interface PageAction {
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly line: number;
|
|
15
|
+
readonly body: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* controller({ … }) 안에서 **페이지 액션**(return this.render/redirect 을 담는
|
|
19
|
+
* 액션)만 골라 이름·라인·본문 텍스트를 낸다. JSON 액션(반환값=응답 · render/
|
|
20
|
+
* redirect 없음)은 제외한다 — 외부 프록시/집계가 정당한 자리라 C arm 대상 밖.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pageActions(file: string, source: string): PageAction[];
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · 요청 경로 인라인 무거운/외부 작업 검출 (결정 102·103 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// 비동기 배치 판단표(결정 102 · AGENTS §3.5 · agents/async.md 서두)의 강제 장치다.
|
|
4
|
+
// 컨트롤러 액션 안에서 "응답에 필요 없는데 무겁거나 느린 일" 을 동기로 하면 웹
|
|
5
|
+
// 응답이 그 무게를 진다 — Node 는 싱글 스레드라 이웃 요청까지 전부 세운다. 정답은
|
|
6
|
+
// `domain/jobs/` 잡으로 빼고 `.later()` 로 발행하는 것(§3.5 · async.md §1).
|
|
7
|
+
//
|
|
8
|
+
// 검출 3종(대표 인라인 패턴):
|
|
9
|
+
// A) 메일 SDK 직접 import (nodemailer·resend·@sendgrid/mail·mailgun) — 컨트롤러는
|
|
10
|
+
// 잡 발행만 (async.md 함정 · async-flow forbidden 과 동일 집합). 앱 종류 무관.
|
|
11
|
+
// B) 이미지/미디어 처리 라이브러리 import (sharp·jimp·gm) — CPU 무거운 작업이
|
|
12
|
+
// 요청 경로에 있다. 앱 종류 무관.
|
|
13
|
+
// C) 외부 HTTP 호출 (fetch/axios 에 **리터럴 `http(s)://` URL**) — 페이지 액션
|
|
14
|
+
// 안에서. 느린/불안정 외부 호출은 응답 시간을 보호하려면 잡으로.
|
|
15
|
+
//
|
|
16
|
+
// 오탐 설계(결정 103 · response-mixing 교훈 "확신 없으면 잡지 않는다"):
|
|
17
|
+
// · **API 앱 제외** — app.config 에 `strategy: 'jwt'` 면 외부 API 호출이 정당
|
|
18
|
+
// (§3 데이터 경로 4종 · 외부 공개 API 앱). 해당 앱은 C(외부 HTTP)를 끈다.
|
|
19
|
+
// · **JSON 액션 제외** — 반환값=응답 액션(E-3)은 프록시/집계일 수 있어 C 제외.
|
|
20
|
+
// C 는 페이지 액션(render/redirect)에만 적용한다.
|
|
21
|
+
// · **리터럴 외부 URL 만** — 변수 URL·상대/내부 경로(`/...`)·`localhost`·
|
|
22
|
+
// `127.0.0.1` 은 정적 판별 불가/내부 호출이라 **잡지 않는다**(빠른 내부 호출
|
|
23
|
+
// 오탐 방지). internal-anchor 가 루트-상대/템플릿리터럴만 잡은 것과 동형.
|
|
24
|
+
// · 주석은 라인 보존 스트립으로 제외 — "이렇게 쓰지 말라" 설명 주석 오탐 방지.
|
|
25
|
+
//
|
|
26
|
+
// 판정은 소스 텍스트 + TS AST(액션 경계)만 본다 — 파일을 실행하지 않는다.
|
|
27
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
28
|
+
import { existsSync } from 'node:fs';
|
|
29
|
+
import { join, relative } from 'node:path';
|
|
30
|
+
import ts from 'typescript';
|
|
31
|
+
// ── 검출 시그니처 ─────────────────────────────────────────────────────
|
|
32
|
+
/** 메일 SDK 모듈 import (컨트롤러 = 잡 발행만 · async.md 함정 · async-flow forbidden 동일 집합). */
|
|
33
|
+
const MAIL_SDK_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:nodemailer|resend|@sendgrid\/mail|mailgun[.\-/][^'"]*|mailgun)['"]/;
|
|
34
|
+
/** 이미지/미디어 처리 라이브러리 import (CPU 무거운 작업). */
|
|
35
|
+
const IMAGE_LIB_IMPORT = /import\s+[\s\S]*?\bfrom\s+['"](?:sharp|jimp|gm)['"]/;
|
|
36
|
+
/**
|
|
37
|
+
* 페이지 액션 본문 안 외부 HTTP 호출 — 리터럴 `http(s)://` URL 만.
|
|
38
|
+
* localhost·127.0.0.1(내부)은 음성 룩어헤드로 제외한다. 변수 URL·상대 경로는
|
|
39
|
+
* 매치하지 않는다(정적 판별 불가 = 잡지 않는다).
|
|
40
|
+
*/
|
|
41
|
+
const EXTERNAL_HTTP = [
|
|
42
|
+
// fetch('https://...') · fetch(`https://...`)
|
|
43
|
+
/\bfetch\s*\(\s*[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
44
|
+
// axios('https://...') · axios.get('https://...') · axios({ url: 'https://...' })
|
|
45
|
+
/\baxios\s*(?:\.\s*(?:get|post|put|patch|delete|request|head)\s*)?\(\s*(?:\{[\s\S]*?\burl\s*:\s*)?[`'"]https?:\/\/(?!localhost|127\.0\.0\.1)/i,
|
|
46
|
+
];
|
|
47
|
+
/** app.config 소스가 JWT/API 앱인가 (외부 HTTP arm 제외 대상 · csrf-wiring 과 동일 판정). */
|
|
48
|
+
export function isApiApp(appConfigSource) {
|
|
49
|
+
return /strategy\s*:\s*['"]jwt['"]/.test(appConfigSource);
|
|
50
|
+
}
|
|
51
|
+
/** 파일이 메일 SDK 를 직접 import 하는가(단위 테스트 진입점). */
|
|
52
|
+
export function importsMailSdk(source) {
|
|
53
|
+
return MAIL_SDK_IMPORT.test(stripComments(source));
|
|
54
|
+
}
|
|
55
|
+
/** 파일이 이미지 처리 라이브러리를 직접 import 하는가(단위 테스트 진입점). */
|
|
56
|
+
export function importsImageLib(source) {
|
|
57
|
+
return IMAGE_LIB_IMPORT.test(stripComments(source));
|
|
58
|
+
}
|
|
59
|
+
/** 액션 본문 텍스트에 리터럴 외부 URL 로의 fetch/axios 호출이 있는가(단위 테스트 진입점). */
|
|
60
|
+
export function callsExternalHttp(actionBody) {
|
|
61
|
+
const stripped = stripComments(actionBody);
|
|
62
|
+
return EXTERNAL_HTTP.some((re) => re.test(stripped));
|
|
63
|
+
}
|
|
64
|
+
// ── 검사 본체 ─────────────────────────────────────────────────────────
|
|
65
|
+
/** apps/ 의 컨트롤러를 훑어 요청 경로 인라인 무거운/외부 작업을 경고로 낸다. */
|
|
66
|
+
export async function checkAsyncOffload(cwd) {
|
|
67
|
+
const appsDir = join(cwd, 'apps');
|
|
68
|
+
const issues = [];
|
|
69
|
+
for (const app of await safeListDirs(appsDir)) {
|
|
70
|
+
// 앱 종류 판정 — API 앱이면 외부 HTTP arm 을 끈다(오탐 방지 · 결정 103).
|
|
71
|
+
const acPath = join(appsDir, app, 'app.config.ts');
|
|
72
|
+
const acSource = existsSync(acPath) ? await readFile(acPath, 'utf8') : '';
|
|
73
|
+
const apiApp = isApiApp(acSource);
|
|
74
|
+
const ctrlDir = join(appsDir, app, 'controllers');
|
|
75
|
+
for (const file of await safeListFiles(ctrlDir)) {
|
|
76
|
+
if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
|
|
77
|
+
continue;
|
|
78
|
+
const full = join(ctrlDir, file);
|
|
79
|
+
const rel = relative(cwd, full);
|
|
80
|
+
const source = await readFile(full, 'utf8');
|
|
81
|
+
const clean = stripComments(source);
|
|
82
|
+
// A) 메일 SDK import — 앱 종류 무관 경고(컨트롤러는 잡 발행만).
|
|
83
|
+
const mailImport = firstMatch(clean, MAIL_SDK_IMPORT);
|
|
84
|
+
if (mailImport) {
|
|
85
|
+
issues.push(offloadIssue(rel, lineOf(source, mailImport.index), 'mail', app, `메일 SDK 직접 import (${mailImport.text.trim().slice(0, 60)}…)`, '메일 발송은 응답에 필요 없고 실패 시 재시도가 필요한 일'));
|
|
86
|
+
}
|
|
87
|
+
// B) 이미지 처리 라이브러리 import — 앱 종류 무관 경고(CPU 무거운 작업).
|
|
88
|
+
const imgImport = firstMatch(clean, IMAGE_LIB_IMPORT);
|
|
89
|
+
if (imgImport) {
|
|
90
|
+
issues.push(offloadIssue(rel, lineOf(source, imgImport.index), 'image', app, `이미지 처리 라이브러리 import (${imgImport.text.trim().slice(0, 60)}…)`, '이미지 리사이즈/변환은 CPU 무거운 작업 — 루프를 막아 이웃 요청을 세운다'));
|
|
91
|
+
}
|
|
92
|
+
// C) 외부 HTTP — 페이지 액션(render/redirect)에서만 · API 앱 제외.
|
|
93
|
+
if (apiApp)
|
|
94
|
+
continue;
|
|
95
|
+
for (const action of pageActions(full, source)) {
|
|
96
|
+
if (!callsExternalHttp(action.body))
|
|
97
|
+
continue;
|
|
98
|
+
issues.push(offloadIssue(rel, action.line, 'http', app, `페이지 액션 '${action.name}' 안에서 외부 HTTP 호출(리터럴 URL)`, '느리거나 불안정한 외부 호출이 응답 시간에 그대로 실린다'));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { rule: 'async-offload', issues };
|
|
103
|
+
}
|
|
104
|
+
/** 공통 경고 메시지 조립 — §7.5.3(에러 = 수리 안내서)대로 "→ 어떻게 고치라" 까지. */
|
|
105
|
+
function offloadIssue(rel, line, kind, app, what, why) {
|
|
106
|
+
const apiHint = kind === 'http'
|
|
107
|
+
? `\n→ 외부 공개 API 앱(모바일·서드파티)이라 외부 호출이 정당하면 apps/${app}/app.config.ts 에 auth: { strategy: 'jwt', ... } 를 선언하세요(이 경고 제외).`
|
|
108
|
+
: '';
|
|
109
|
+
return {
|
|
110
|
+
rule: 'async-offload',
|
|
111
|
+
level: 'warning',
|
|
112
|
+
file: rel,
|
|
113
|
+
line,
|
|
114
|
+
message: `요청 경로 인라인 작업: ${rel}:${line} · ${what}. ${why} — ` +
|
|
115
|
+
`Node 는 싱글 스레드라 무거운/느린 작업 하나가 이웃 요청을 전부 세웁니다(결정 102 판단표).\n` +
|
|
116
|
+
`→ domain/jobs/ 아래 잡으로 빼고 컨트롤러에서는 .later() 로 발행하세요:\n` +
|
|
117
|
+
` // domain/jobs/<camel>.ts — export const <Pascal> = job(async (…) => { …무거운 일… }, { retries: 3 })\n` +
|
|
118
|
+
` // 컨트롤러: await <Pascal>.later(…) (응답은 즉시 반환 · 처리는 gaon work 프로세스)\n` +
|
|
119
|
+
`→ 커밋 뒤에만 나가야 하면 서비스 afterCommit() 또는 아웃박스(agents/async.md §4).` +
|
|
120
|
+
apiHint,
|
|
121
|
+
detail: { app, kind },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* controller({ … }) 안에서 **페이지 액션**(return this.render/redirect 을 담는
|
|
126
|
+
* 액션)만 골라 이름·라인·본문 텍스트를 낸다. JSON 액션(반환값=응답 · render/
|
|
127
|
+
* redirect 없음)은 제외한다 — 외부 프록시/집계가 정당한 자리라 C arm 대상 밖.
|
|
128
|
+
*/
|
|
129
|
+
export function pageActions(file, source) {
|
|
130
|
+
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
|
|
131
|
+
const out = [];
|
|
132
|
+
const visit = (node) => {
|
|
133
|
+
if (ts.isCallExpression(node) && isControllerCall(node)) {
|
|
134
|
+
const arg = node.arguments[0];
|
|
135
|
+
if (arg && ts.isObjectLiteralExpression(arg)) {
|
|
136
|
+
for (const prop of arg.properties) {
|
|
137
|
+
const name = actionName(prop);
|
|
138
|
+
const body = actionBodyNode(prop);
|
|
139
|
+
if (!name || !body)
|
|
140
|
+
continue;
|
|
141
|
+
if (!rendersPage(body))
|
|
142
|
+
continue; // JSON/void 액션 제외
|
|
143
|
+
const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
|
|
144
|
+
out.push({ name, line: line + 1, body: body.getText(sf) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
ts.forEachChild(node, visit);
|
|
149
|
+
};
|
|
150
|
+
visit(sf);
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function isControllerCall(node) {
|
|
154
|
+
const e = node.expression;
|
|
155
|
+
if (ts.isIdentifier(e) && e.text === 'controller')
|
|
156
|
+
return true;
|
|
157
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
|
|
158
|
+
return true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
function actionName(prop) {
|
|
162
|
+
if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
|
|
163
|
+
return prop.name.text;
|
|
164
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
165
|
+
ts.isIdentifier(prop.name) &&
|
|
166
|
+
(ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
|
|
167
|
+
return prop.name.text;
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
function actionBodyNode(prop) {
|
|
172
|
+
if (ts.isMethodDeclaration(prop))
|
|
173
|
+
return prop.body;
|
|
174
|
+
if (ts.isPropertyAssignment(prop)) {
|
|
175
|
+
if (ts.isArrowFunction(prop.initializer))
|
|
176
|
+
return prop.initializer.body;
|
|
177
|
+
if (ts.isFunctionExpression(prop.initializer))
|
|
178
|
+
return prop.initializer.body;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/** 액션 본문이 this.render/this.redirect 를 하나라도 담는가(= 페이지 액션). */
|
|
183
|
+
function rendersPage(body) {
|
|
184
|
+
let renders = false;
|
|
185
|
+
const visit = (node) => {
|
|
186
|
+
if (renders)
|
|
187
|
+
return;
|
|
188
|
+
// 중첩 함수(콜백)로 내려가지 않는다 — 액션 자신의 응답만 본다.
|
|
189
|
+
if (node !== body &&
|
|
190
|
+
(ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (ts.isPropertyAccessExpression(node) &&
|
|
194
|
+
node.expression.kind === ts.SyntaxKind.ThisKeyword &&
|
|
195
|
+
(node.name.text === 'render' || node.name.text === 'redirect')) {
|
|
196
|
+
renders = true;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
ts.forEachChild(node, visit);
|
|
200
|
+
};
|
|
201
|
+
visit(body);
|
|
202
|
+
return renders;
|
|
203
|
+
}
|
|
204
|
+
// ── 텍스트 유틸 ───────────────────────────────────────────────────────
|
|
205
|
+
/** 라인 보존 주석 스트립 — 안티패턴을 설명하는 주석이 오탐을 내지 않도록. */
|
|
206
|
+
function stripComments(source) {
|
|
207
|
+
return source
|
|
208
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
209
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + ' '.repeat(m.length - p1.length));
|
|
210
|
+
}
|
|
211
|
+
function firstMatch(source, re) {
|
|
212
|
+
const m = re.exec(source);
|
|
213
|
+
return m ? { index: m.index, text: m[0] } : undefined;
|
|
214
|
+
}
|
|
215
|
+
/** 문자 오프셋 → 1-기반 라인 번호. */
|
|
216
|
+
function lineOf(source, index) {
|
|
217
|
+
return source.slice(0, index).split('\n').length;
|
|
218
|
+
}
|
|
219
|
+
async function safeListDirs(dir) {
|
|
220
|
+
try {
|
|
221
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
222
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function safeListFiles(dir) {
|
|
229
|
+
try {
|
|
230
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
231
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return [];
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
3
|
+
export declare function usesPagePropsDestructure(source: string): boolean;
|
|
4
|
+
/** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
|
|
5
|
+
export declare function checkPagePropsDestructure(cwd: string): Promise<RuleReport>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @gaonjs/cli · doctor · pageProps() 구조분해 검출 (결정 99 · 경고)
|
|
2
|
+
//
|
|
3
|
+
// `pageProps()` 는 반응형 프록시다 — 같은 페이지로의 리다이렉트/리로드로 서버가
|
|
4
|
+
// 새 props 를 주면(댓글 작성·삭제 등) 재마운트 없이 화면이 즉시 갱신된다. 하지만
|
|
5
|
+
// `const { posts } = pageProps<...>()` 처럼 **구조분해**하면 그 순간 값을 한 번
|
|
6
|
+
// 읽어 반응성이 끊긴다(Vue defineProps 를 구조분해하면 안 되는 것과 같은 함정 ·
|
|
7
|
+
// 첫 실사용 블로그에서 preserveState:false 강제 리로드 우회로 나타난 근본 원인).
|
|
8
|
+
// 이 검사가 그 구조분해를 경고로 잡는다. 판정은 소스 텍스트 기반(주석 제외).
|
|
9
|
+
//
|
|
10
|
+
// 오탐 방지: `const props = pageProps<...>()`(변수 바인딩)는 정상이므로 건드리지
|
|
11
|
+
// 않는다 — 여는 중괄호 `{` 로 시작하는 구조분해 바인딩만 잡는다.
|
|
12
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
13
|
+
import { join, relative } from 'node:path';
|
|
14
|
+
// 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — "구조분해하지
|
|
15
|
+
// 말라"고 설명하는 주석이 오탐을 내지 않도록.
|
|
16
|
+
function stripCommentsKeepLines(source) {
|
|
17
|
+
const blank = (m) => m.replace(/[^\n]/g, ' ');
|
|
18
|
+
return source
|
|
19
|
+
.replace(/\/\*[\s\S]*?\*\//g, blank)
|
|
20
|
+
.replace(/<!--[\s\S]*?-->/g, blank)
|
|
21
|
+
.replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
|
|
22
|
+
}
|
|
23
|
+
// `const|let|var { ... } = pageProps` — 구조분해 바인딩의 초기화가 pageProps 호출.
|
|
24
|
+
// 제네릭 인자(`<'web:posts#index'>`)·공백·중첩 중괄호는 허용하되, `{` 로 시작하는
|
|
25
|
+
// 구조분해만 잡는다(변수 바인딩 `const props =` 은 제외).
|
|
26
|
+
const DESTRUCTURE_RE = /\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*pageProps\b/g;
|
|
27
|
+
/** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
|
|
28
|
+
export function usesPagePropsDestructure(source) {
|
|
29
|
+
DESTRUCTURE_RE.lastIndex = 0;
|
|
30
|
+
return DESTRUCTURE_RE.test(stripCommentsKeepLines(source));
|
|
31
|
+
}
|
|
32
|
+
/** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
|
|
33
|
+
export async function checkPagePropsDestructure(cwd) {
|
|
34
|
+
const appsDir = join(cwd, 'apps');
|
|
35
|
+
const issues = [];
|
|
36
|
+
for (const abs of await walkVue(appsDir)) {
|
|
37
|
+
const source = await readFile(abs, 'utf8').catch(() => '');
|
|
38
|
+
const stripped = stripCommentsKeepLines(source);
|
|
39
|
+
const rel = relative(cwd, abs);
|
|
40
|
+
for (const m of stripped.matchAll(DESTRUCTURE_RE)) {
|
|
41
|
+
const line = stripped.slice(0, m.index ?? 0).split('\n').length;
|
|
42
|
+
issues.push({
|
|
43
|
+
rule: 'pageprops-destructure',
|
|
44
|
+
level: 'warning',
|
|
45
|
+
file: rel,
|
|
46
|
+
line,
|
|
47
|
+
message: `pageProps() 구조분해 발견: ${rel}:${line} 이 \`const { … } = pageProps(…)\` 로 ` +
|
|
48
|
+
`props 를 구조분해합니다. pageProps() 는 반응형 프록시라, 구조분해하면 그 순간 값을 ` +
|
|
49
|
+
`한 번 읽어 반응성이 끊깁니다 — 같은 페이지로의 리다이렉트/리로드(댓글 작성·삭제 등) ` +
|
|
50
|
+
`후 새 데이터가 화면에 반영되지 않습니다(결정 99).\n` +
|
|
51
|
+
`→ 변수로 받아 속성으로 접근하세요: \`const props = pageProps<'…'>()\` 후 ` +
|
|
52
|
+
`\`props.posts\`. (Vue defineProps 를 구조분해하면 안 되는 것과 같은 이유입니다.)`,
|
|
53
|
+
detail: { file: rel, line },
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { rule: 'pageprops-destructure', issues };
|
|
58
|
+
}
|
|
59
|
+
/** apps/ 하위 .vue 절대경로. */
|
|
60
|
+
async function walkVue(dir) {
|
|
61
|
+
const out = [];
|
|
62
|
+
const walk = async (d) => {
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = await readdir(d, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
for (const e of entries) {
|
|
71
|
+
const abs = join(d, e.name);
|
|
72
|
+
if (e.isDirectory()) {
|
|
73
|
+
if (e.name === 'node_modules' || e.name === '.gaon')
|
|
74
|
+
continue;
|
|
75
|
+
await walk(abs);
|
|
76
|
+
}
|
|
77
|
+
else if (e.isFile() && e.name.endsWith('.vue')) {
|
|
78
|
+
out.push(abs);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
await walk(dir);
|
|
83
|
+
return out.sort();
|
|
84
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor';
|
|
1
|
+
export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload';
|
|
2
2
|
export type DoctorLevel = 'passed' | 'warning' | 'error';
|
|
3
3
|
export interface DoctorCheck {
|
|
4
4
|
readonly rule: DoctorRule;
|
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
|
+
// 21 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
|
|
4
4
|
// 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
|
|
5
5
|
// warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
|
|
6
6
|
// errors.length > 0 이면 fail 로 판단한다.
|
package/dist/doctor.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } fr
|
|
|
17
17
|
export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
|
|
18
18
|
export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
19
19
|
export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
|
|
20
|
+
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
21
|
+
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
20
22
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
21
23
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
22
24
|
export interface DoctorCommandOptions {
|
package/dist/doctor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 21 검사를 조립한다:
|
|
5
5
|
* 1) response-mixing (errata E-3 §C · 라이브)
|
|
6
6
|
* 2) n-plus-one (errata E-4 (e))
|
|
7
7
|
* 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
|
|
22
22
|
* 18) csrf-wiring (결정 93 · 비-GET 라우트 + session 미배선 = CSRF 무방비 경고)
|
|
23
23
|
* 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
|
|
24
|
+
* 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
|
|
25
|
+
* 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
|
|
24
26
|
*
|
|
25
27
|
* 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
|
|
26
28
|
* DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
|
|
@@ -52,6 +54,8 @@ import { checkStaticCollision } from './doctor/static-collision.js';
|
|
|
52
54
|
import { checkMethodOverride } from './doctor/method-override.js';
|
|
53
55
|
import { checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
54
56
|
import { checkInternalAnchor } from './doctor/internal-anchor.js';
|
|
57
|
+
import { checkPagePropsDestructure } from './doctor/pageprops-destructure.js';
|
|
58
|
+
import { checkAsyncOffload } from './doctor/async-offload.js';
|
|
55
59
|
import { renderHuman, renderJson } from './doctor/reporter.js';
|
|
56
60
|
import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
57
61
|
import { makeResult, } from './doctor/types.js';
|
|
@@ -72,10 +76,12 @@ export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } fr
|
|
|
72
76
|
export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
|
|
73
77
|
export { hasStateChangingRoutes, checkCsrfWiring } from './doctor/csrf-wiring.js';
|
|
74
78
|
export { internalAnchorHref, usesInternalAnchor, checkInternalAnchor, } from './doctor/internal-anchor.js';
|
|
79
|
+
export { usesPagePropsDestructure, checkPagePropsDestructure, } from './doctor/pageprops-destructure.js';
|
|
80
|
+
export { isApiApp, importsMailSdk, importsImageLib, callsExternalHttp, pageActions, checkAsyncOffload, } from './doctor/async-offload.js';
|
|
75
81
|
export { renderHuman, renderJson } from './doctor/reporter.js';
|
|
76
82
|
export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
|
|
77
83
|
/**
|
|
78
|
-
* 실행할 검사 이름. 지정 없음(undefined) =
|
|
84
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 21개 모두.
|
|
79
85
|
*/
|
|
80
86
|
const ALL_RULES = [
|
|
81
87
|
'response-mixing',
|
|
@@ -97,6 +103,8 @@ const ALL_RULES = [
|
|
|
97
103
|
'method-override',
|
|
98
104
|
'csrf-wiring',
|
|
99
105
|
'internal-anchor',
|
|
106
|
+
'pageprops-destructure',
|
|
107
|
+
'async-offload',
|
|
100
108
|
];
|
|
101
109
|
const CHECKERS = {
|
|
102
110
|
'response-mixing': checkResponseMixing,
|
|
@@ -118,6 +126,8 @@ const CHECKERS = {
|
|
|
118
126
|
'method-override': checkMethodOverride,
|
|
119
127
|
'csrf-wiring': checkCsrfWiring,
|
|
120
128
|
'internal-anchor': checkInternalAnchor,
|
|
129
|
+
'pageprops-destructure': checkPagePropsDestructure,
|
|
130
|
+
'async-offload': checkAsyncOffload,
|
|
121
131
|
};
|
|
122
132
|
/**
|
|
123
133
|
* 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
|
package/dist/index.js
CHANGED
|
@@ -101,7 +101,7 @@ function renderHelp(version = VERSION) {
|
|
|
101
101
|
" gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
|
|
102
102
|
" gaon console 프로젝트 컨텍스트 REPL (--no-config)",
|
|
103
103
|
" gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
|
|
104
|
-
" gaon doctor 정적 검사 (
|
|
104
|
+
" gaon doctor 정적 검사 (21 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드)",
|
|
105
105
|
" gaon doctor --json 자동화용 JSON 출력",
|
|
106
106
|
" gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
|
|
107
107
|
" gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
|
|
@@ -147,6 +147,7 @@ export function parseDoctorChecks(argv) {
|
|
|
147
147
|
"shared-composable-purity",
|
|
148
148
|
"no-auto-import",
|
|
149
149
|
"csrf-wiring",
|
|
150
|
+
"async-offload",
|
|
150
151
|
];
|
|
151
152
|
const isKnown = (s) => known.includes(s);
|
|
152
153
|
const out = [];
|
|
@@ -9,14 +9,15 @@ import CardFooter from '../components/ui/CardFooter.vue'
|
|
|
9
9
|
import Button from '../components/ui/Button.vue'
|
|
10
10
|
|
|
11
11
|
// dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
|
|
12
|
-
|
|
12
|
+
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
13
|
+
const props = pageProps<'{{APP_NAME}}:dashboard#show'>()
|
|
13
14
|
|
|
14
15
|
// 로그아웃 = DELETE /session (r.resource('session') 의 destroy).
|
|
15
16
|
// HTML <form> 은 DELETE 를 보낼 수 없으므로 Inertia 라우터로 실제 메서드를
|
|
16
17
|
// 보낸다(결정 64) — `?_method=DELETE` 우회는 서버가 해석하지 않아 POST /session
|
|
17
18
|
// (= 로그인 create) 으로 잘못 라우팅됐다.
|
|
18
19
|
function logout(): void {
|
|
19
|
-
router.delete('/session', { headers: { 'x-csrf-token': csrf } })
|
|
20
|
+
router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
|
|
20
21
|
}
|
|
21
22
|
</script>
|
|
22
23
|
|
|
@@ -24,8 +25,8 @@ function logout(): void {
|
|
|
24
25
|
<div class="mx-auto max-w-2xl px-4 py-10">
|
|
25
26
|
<Card>
|
|
26
27
|
<CardHeader>
|
|
27
|
-
<CardTitle>환영합니다, {{ user.name }}님</CardTitle>
|
|
28
|
-
<CardDescription>{{ user.email }}</CardDescription>
|
|
28
|
+
<CardTitle>환영합니다, {{ props.user.name }}님</CardTitle>
|
|
29
|
+
<CardDescription>{{ props.user.email }}</CardDescription>
|
|
29
30
|
</CardHeader>
|
|
30
31
|
<CardContent>
|
|
31
32
|
<p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
|
|
@@ -12,12 +12,14 @@ import Button from '../../components/ui/Button.vue'
|
|
|
12
12
|
import Alert from '../../components/ui/Alert.vue'
|
|
13
13
|
import AlertDescription from '../../components/ui/AlertDescription.vue'
|
|
14
14
|
|
|
15
|
-
// 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
|
|
16
|
-
|
|
15
|
+
// 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2). pageProps 는
|
|
16
|
+
// 반응형이라 변수로 받아 props.x 로 접근한다 — 구조분해 금지(결정 99). 로그인
|
|
17
|
+
// 실패로 서버가 같은 페이지를 다시 render 하면 props.error 가 즉시 갱신된다.
|
|
18
|
+
const props = pageProps<'{{APP_NAME}}:session#new'>()
|
|
17
19
|
|
|
18
20
|
// 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
|
|
19
21
|
// 서버는 redirect(Inertia 응답)로 답하고, 실패 시 같은 페이지를 다시 render 한다.
|
|
20
|
-
const form = useForm({ email: '', password: '', _csrf: csrf })
|
|
22
|
+
const form = useForm({ email: '', password: '', _csrf: props.csrf })
|
|
21
23
|
</script>
|
|
22
24
|
|
|
23
25
|
<template>
|
|
@@ -28,8 +30,8 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
|
|
|
28
30
|
<CardDescription>계정으로 로그인하세요.</CardDescription>
|
|
29
31
|
</CardHeader>
|
|
30
32
|
<CardContent>
|
|
31
|
-
<Alert v-if="error" variant="destructive" class="mb-4">
|
|
32
|
-
<AlertDescription>{{ error }}</AlertDescription>
|
|
33
|
+
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
34
|
+
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
33
35
|
</Alert>
|
|
34
36
|
<Form @submit="form.post('/session')">
|
|
35
37
|
<FormField label="이메일" :error="form.errors.email">
|
|
@@ -12,10 +12,11 @@ import Button from '../../components/ui/Button.vue'
|
|
|
12
12
|
import Alert from '../../components/ui/Alert.vue'
|
|
13
13
|
import AlertDescription from '../../components/ui/AlertDescription.vue'
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
16
|
+
const props = pageProps<'{{APP_NAME}}:registration#new'>()
|
|
16
17
|
|
|
17
18
|
// 세션 앱 폼 = Inertia SPA 제출(결정 64) — fetch() 로 만들지 않는다.
|
|
18
|
-
const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
|
|
19
|
+
const form = useForm({ name: '', email: '', password: '', _csrf: props.csrf })
|
|
19
20
|
</script>
|
|
20
21
|
|
|
21
22
|
<template>
|
|
@@ -26,8 +27,8 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
|
|
|
26
27
|
<CardDescription>새 계정을 만드세요.</CardDescription>
|
|
27
28
|
</CardHeader>
|
|
28
29
|
<CardContent>
|
|
29
|
-
<Alert v-if="error" variant="destructive" class="mb-4">
|
|
30
|
-
<AlertDescription>{{ error }}</AlertDescription>
|
|
30
|
+
<Alert v-if="props.error" variant="destructive" class="mb-4">
|
|
31
|
+
<AlertDescription>{{ props.error }}</AlertDescription>
|
|
31
32
|
</Alert>
|
|
32
33
|
<Form @submit="form.post('/registration')">
|
|
33
34
|
<FormField label="이름" :error="form.errors.name">
|
|
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
104
104
|
컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
|
|
105
105
|
`agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
|
|
106
106
|
|
|
107
|
-
### 2.2 `gaon doctor` 검사
|
|
107
|
+
### 2.2 `gaon doctor` 검사 21종
|
|
108
108
|
|
|
109
109
|
1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
|
|
110
110
|
2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
|
|
@@ -125,6 +125,8 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
125
125
|
17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
|
|
126
126
|
18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
|
|
127
127
|
19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
|
|
128
|
+
20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
|
|
129
|
+
21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
|
|
128
130
|
|
|
129
131
|
## 3. 로직 배치 One Way 판단표
|
|
130
132
|
|
|
@@ -158,6 +160,26 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
158
160
|
위치를 강제하지 않는다 (`agents/async.md` §2). DB 커밋 정합이 필요하면
|
|
159
161
|
서비스 `afterCommit()` 또는 아웃박스.
|
|
160
162
|
|
|
163
|
+
### 3.5 비동기 배치 (동기 인라인 vs 잡/이벤트/스케줄 · 결정 102)
|
|
164
|
+
|
|
165
|
+
§3.1~§3.4 가 "로직을 **어느 층**에 둘까"라면, 이 표는 "그 로직을 **동기로 둘까
|
|
166
|
+
비동기로 뺄까**"의 One Way 다. 상세·예시는 `agents/async.md` 서두 판단표.
|
|
167
|
+
|
|
168
|
+
| 상황 | 배치 | 이유 |
|
|
169
|
+
|---|---|---|
|
|
170
|
+
| 응답에 결과가 **필요** | 요청 인라인 | 사용자가 그 값을 기다린다 |
|
|
171
|
+
| 응답 불필요 · 실패 시 재시도 (메일·알림·웹훅) | **잡** (`domain/jobs/`) | 응답 지연 제거 + 재시도(백오프·DLQ) |
|
|
172
|
+
| 느리거나 불안정한 **외부 API** | **잡** | 이벤트 루프·응답 시간 보호 |
|
|
173
|
+
| **CPU 무거운** 작업 (이미지 리사이즈·리포트·대량 연산) | **잡** | 루프 블로킹 = 이웃 요청 전부 정지 |
|
|
174
|
+
| 트랜잭션 커밋 **파생 효과** | **이벤트 + 아웃박스** (`afterCommit`) | DB 확정과 정합 (롤백 시 발행 취소) |
|
|
175
|
+
| **주기** 작업 (정리·집계·랭킹) | **`domain/schedule.ts`** | 요청과 무관한 시간축 · 리더 1인만 |
|
|
176
|
+
|
|
177
|
+
**실무 신호**: 요청 중 수백 ms 이상 걸리는 일은 잡 후보. Node 는 싱글 스레드라
|
|
178
|
+
무거운 작업 하나가 이웃 요청을 세운다. **"잡으로 뺀다 = `work` 프로세스로
|
|
179
|
+
옮긴다"** — 인라인은 `serve`(웹)가, 잡·`afterCommit`·`schedule` 은 `work` 가
|
|
180
|
+
처리하므로 웹 응답이 그 무게를 지지 않는다. `gaon doctor` 의 `async-offload`
|
|
181
|
+
가 컨트롤러 인라인 메일·이미지 처리·외부 HTTP 를 경고로 잡는다(결정 103).
|
|
182
|
+
|
|
161
183
|
## 4. 검증 루프
|
|
162
184
|
|
|
163
185
|
작업마다 실행한다:
|
|
@@ -165,7 +187,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
|
|
|
165
187
|
```bash
|
|
166
188
|
gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
|
|
167
189
|
gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
|
|
168
|
-
gaon doctor # 정적 검사
|
|
190
|
+
gaon doctor # 정적 검사 21종 (§2.2)
|
|
169
191
|
```
|
|
170
192
|
|
|
171
193
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -5,6 +5,54 @@
|
|
|
5
5
|
> 대상 패키지: `@gaonjs/async` (파사드 import 는 `gaonjs/async`). 백본 = NATS JetStream ·
|
|
6
6
|
> 실행은 워커 프로세스(`gaon work`).
|
|
7
7
|
|
|
8
|
+
## 비동기 배치의 One Way 판단표 (결정 102)
|
|
9
|
+
|
|
10
|
+
`agents/data.md` §5.3 / `AGENTS.md` §3.1 이 **서버 로직을 어느 층**(모델·서비스·
|
|
11
|
+
컨트롤러)에 둘지 정한다면, 이 표는 그 로직을 **동기(요청 인라인)로 둘지 비동기
|
|
12
|
+
(잡·이벤트·스케줄)로 뺄지**를 정한다 — 같은 "정답이 하나" 원칙의 비동기 축이다.
|
|
13
|
+
|
|
14
|
+
| 상황 | 배치 | 이유 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 응답에 결과가 **필요** | 요청 인라인 | 사용자가 그 값을 기다린다 (조회·검증·생성 결과) |
|
|
17
|
+
| 응답 불필요 · 실패 시 재시도 (메일·알림·웹훅) | **잡** (`domain/jobs/`) | 응답 지연 제거 + 재시도 배터리(백오프·DLQ) |
|
|
18
|
+
| 느리거나 불안정한 **외부 API** 호출 | **잡** | 이벤트 루프·응답 시간 보호 (외부 지연이 응답에 새지 않음) |
|
|
19
|
+
| **CPU 무거운** 작업 (이미지 리사이즈·리포트·대량 연산) | **잡** | 루프 블로킹 = 그 사이 이웃 요청 전부 정지 |
|
|
20
|
+
| 트랜잭션 커밋에 딸린 **파생 효과** | **이벤트 + 아웃박스** (`afterCommit`) | DB 확정과 정합 (롤백 시 발행도 취소 · §4) |
|
|
21
|
+
| **주기** 작업 (정리·집계·랭킹) | **`domain/schedule.ts`** | 요청과 무관한 시간축 · 리더 1인만 실행 |
|
|
22
|
+
|
|
23
|
+
**실무 신호**: 요청 처리 중 **수백 ms 이상** 걸리는 일은 잡 후보다. Node 는 싱글
|
|
24
|
+
스레드라 무거운 작업 하나가 워커 앞에 이웃 요청을 전부 세운다(head-of-line
|
|
25
|
+
blocking). 확신이 안 서면 "응답에 이 결과가 필요한가?" 만 묻는다 — 아니면 잡이다.
|
|
26
|
+
|
|
27
|
+
**"잡으로 뺀다 = work 프로세스로 옮긴다".** 운영 프로세스는 3종(§6): `gaon serve`
|
|
28
|
+
(웹 요청) · `gaon work`(잡·리스너·스케줄·아웃박스 릴레이) · `gaon hub`(실시간).
|
|
29
|
+
인라인 코드는 `serve` 안에서 요청 스레드로 돈다. `.later()`·`afterCommit`·
|
|
30
|
+
`schedule.ts` 로 뺀 일은 **`work` 프로세스**가 별도로 처리하므로 웹 응답이 그
|
|
31
|
+
무게를 지지 않는다. 그래서 "느리다/무겁다/실패해도 재시도" 는 전부 `work` 행(行)이다.
|
|
32
|
+
|
|
33
|
+
각 배치의 정본 예시 (전체 시그니처·옵션은 아래 §1·§4·§5):
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// 잡 — 응답과 분리해 발행 (메일·알림·이미지 처리 등). 컨트롤러/서비스에서:
|
|
37
|
+
await SendWelcomeMail.later(user.id) // domain/jobs/sendWelcomeMail.ts (§1)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
// 파생 효과 — 커밋 뒤에만 나가야 하는 발행은 서비스 afterCommit (§4 아웃박스).
|
|
42
|
+
export const RegisterUser = service(async (input: RegisterInput) => {
|
|
43
|
+
const user = await User.create(input)
|
|
44
|
+
afterCommit(() => ResizeAvatar.later(user.id)) // 커밋 성공 후에만 발행
|
|
45
|
+
return user
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// 주기 작업 — domain/schedule.ts · 대상은 항상 잡 (§5 · 인라인 함수 금지).
|
|
51
|
+
export default schedule((s) => {
|
|
52
|
+
s.cron('0 9 * * 1', SendWeeklyReport) // 매주 월 09:00 · 리더 1인만
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
8
56
|
## 정본 규칙
|
|
9
57
|
|
|
10
58
|
### 1. 잡 (`job()`) (`packages/async/src/jobs.ts:146-185`)
|
|
@@ -209,6 +257,11 @@ async create() {
|
|
|
209
257
|
|
|
210
258
|
- **컨트롤러에서 메일·외부 발송 직접 호출 = 함정** — 컨트롤러는 잡
|
|
211
259
|
발행만. nodemailer·resend·@sendgrid/mail 직접 import 금지.
|
|
260
|
+
- **무거운/느린 작업을 요청 인라인에 두면 함정** (판단표 · 결정 102) —
|
|
261
|
+
이미지 리사이즈(sharp·jimp)·외부 API 호출을 컨트롤러 액션 안에서 동기로
|
|
262
|
+
하면 응답이 그 무게를 진다. `domain/jobs/` 잡으로 빼고 `.later()` 로 발행한다.
|
|
263
|
+
`gaon doctor` 의 `async-offload` 가 이 인라인 패턴을 경고로 잡는다(결정 103 ·
|
|
264
|
+
JSON/API 앱의 정당한 외부 호출은 오탐 방지로 제외).
|
|
212
265
|
- **클래스형 잡·데코레이터(`@Job`·`@Processor`) 금지** — `job()` 함수형만.
|
|
213
266
|
- **잡 파일 위치는 `domain/jobs/`** — 앱 폴더가 아니다 (잡은 도메인
|
|
214
267
|
소속 · 어느 앱에서든 큐잉).
|
|
@@ -225,6 +278,8 @@ async create() {
|
|
|
225
278
|
|
|
226
279
|
| 결정 | 내용 |
|
|
227
280
|
|---|---|
|
|
281
|
+
| 결정 102 | 비동기 배치 One Way 판단표 (동기 인라인 vs 잡/이벤트/스케줄 · 서두 표) |
|
|
282
|
+
| 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
|
|
228
283
|
| 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
|
|
229
284
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
|
|
230
285
|
| §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
|
|
@@ -31,6 +31,13 @@ const props = pageProps<'web:posts#index'>()
|
|
|
31
31
|
</template>
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
+
- **변수로 받아 `props.x` 로 접근 — 구조분해 금지** (결정 99). `pageProps()` 는
|
|
35
|
+
반응형 프록시라, 같은 페이지로의 리다이렉트/리로드로 서버가 새 props 를 주면
|
|
36
|
+
(댓글 작성·삭제 등) 재마운트 없이 화면이 즉시 갱신된다. 하지만
|
|
37
|
+
`const { posts } = pageProps<...>()` 처럼 구조분해하면 그 순간 값을 한 번
|
|
38
|
+
읽어 **반응성이 끊긴다**(Vue `defineProps` 를 구조분해하면 안 되는 것과 같은
|
|
39
|
+
함정). 항상 `const props = pageProps<...>()` 로 받아 `props.posts` 로 쓴다.
|
|
40
|
+
구조분해는 doctor **pageprops-destructure** 가 잡는다(경고).
|
|
34
41
|
- **라우트 키** = `<app>:<controller>#<action>` (앱 폴더명 · 컨트롤러 파일명
|
|
35
42
|
stem · 소문자 복수 · 결정 55). `apps/web/controllers/posts.ts` 의 `index`
|
|
36
43
|
액션 → `'web:posts#index'`.
|
|
@@ -155,12 +155,13 @@ redirect 로 처리한다 — 전체 페이지 리로드도, 별도 REST 엔드
|
|
|
155
155
|
|
|
156
156
|
```ts
|
|
157
157
|
// 로그인 폼 — 제출은 Inertia SPA 방식, 서버는 redirect 로 답한다.
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
158
|
+
// pageProps 는 반응형 — 변수로 받아 props.x 로 접근한다(구조분해 금지 · 결정 99).
|
|
159
|
+
const props = pageProps<'web:session#new'>()
|
|
160
|
+
const form = useForm({ email: '', password: '', _csrf: props.csrf })
|
|
161
|
+
// <form @submit.prevent="form.post('/session')"> · 실패 시 {{ props.error }} 가 반응형으로 갱신
|
|
161
162
|
|
|
162
163
|
// HTML <form> 이 못 보내는 메서드(DELETE 등)는 router 로 보낸다.
|
|
163
|
-
router.delete('/session', { headers: { 'x-csrf-token': csrf } })
|
|
164
|
+
router.delete('/session', { headers: { 'x-csrf-token': props.csrf } })
|
|
164
165
|
```
|
|
165
166
|
|
|
166
167
|
`?_method=DELETE` 같은 우회는 **서버가 해석하지 않는다** — POST 로 나가
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
30
|
"@gaonjs/async": "0.6.1",
|
|
31
|
-
"@gaonjs/
|
|
32
|
-
"@gaonjs/
|
|
31
|
+
"@gaonjs/web": "0.7.2",
|
|
32
|
+
"@gaonjs/config": "0.5.3",
|
|
33
33
|
"@gaonjs/core": "0.2.1",
|
|
34
|
-
"@gaonjs/
|
|
35
|
-
"@gaonjs/
|
|
34
|
+
"@gaonjs/mail": "0.1.3",
|
|
35
|
+
"@gaonjs/data": "0.9.2"
|
|
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})\""
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
// 웹 앱 팩토리 — gaon g auth 스캐폴드. createWebApp 으로 세션·인증을 배선한다.
|
|
2
|
-
import { createApp, type AppSessionOptions } from 'gaonjs/web'
|
|
3
|
-
import appRoutes from './routes.js'
|
|
4
|
-
import session from './controllers/session.js'
|
|
5
|
-
import registration from './controllers/registration.js'
|
|
6
|
-
import dashboard from './controllers/dashboard.js'
|
|
7
|
-
import { loadUser } from './auth.js'
|
|
8
|
-
|
|
9
|
-
export interface WebAppDeps {
|
|
10
|
-
/** 세션 설정 — { redisUrl, secret } (또는 redis 인스턴스). */
|
|
11
|
-
readonly session: AppSessionOptions
|
|
12
|
-
/** 서명 쿠키/CSRF 용 비밀. */
|
|
13
|
-
readonly cookieSecret?: string
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function createWebApp(deps: WebAppDeps) {
|
|
17
|
-
return createApp({
|
|
18
|
-
apps: [
|
|
19
|
-
{
|
|
20
|
-
name: '{{APP_NAME}}',
|
|
21
|
-
routes: appRoutes,
|
|
22
|
-
controllers: { session, registration, dashboard },
|
|
23
|
-
session: deps.session,
|
|
24
|
-
auth: { loadUser, loginRedirect: '/session/new' },
|
|
25
|
-
},
|
|
26
|
-
],
|
|
27
|
-
cookieSecret: deps.cookieSecret,
|
|
28
|
-
})
|
|
29
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
// 서버 진입점 — gaon g auth 스캐폴드. `node dist/server.js` 로 실행.
|
|
2
|
-
import { createWebApp } from './apps/{{APP_NAME}}/app.js'
|
|
3
|
-
|
|
4
|
-
const app = await createWebApp({
|
|
5
|
-
session: {
|
|
6
|
-
redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
|
|
7
|
-
secret: process.env.SESSION_SECRET ?? 'change-me-to-a-32+char-random-secret!!',
|
|
8
|
-
},
|
|
9
|
-
cookieSecret: process.env.COOKIE_SECRET,
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
const port = Number(process.env.PORT ?? 3000)
|
|
13
|
-
await app.listen({ port, host: '0.0.0.0' })
|
|
14
|
-
console.log(`web 앱이 http://localhost:${port} 에서 실행 중입니다.`)
|