@gaonjs/web 0.1.1 → 0.1.3
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/controller.d.ts +14 -0
- package/dist/controller.js +29 -0
- package/dist/generator.d.ts +23 -0
- package/dist/generator.js +120 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +9 -1
- package/dist/routes.d.ts +22 -0
- package/dist/routes.js +30 -0
- package/package.json +2 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface Rendered<P> {
|
|
2
|
+
readonly page: string;
|
|
3
|
+
readonly props: P;
|
|
4
|
+
}
|
|
5
|
+
/** 액션 본문의 `this` — M3 최소는 render 만. */
|
|
6
|
+
export interface ControllerContext {
|
|
7
|
+
render<P>(page: string, props: P): Rendered<P>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 액션 객체를 그대로 반환하되, 본문의 `this` 를 ControllerContext 로
|
|
11
|
+
* 준다(ThisType). 런타임에서는 각 액션을 context 에 바인딩해, 실제
|
|
12
|
+
* 호출 시 `this.render` 가 동작하게 한다.
|
|
13
|
+
*/
|
|
14
|
+
export declare function controller<A extends object>(actions: A & ThisType<ControllerContext>): A;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @gaonjs/web · controller() 최소 런타임 (M3 — 최소 웹 레이어)
|
|
2
|
+
//
|
|
3
|
+
// 설계 §5.2: `controller({ async index() { return this.render(...) } })`.
|
|
4
|
+
// 액션은 `this.render(page, props)` 로 페이지와 props 를 반환하고, 그
|
|
5
|
+
// 반환 타입(Rendered<P>)이 타입 브리지(.gaon/routes.d.ts)를 거쳐 Vue
|
|
6
|
+
// 페이지의 pageProps<K>() 로 흐른다(§6.2·§6.3).
|
|
7
|
+
//
|
|
8
|
+
// ThisType 주의(§13.4-1 의 model 교훈과 동형): 액션 제네릭에 제약을
|
|
9
|
+
// 걸면 ThisType 추론이 조용히 붕괴한다 → **무제약 A + ThisType 교차**로
|
|
10
|
+
// 둔다. 타입 회귀 테스트(controller.test-d.ts)로 고정한다.
|
|
11
|
+
//
|
|
12
|
+
// this.params·auth·redirect 등 나머지 실행 시맨틱은 후속 마일스톤이다.
|
|
13
|
+
const context = {
|
|
14
|
+
render(page, props) {
|
|
15
|
+
return { page, props };
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* 액션 객체를 그대로 반환하되, 본문의 `this` 를 ControllerContext 로
|
|
20
|
+
* 준다(ThisType). 런타임에서는 각 액션을 context 에 바인딩해, 실제
|
|
21
|
+
* 호출 시 `this.render` 가 동작하게 한다.
|
|
22
|
+
*/
|
|
23
|
+
export function controller(actions) {
|
|
24
|
+
const bound = {};
|
|
25
|
+
for (const [name, fn] of Object.entries(actions)) {
|
|
26
|
+
bound[name] = typeof fn === 'function' ? fn.bind(context) : fn;
|
|
27
|
+
}
|
|
28
|
+
return bound;
|
|
29
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface DiscoveredRoute {
|
|
2
|
+
/** 라우트 맵 키 — `<controller>#<action>`. */
|
|
3
|
+
readonly key: string;
|
|
4
|
+
/** 이 컨트롤러의 import 바인딩 이름(파일 내 유일·유효 식별자). */
|
|
5
|
+
readonly binding: string;
|
|
6
|
+
/** `.gaon/routes.d.ts` 기준 컨트롤러 import 지정자(.js, posix). */
|
|
7
|
+
readonly importPath: string;
|
|
8
|
+
/** 컨트롤러 액션 이름. */
|
|
9
|
+
readonly action: string;
|
|
10
|
+
}
|
|
11
|
+
/** outDir(.gaon) 기준 상대 import 지정자 (.ts→.js, posix, ./·../ 보정). */
|
|
12
|
+
export declare function toImportSpecifier(outDir: string, file: string): string;
|
|
13
|
+
/** 컨트롤러 이름 → 파일 내 유효·유일 import 바인딩. */
|
|
14
|
+
export declare function bindingName(controller: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* 앱 디렉터리를 스캔해 라우트 목록을 수집한다.
|
|
17
|
+
* routes.ts 가 없으면 빈 목록(앱에 라우트가 아직 없을 수 있다).
|
|
18
|
+
*/
|
|
19
|
+
export declare function collectRoutes(appDir: string, outDir: string): Promise<DiscoveredRoute[]>;
|
|
20
|
+
/** 수집된 라우트 목록 → routes.d.ts 소스 텍스트. */
|
|
21
|
+
export declare function renderRoutesDts(routes: DiscoveredRoute[]): string;
|
|
22
|
+
/** 전 과정: 앱 스캔 → 렌더 → apps/<app>/.gaon/routes.d.ts 기록. 텍스트 반환. */
|
|
23
|
+
export declare function generateRoutesDts(appDir: string, outFile: string): Promise<string>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// @gaonjs/web · routes.d.ts 생성기 (M3 — 타입 브리지, 앱별)
|
|
2
|
+
//
|
|
3
|
+
// 앱 하나(apps/<app>)의 `routes.ts` 를 실행해 참조 컨트롤러를 얻고,
|
|
4
|
+
// 각 컨트롤러 파일(controllers/<name>.ts)의 default export 에서 **실제
|
|
5
|
+
// 구현된 액션 키**를 읽어 `.gaon/routes.d.ts` 를 낳는다.
|
|
6
|
+
//
|
|
7
|
+
// 생성 파일은 **타입 참조만** 담는다(CLAUDE.md 규칙 3):
|
|
8
|
+
// '<ctrl>#<action>': (typeof <ctrl>)['<action>']
|
|
9
|
+
// TanStack Router·Nuxt 가 검증한 방식이며, 참조만 담아 tsserver 부하가
|
|
10
|
+
// 거의 없다(설계 §6.3). 실제 액션 타입 계산은 전부 추론에 맡긴다.
|
|
11
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
12
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
13
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
14
|
+
import { pathToFileURL } from 'node:url';
|
|
15
|
+
/** outDir(.gaon) 기준 상대 import 지정자 (.ts→.js, posix, ./·../ 보정). */
|
|
16
|
+
export function toImportSpecifier(outDir, file) {
|
|
17
|
+
let rel = relative(resolve(outDir), resolve(file)).replace(/\\/g, '/');
|
|
18
|
+
rel = rel.replace(/\.ts$/, '.js');
|
|
19
|
+
if (!rel.startsWith('.'))
|
|
20
|
+
rel = './' + rel;
|
|
21
|
+
return rel;
|
|
22
|
+
}
|
|
23
|
+
/** 컨트롤러 이름 → 파일 내 유효·유일 import 바인딩. */
|
|
24
|
+
export function bindingName(controller) {
|
|
25
|
+
return 'c_' + controller.replace(/[^A-Za-z0-9_]/g, '_');
|
|
26
|
+
}
|
|
27
|
+
/** ESM 캐시 우회 — 워치 재생성 시 편집 반영(§generator.ts 와 동일 기법). */
|
|
28
|
+
async function importFresh(file) {
|
|
29
|
+
const st = await stat(file);
|
|
30
|
+
return (await import(`${pathToFileURL(file).href}?t=${st.mtimeMs}-${st.size}`));
|
|
31
|
+
}
|
|
32
|
+
function isRouteDef(v) {
|
|
33
|
+
return typeof v === 'object' && v !== null && Array.isArray(v.controllers);
|
|
34
|
+
}
|
|
35
|
+
/** default export(actions 객체)에서 함수 값을 가진 키만 액션으로 취한다. */
|
|
36
|
+
function actionKeys(mod) {
|
|
37
|
+
const def = mod.default;
|
|
38
|
+
if (typeof def !== 'object' || def === null)
|
|
39
|
+
return [];
|
|
40
|
+
return Object.entries(def)
|
|
41
|
+
.filter(([, v]) => typeof v === 'function')
|
|
42
|
+
.map(([k]) => k)
|
|
43
|
+
.sort();
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* 앱 디렉터리를 스캔해 라우트 목록을 수집한다.
|
|
47
|
+
* routes.ts 가 없으면 빈 목록(앱에 라우트가 아직 없을 수 있다).
|
|
48
|
+
*/
|
|
49
|
+
export async function collectRoutes(appDir, outDir) {
|
|
50
|
+
const routesFile = join(resolve(appDir), 'routes.ts');
|
|
51
|
+
const controllersDir = join(resolve(appDir), 'controllers');
|
|
52
|
+
const routesMod = await importFresh(routesFile);
|
|
53
|
+
const def = routesMod.default;
|
|
54
|
+
if (!isRouteDef(def)) {
|
|
55
|
+
throw new Error(`[@gaonjs/web] ${routesFile} 의 default export 가 routes(...) 결과가 아닙니다.\n` +
|
|
56
|
+
`→ routes.ts 를 \`export default routes((r) => { ... })\` 형태로 작성하세요.`);
|
|
57
|
+
}
|
|
58
|
+
const existing = new Set(await listControllerFiles(controllersDir));
|
|
59
|
+
const found = [];
|
|
60
|
+
for (const ctrl of def.controllers) {
|
|
61
|
+
const file = join(controllersDir, `${ctrl}.ts`);
|
|
62
|
+
if (!existing.has(file)) {
|
|
63
|
+
throw new Error(`[@gaonjs/web] 라우트가 참조하는 컨트롤러가 없습니다: ${ctrl}\n` +
|
|
64
|
+
`→ ${file} 에 \`export default controller({ ... })\` 를 만드세요.`);
|
|
65
|
+
}
|
|
66
|
+
const mod = await importFresh(file);
|
|
67
|
+
const importPath = toImportSpecifier(outDir, file);
|
|
68
|
+
const binding = bindingName(ctrl);
|
|
69
|
+
for (const action of actionKeys(mod)) {
|
|
70
|
+
found.push({ key: `${ctrl}#${action}`, binding, importPath, action });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return found.sort((a, b) => a.key.localeCompare(b.key));
|
|
74
|
+
}
|
|
75
|
+
async function listControllerFiles(dir) {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return []; // controllers 디렉터리가 아직 없을 수 있다
|
|
82
|
+
}
|
|
83
|
+
return entries
|
|
84
|
+
.filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts'))
|
|
85
|
+
.map((e) => join(dir, e.name));
|
|
86
|
+
}
|
|
87
|
+
/** 수집된 라우트 목록 → routes.d.ts 소스 텍스트. */
|
|
88
|
+
export function renderRoutesDts(routes) {
|
|
89
|
+
const lines = [];
|
|
90
|
+
lines.push('// AUTO-GENERATED by @gaonjs/web — 편집 금지.');
|
|
91
|
+
lines.push('// `gaon check` / `gaon dev` 가 검사·개발 중 이 파일을 재생성한다(§6.3).');
|
|
92
|
+
// 컨트롤러별 import (바인딩 유일) — 등장 순서 안정화.
|
|
93
|
+
const seen = new Map(); // binding → importPath
|
|
94
|
+
for (const r of routes)
|
|
95
|
+
if (!seen.has(r.binding))
|
|
96
|
+
seen.set(r.binding, r.importPath);
|
|
97
|
+
for (const [binding, importPath] of seen) {
|
|
98
|
+
lines.push(`import type ${binding} from '${importPath}'`);
|
|
99
|
+
}
|
|
100
|
+
lines.push('');
|
|
101
|
+
lines.push('declare global {');
|
|
102
|
+
lines.push(' interface GaonRouteMap {');
|
|
103
|
+
for (const r of routes) {
|
|
104
|
+
lines.push(` '${r.key}': (typeof ${r.binding})['${r.action}']`);
|
|
105
|
+
}
|
|
106
|
+
lines.push(' }');
|
|
107
|
+
lines.push('}');
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push('export {}');
|
|
110
|
+
lines.push('');
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|
|
113
|
+
/** 전 과정: 앱 스캔 → 렌더 → apps/<app>/.gaon/routes.d.ts 기록. 텍스트 반환. */
|
|
114
|
+
export async function generateRoutesDts(appDir, outFile) {
|
|
115
|
+
const outDir = dirname(resolve(outFile));
|
|
116
|
+
const text = renderRoutesDts(await collectRoutes(appDir, outDir));
|
|
117
|
+
await mkdir(outDir, { recursive: true });
|
|
118
|
+
await writeFile(outFile, text, 'utf8');
|
|
119
|
+
return text;
|
|
120
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
export declare const version
|
|
1
|
+
export declare const version: string;
|
|
2
2
|
export declare const status: "planned";
|
|
3
|
+
export { routes, type RouteBuilder, type RouteDef, type HttpMethod, } from "./routes.js";
|
|
4
|
+
export { controller, type Rendered, type ControllerContext, } from "./controller.js";
|
|
5
|
+
export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, type DiscoveredRoute, } from "./generator.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @gaonjs/web — Gaon 웹 레이어 (Fastify 통합·라우팅·보안 기본값).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 전체 실행 레이어(HTTP 바인딩·미들웨어·render)는 후속 마일스톤이다.
|
|
5
|
+
* M3 에서는 타입 브리지 이음새만 놓는다: routes() DSL·controller() 헬퍼와
|
|
6
|
+
* routes.d.ts 생성기(§6.3).
|
|
5
7
|
*/
|
|
6
8
|
import { VERSION } from "@gaonjs/core";
|
|
7
9
|
export const version = VERSION;
|
|
8
10
|
export const status = "planned";
|
|
11
|
+
// 라우팅 DSL (M3 최소 — 타입 브리지용)
|
|
12
|
+
export { routes, } from "./routes.js";
|
|
13
|
+
// controller() 최소 런타임 (M3 — this.render)
|
|
14
|
+
export { controller, } from "./controller.js";
|
|
15
|
+
// routes.d.ts 생성기 (앱별 타입 브리지)
|
|
16
|
+
export { collectRoutes, renderRoutesDts, generateRoutesDts, toImportSpecifier, bindingName, } from "./generator.js";
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete';
|
|
2
|
+
export interface RouteBuilder {
|
|
3
|
+
/** Rails 식 리소스 라우팅 — 표준 액션을 컨트롤러 <name> 에 연결. */
|
|
4
|
+
resources(name: string): void;
|
|
5
|
+
/** 단수 리소스(세션 등). */
|
|
6
|
+
resource(name: string): void;
|
|
7
|
+
get(path: string, target: string): void;
|
|
8
|
+
post(path: string, target: string): void;
|
|
9
|
+
put(path: string, target: string): void;
|
|
10
|
+
patch(path: string, target: string): void;
|
|
11
|
+
delete(path: string, target: string): void;
|
|
12
|
+
}
|
|
13
|
+
export interface RouteDef {
|
|
14
|
+
/** 이 앱 라우트가 참조하는 컨트롤러 이름들(정렬·중복제거). 타입 브리지 입력. */
|
|
15
|
+
readonly controllers: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* 라우트 정의 빌더를 실행해 참조 컨트롤러 목록을 수집한다.
|
|
19
|
+
* `get('/about', 'pages#about')` 처럼 `controller#action` 대상은
|
|
20
|
+
* `#` 앞부분(컨트롤러)만 취한다.
|
|
21
|
+
*/
|
|
22
|
+
export declare function routes(build: (r: RouteBuilder) => void): RouteDef;
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// @gaonjs/web · 라우팅 DSL (M3 최소 구현 — 타입 브리지용)
|
|
2
|
+
//
|
|
3
|
+
// 설계 §5.1 의 `routes((r) => { r.resources('posts') })` 형태다. M3 는
|
|
4
|
+
// 타입 브리지(routes.d.ts) 생성을 위해 **어느 컨트롤러가 이 앱 라우트에
|
|
5
|
+
// 참조되는지**만 기록하면 된다. 실제 라우팅 매칭·미들웨어·HTTP 바인딩은
|
|
6
|
+
// @gaonjs/web 후속 마일스톤이다 — 여기서는 그 이음새만 놓는다.
|
|
7
|
+
/**
|
|
8
|
+
* 라우트 정의 빌더를 실행해 참조 컨트롤러 목록을 수집한다.
|
|
9
|
+
* `get('/about', 'pages#about')` 처럼 `controller#action` 대상은
|
|
10
|
+
* `#` 앞부분(컨트롤러)만 취한다.
|
|
11
|
+
*/
|
|
12
|
+
export function routes(build) {
|
|
13
|
+
const controllers = new Set();
|
|
14
|
+
const addTarget = (target) => {
|
|
15
|
+
const ctrl = target.split('#')[0]?.trim();
|
|
16
|
+
if (ctrl)
|
|
17
|
+
controllers.add(ctrl);
|
|
18
|
+
};
|
|
19
|
+
const r = {
|
|
20
|
+
resources: (name) => void controllers.add(name),
|
|
21
|
+
resource: (name) => void controllers.add(name),
|
|
22
|
+
get: (_p, t) => addTarget(t),
|
|
23
|
+
post: (_p, t) => addTarget(t),
|
|
24
|
+
put: (_p, t) => addTarget(t),
|
|
25
|
+
patch: (_p, t) => addTarget(t),
|
|
26
|
+
delete: (_p, t) => addTarget(t),
|
|
27
|
+
};
|
|
28
|
+
build(r);
|
|
29
|
+
return { controllers: [...controllers].sort() };
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/web",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Gaon 웹 레이어: Fastify 통합·파일 기반 라우팅·보안 기본값 (구현 예정)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@gaonjs/core": "0.1.
|
|
27
|
+
"@gaonjs/core": "0.1.2"
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
|