@gaonjs/vue 0.1.3 → 0.2.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/api.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ import type { Serialized } from './serialize.js';
2
+ /**
3
+ * 라우트 매니페스트 — .gaon/routes.d.ts 가 채우는 GaonRouteMap 옆에 함께
4
+ * 채워지는 런타임/타입 지도. 키는 'controller#action'.
5
+ *
6
+ * pageProps 는 타입만 알면 되지만 api() 는 실제 HTTP 메서드·경로를 알아야
7
+ * 요청을 보낼 수 있다 — 그래서 라우트 지도가 필요하다. 생성기가 declaration
8
+ * merging + 값 export 로 함께 채운다(추가 생성 파일 X · 기존 파일에 병합).
9
+ */
10
+ export interface GaonRouteInfo {
11
+ readonly method: 'get' | 'post' | 'put' | 'patch' | 'delete';
12
+ readonly path: string;
13
+ }
14
+ declare global {
15
+ interface GaonRoutes {
16
+ }
17
+ }
18
+ /** GaonRouteMap[K] (액션 함수) 에서 render 결과 P 를 뽑아 낸다 — pageProps 와 동일. */
19
+ type PropsOf<F> = F extends (...args: never[]) => infer R ? Awaited<R> extends {
20
+ page: string;
21
+ props: infer P;
22
+ } ? P : Awaited<R> : never;
23
+ /**
24
+ * 요청 파라미터 — 라우트에 :id 같은 자리표시자가 있으면 여기서 뽑고, 나머지는
25
+ * GET 이면 쿼리스트링, 그 외 메서드는 JSON 본문으로 붙는다. 값은 문자열
26
+ * 화된다(경로/쿼리 특성).
27
+ */
28
+ export type ApiParams = Record<string, string | number | bigint | boolean | undefined | null>;
29
+ export interface ApiOptions {
30
+ /** 기본 http 헤더에 얹어 보낸다(Authorization 등). */
31
+ readonly headers?: Record<string, string>;
32
+ /** AbortController 지원(취소·타임아웃). */
33
+ readonly signal?: AbortSignal;
34
+ /** URL prefix (앱 프리픽스가 web 이 아니면 지정). 생략 시 window.origin. */
35
+ readonly baseUrl?: string;
36
+ /** fetch 구현 주입(테스트·SSR). 생략 시 globalThis.fetch. */
37
+ readonly fetch?: typeof fetch;
38
+ }
39
+ /**
40
+ * 라우트 키로 서버 액션을 부른다.
41
+ * · 반환 타입: 액션 반환의 Serialized<> (직렬화 경계 재사용).
42
+ * · 요청: 메서드는 라우트 정의에서, 경로는 :param 을 채워 완성.
43
+ * · 실패: 4xx/5xx 는 예외로 던지고, ValidationError(422)는 issues 를 포함한다.
44
+ */
45
+ export declare function api<K extends keyof GaonRouteMap>(key: K, params?: ApiParams, opts?: ApiOptions): Promise<Serialized<PropsOf<GaonRouteMap[K]>>>;
46
+ /**
47
+ * 라우트 매니페스트를 등록한다 — 프론트엔드 부트스트랩(app 엔트리)이 한 번
48
+ * 호출하면 이후 api() 가 method·path 를 안다. 앱 빌드 시 .gaon/routes.d.ts
49
+ * 와 함께 생성되는 매니페스트 모듈이 이 함수를 호출한다.
50
+ */
51
+ export declare function registerRoutes(routes: Record<string, GaonRouteInfo>): void;
52
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,127 @@
1
+ // @gaonjs/vue · api() — 타입드 클라이언트 (errata E-3 §C)
2
+ //
3
+ // pageProps 와 **같은 .gaon/routes.d.ts 브리지**를 재사용한다 — 추가 생성 파일
4
+ // 없음. 라우트 키 하나로 서버 액션의 반환 타입을 클라이언트에서 그대로 안다.
5
+ //
6
+ // import { api } from 'gaonjs/vue'
7
+ // const { results } = await api('posts#search', { q: keyword.value })
8
+ //
9
+ // 반환 타입은 액션 반환 타입의 **Serialized<>** (M4 render props 직렬화와 같은
10
+ // 규칙 · 같은 매핑 재사용). 별도 직렬화 규칙을 두지 않는다(The One Way).
11
+ //
12
+ // 두 번째 인자는 라우트 파라미터 + 쿼리(HTTP 메서드는 라우트 정의에서 결정).
13
+ // 라우트에 `:id` 처럼 자리표시자가 있으면 params 에서 뽑아 경로에 채우고,
14
+ // 남는 값은 GET 은 쿼리스트링으로, POST/PUT/PATCH/DELETE 는 JSON 본문으로 실어
15
+ // 보낸다. 서버 쪽 this.params 병합 우선순위(라우트>body>query)와 일치한다.
16
+ // ── 런타임 ────────────────────────────────────────────────────
17
+ /**
18
+ * 라우트 키로 서버 액션을 부른다.
19
+ * · 반환 타입: 액션 반환의 Serialized<> (직렬화 경계 재사용).
20
+ * · 요청: 메서드는 라우트 정의에서, 경로는 :param 을 채워 완성.
21
+ * · 실패: 4xx/5xx 는 예외로 던지고, ValidationError(422)는 issues 를 포함한다.
22
+ */
23
+ export async function api(key, params = {}, opts = {}) {
24
+ const routes = (globalThis.__GAON_ROUTES__) ??
25
+ {};
26
+ const info = routes[key];
27
+ if (!info) {
28
+ throw new Error(`[gaonjs/vue] 라우트 '${String(key)}' 를 찾을 수 없습니다.\n` +
29
+ `→ apps/<app>/routes.ts 에 정의돼 있는지, gaon dev/check 가 .gaon/routes.d.ts 를 재생성했는지 확인하세요.`);
30
+ }
31
+ const { url, leftovers } = buildUrl(info, params);
32
+ const method = info.method.toUpperCase();
33
+ const useBody = method !== 'GET' && method !== 'HEAD';
34
+ const headers = {
35
+ Accept: 'application/json',
36
+ ...(opts.headers ?? {}),
37
+ };
38
+ let body;
39
+ let finalUrl = url;
40
+ if (useBody) {
41
+ body = JSON.stringify(leftovers);
42
+ headers['Content-Type'] = 'application/json; charset=utf-8';
43
+ // CSRF: 세션 앱은 쿠키 기반 CSRF 를 쓴다 — 프론트 부트스트랩이 심어 놓은
44
+ // 메타 태그 값을 자동으로 붙여 준다(있으면).
45
+ const csrf = readCsrfToken();
46
+ if (csrf)
47
+ headers['X-CSRF-Token'] = csrf;
48
+ }
49
+ else if (Object.keys(leftovers).length > 0) {
50
+ const q = new URLSearchParams();
51
+ for (const [k, v] of Object.entries(leftovers)) {
52
+ if (v === undefined || v === null)
53
+ continue;
54
+ q.append(k, String(v));
55
+ }
56
+ finalUrl = url + (url.includes('?') ? '&' : '?') + q.toString();
57
+ }
58
+ const base = opts.baseUrl ?? '';
59
+ const doFetch = opts.fetch ?? globalThis.fetch;
60
+ const res = await doFetch(base + finalUrl, {
61
+ method,
62
+ headers,
63
+ body,
64
+ signal: opts.signal,
65
+ credentials: 'same-origin',
66
+ });
67
+ if (!res.ok) {
68
+ // 422 는 서버 검증 실패(§7.5.3). 파싱 시도 후 issues 를 실어 재던진다.
69
+ let payload = undefined;
70
+ try {
71
+ payload = await res.json();
72
+ }
73
+ catch {
74
+ /* 본문 없음 */
75
+ }
76
+ const err = new Error(`[gaonjs/vue] ${String(key)} 요청 실패 (${res.status} ${res.statusText}).`);
77
+ err.status = res.status;
78
+ err.body = payload;
79
+ throw err;
80
+ }
81
+ // 204 No Content 는 빈 값으로.
82
+ if (res.status === 204)
83
+ return undefined;
84
+ const json = (await res.json());
85
+ return json;
86
+ }
87
+ /**
88
+ * 라우트 매니페스트를 등록한다 — 프론트엔드 부트스트랩(app 엔트리)이 한 번
89
+ * 호출하면 이후 api() 가 method·path 를 안다. 앱 빌드 시 .gaon/routes.d.ts
90
+ * 와 함께 생성되는 매니페스트 모듈이 이 함수를 호출한다.
91
+ */
92
+ export function registerRoutes(routes) {
93
+ const g = globalThis;
94
+ g.__GAON_ROUTES__ = { ...(g.__GAON_ROUTES__ ?? {}), ...routes };
95
+ }
96
+ // ── 내부 ──────────────────────────────────────────────────────
97
+ function buildUrl(info, params) {
98
+ const leftovers = {};
99
+ let url = info.path;
100
+ // 사용된 자리표시자는 leftovers 에서 빼고, 남는 값은 쿼리/본문에 실린다.
101
+ const used = new Set();
102
+ url = url.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, (_m, name) => {
103
+ used.add(name);
104
+ const v = params[name];
105
+ if (v === undefined || v === null) {
106
+ throw new Error(`[gaonjs/vue] 라우트 파라미터 ':${name}' 값이 없습니다 (path=${info.path}).\n` +
107
+ `→ api('${info.method} ${info.path}', { ${name}: ... }) 처럼 넘기세요.`);
108
+ }
109
+ return encodeURIComponent(String(v));
110
+ });
111
+ for (const [k, v] of Object.entries(params)) {
112
+ if (used.has(k) || v === undefined || v === null)
113
+ continue;
114
+ leftovers[k] = String(v);
115
+ }
116
+ return { url, leftovers };
117
+ }
118
+ function readCsrfToken() {
119
+ const doc = globalThis.document;
120
+ if (!doc)
121
+ return undefined;
122
+ const el = doc.querySelector('meta[name="csrf-token"]');
123
+ if (!el)
124
+ return undefined;
125
+ const c = el.getAttribute('content');
126
+ return c ?? undefined;
127
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export declare const version: string;
2
2
  export declare const status: "planned";
3
3
  export { pageProps } from "./pageProps.js";
4
+ export { serializeProps, type Serialized } from "./serialize.js";
5
+ export { api, registerRoutes, type ApiParams, type ApiOptions, type GaonRouteInfo, } from "./api.js";
package/dist/index.js CHANGED
@@ -10,3 +10,7 @@ export const version = VERSION;
10
10
  export const status = "planned";
11
11
  // 타입 브리지 (M3) — GaonRouteMap[K] → 페이지 props 타입
12
12
  export { pageProps } from "./pageProps.js";
13
+ // 직렬화 경계 (M4) — Rec → JSON-safe 페이지 props (Date/bigint→string, hidden 제외)
14
+ export { serializeProps } from "./serialize.js";
15
+ // 타입드 클라이언트 (errata E-3) — 라우트 키 → Serialized 반환
16
+ export { api, registerRoutes, } from "./api.js";
@@ -1,4 +1,5 @@
1
1
  import type { Rendered } from '@gaonjs/web';
2
+ import type { Serialized } from './serialize.js';
2
3
  declare global {
3
4
  interface GaonRouteMap {
4
5
  }
@@ -7,7 +8,8 @@ type PropsOf<F> = F extends (...args: never[]) => infer R ? Awaited<R> extends R
7
8
  /**
8
9
  * 페이지의 props 타입을 라우트 이름으로 가져온다.
9
10
  * `const { posts } = pageProps<'posts#index'>()`
10
- * 런타임 값은 Inertia 가 주입한 실제 페이지 props 로 대체된다(M4 브리지).
11
+ * 런타임 값은 Inertia 가 주입한 실제 페이지 props 로 대체된다(M4 브리지
12
+ * serializeProps 를 통과한 JSON-safe 값).
11
13
  */
12
- export declare function pageProps<K extends keyof GaonRouteMap>(): PropsOf<GaonRouteMap[K]>;
14
+ export declare function pageProps<K extends keyof GaonRouteMap>(): Serialized<PropsOf<GaonRouteMap[K]>>;
13
15
  export {};
package/dist/pageProps.js CHANGED
@@ -8,12 +8,15 @@
8
8
  // 체인의 마지막 고리다. 스키마에서 컬럼을 지우면 .vue 템플릿의 사용
9
9
  // 지점에서 vue-tsc 가 에러를 낸다(완료 기준).
10
10
  //
11
- // M3 raw props(P)를 그대로 돌려준다. 깊은 직렬화(Rec→SerializedOf,
12
- // hidden 제외)는 M4 그때 반환 타입을 SerializedOf<P> 로 승격한다.
11
+ // 반환 타입은 깊은 직렬화 경계 Serialized<P> 승격된다(M4). 컨트롤러가
12
+ // 넘긴 raw props(Date·bigint·모델 메서드·hidden 포함)가 아니라, 페이지가
13
+ // 실제로 받는 JSON-safe 모양이다 — Date/bigint 는 문자열, hidden 컬럼은
14
+ // 존재하지 않으므로 접근 시 컴파일 에러가 난다(§4.2 완료 기준).
13
15
  /**
14
16
  * 페이지의 props 타입을 라우트 이름으로 가져온다.
15
17
  * `const { posts } = pageProps<'posts#index'>()`
16
- * 런타임 값은 Inertia 가 주입한 실제 페이지 props 로 대체된다(M4 브리지).
18
+ * 런타임 값은 Inertia 가 주입한 실제 페이지 props 로 대체된다(M4 브리지
19
+ * serializeProps 를 통과한 JSON-safe 값).
17
20
  */
18
21
  export function pageProps() {
19
22
  return undefined;
@@ -0,0 +1,17 @@
1
+ import type { IsHidden } from '@gaonjs/core';
2
+ /**
3
+ * 서버 타입 `T`(Date·bigint·메서드·hidden 포함) → 페이지가 받는 JSON-safe 타입.
4
+ *
5
+ * 규칙: Date→string, bigint→string, 함수→제외, 배열·객체는 재귀.
6
+ * 객체에서 hidden 브랜드가 붙은 키와 함수 값 키는 결과에서 빠진다.
7
+ */
8
+ export type Serialized<T> = T extends Date ? string : T extends bigint ? string : T extends (...args: never[]) => unknown ? never : T extends ReadonlyArray<infer U> ? Array<Serialized<U>> : T extends object ? SerializedObject<T> : T;
9
+ type SerializedObject<T> = {
10
+ [K in keyof T as IsHidden<T[K]> extends true ? never : T[K] extends (...args: never[]) => unknown ? never : K]: Serialized<T[K]>;
11
+ };
12
+ /**
13
+ * 컨트롤러 props 를 JSON-safe 페이지 props 로 변환한다(Inertia 브리지).
14
+ * Date→ISO 문자열, bigint→문자열, 함수 제거, hidden 컬럼 제외, 중첩 재귀.
15
+ */
16
+ export declare function serializeProps<T>(value: T): Serialized<T>;
17
+ export {};
@@ -0,0 +1,51 @@
1
+ // @gaonjs/vue · 직렬화 경계 (M4 — Rec → Serialized 깊은 매핑 + 런타임)
2
+ //
3
+ // 서버의 레코드는 그대로 JSON 이 아니다(§4.4). 모델 메서드·관계 함수가
4
+ // 붙어 있고, Date·bigint 는 JSON 위에서 문자열이 되며(bigint 는 애초에
5
+ // JSON.stringify 가 던진다), hidden 컬럼은 노출되면 안 된다(§4.2).
6
+ //
7
+ // 이 경계에서 두 가지가 맞물린다:
8
+ // · 타입: Serialized<P> 가 페이지가 실제로 받는 JSON-safe 모양을 계산한다
9
+ // (Date/bigint → string, 함수·hidden 제외, 중첩·배열 재귀). pageProps<K>()
10
+ // 의 반환 타입을 이 매핑으로 승격한다.
11
+ // · 런타임: serializeProps() 가 컨트롤러 props 를 같은 규칙으로 변환한다.
12
+ // Inertia 브리지가 페이지에 넘기기 직전 이 함수를 통과시킨다.
13
+ //
14
+ // hidden 지식의 출처는 @gaonjs/data 다(RowOf 브랜딩 + 레코드의 HIDDEN_COLUMNS
15
+ // 마커). 여기서는 그 브랜드/마커를 감지해 떨구기만 한다 — 책임 분리(§13.5 M4).
16
+ import { HIDDEN_COLUMNS } from '@gaonjs/core';
17
+ // ── 런타임 ────────────────────────────────────────────────────
18
+ /**
19
+ * 컨트롤러 props 를 JSON-safe 페이지 props 로 변환한다(Inertia 브리지).
20
+ * Date→ISO 문자열, bigint→문자열, 함수 제거, hidden 컬럼 제외, 중첩 재귀.
21
+ */
22
+ export function serializeProps(value) {
23
+ return serialize(value);
24
+ }
25
+ function serialize(value) {
26
+ if (value === null || value === undefined)
27
+ return value;
28
+ if (typeof value === 'bigint')
29
+ return value.toString();
30
+ if (value instanceof Date)
31
+ return value.toISOString();
32
+ if (Array.isArray(value))
33
+ return value.map(serialize);
34
+ if (typeof value === 'object') {
35
+ // 모델 레코드는 HIDDEN_COLUMNS 마커(비열거)로 제외할 컬럼명을 싣는다.
36
+ const hidden = value[HIDDEN_COLUMNS];
37
+ const out = {};
38
+ for (const key of Object.keys(value)) {
39
+ if (hidden && hidden.includes(key))
40
+ continue;
41
+ const v = value[key];
42
+ if (typeof v === 'function')
43
+ continue; // 메서드·관계 함수는 직렬화하지 않는다
44
+ out[key] = serialize(v);
45
+ }
46
+ return out;
47
+ }
48
+ if (typeof value === 'function')
49
+ return undefined;
50
+ return value;
51
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/vue",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "Gaon 프론트엔드 어댑터: Vue 3 · Inertia 브리지 · 타입 전파 · SSR (구현 예정)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,8 +24,8 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@gaonjs/core": "0.1.2",
28
- "@gaonjs/web": "0.1.3"
27
+ "@gaonjs/core": "0.1.4",
28
+ "@gaonjs/web": "0.3.0"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"