@gencow/core 0.1.2 → 0.1.4
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/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/reactive.d.ts +101 -0
- package/dist/reactive.js +42 -0
- package/dist/storage.js +18 -0
- package/package.json +34 -35
- package/src/index.ts +2 -2
- package/src/reactive.ts +124 -0
- package/src/storage.ts +25 -0
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
* Provides: query, mutation, storage, scheduler, auth
|
|
5
5
|
* All with Convex-compatible DX patterns.
|
|
6
6
|
*/
|
|
7
|
-
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx } from "./reactive";
|
|
8
|
-
export { query, mutation, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations } from "./reactive";
|
|
7
|
+
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
|
|
8
|
+
export { query, mutation, httpAction, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
9
9
|
export type { Storage } from "./storage";
|
|
10
10
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
11
11
|
export type { Scheduler } from "./scheduler";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Provides: query, mutation, storage, scheduler, auth
|
|
5
5
|
* All with Convex-compatible DX patterns.
|
|
6
6
|
*/
|
|
7
|
-
export { query, mutation, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations } from "./reactive";
|
|
7
|
+
export { query, mutation, httpAction, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
8
8
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
9
9
|
export { v, parseArgs, GencowValidationError } from "./v";
|
|
10
10
|
export { withRetry } from "./retry";
|
package/dist/reactive.d.ts
CHANGED
|
@@ -33,6 +33,29 @@ export interface RealtimeCtx {
|
|
|
33
33
|
* Convex의 ctx 패턴과 동일하게, 이 객체를 통해서만 DB/Storage/Auth에 접근 가능.
|
|
34
34
|
* fs, child_process 등 원시 Node.js API는 노출되지 않음.
|
|
35
35
|
*/
|
|
36
|
+
export interface AIMessage {
|
|
37
|
+
role: "user" | "system" | "assistant";
|
|
38
|
+
content: string;
|
|
39
|
+
}
|
|
40
|
+
export interface AIResult {
|
|
41
|
+
text: string;
|
|
42
|
+
usage: {
|
|
43
|
+
promptTokens: number;
|
|
44
|
+
completionTokens: number;
|
|
45
|
+
totalTokens: number;
|
|
46
|
+
};
|
|
47
|
+
creditsCharged: number;
|
|
48
|
+
model: string;
|
|
49
|
+
}
|
|
50
|
+
export interface AIContext {
|
|
51
|
+
/** AI 텍스트 생성 — ctx.ai.chat({ model, messages }) */
|
|
52
|
+
chat: (opts: {
|
|
53
|
+
model?: string;
|
|
54
|
+
messages: AIMessage[];
|
|
55
|
+
temperature?: number;
|
|
56
|
+
maxTokens?: number;
|
|
57
|
+
}) => Promise<AIResult>;
|
|
58
|
+
}
|
|
36
59
|
export interface GencowCtx {
|
|
37
60
|
/** Drizzle DB 인스턴스 — ctx.db.select().from(table) */
|
|
38
61
|
db: any;
|
|
@@ -46,9 +69,43 @@ export interface GencowCtx {
|
|
|
46
69
|
realtime: RealtimeCtx;
|
|
47
70
|
/** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
|
|
48
71
|
retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
|
|
72
|
+
/** AI 헬퍼 — ctx.ai.chat({ model, messages }) — 로컬: 직접 호출, BaaS: Control Plane 프록시 */
|
|
73
|
+
ai?: AIContext;
|
|
49
74
|
}
|
|
50
75
|
type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
|
|
51
76
|
type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
|
|
77
|
+
/**
|
|
78
|
+
* httpAction 핸들러의 Request/Response 타입.
|
|
79
|
+
* Hono의 Context를 직접 받아 full HTTP 제어 가능.
|
|
80
|
+
*/
|
|
81
|
+
export type HttpActionHandler = (ctx: GencowCtx, req: HttpActionRequest) => Promise<HttpActionResponse>;
|
|
82
|
+
export interface HttpActionRequest {
|
|
83
|
+
/** HTTP method (GET, POST, PUT, DELETE, PATCH) */
|
|
84
|
+
method: string;
|
|
85
|
+
/** Full URL */
|
|
86
|
+
url: string;
|
|
87
|
+
/** URL path (e.g. /api/cli/auth-start) */
|
|
88
|
+
path: string;
|
|
89
|
+
/** Route params (e.g. { id: "abc" } for /api/apps/:id) */
|
|
90
|
+
params: Record<string, string>;
|
|
91
|
+
/** Query string params */
|
|
92
|
+
query: Record<string, string>;
|
|
93
|
+
/** Request headers */
|
|
94
|
+
headers: Record<string, string>;
|
|
95
|
+
/** Parse JSON body */
|
|
96
|
+
json: <T = unknown>() => Promise<T>;
|
|
97
|
+
/** Parse form data */
|
|
98
|
+
formData: () => Promise<FormData>;
|
|
99
|
+
/** Raw body as ArrayBuffer */
|
|
100
|
+
arrayBuffer: () => Promise<ArrayBuffer>;
|
|
101
|
+
/** Raw body as text */
|
|
102
|
+
text: () => Promise<string>;
|
|
103
|
+
}
|
|
104
|
+
export interface HttpActionResponse {
|
|
105
|
+
status?: number;
|
|
106
|
+
headers?: Record<string, string>;
|
|
107
|
+
body?: unknown;
|
|
108
|
+
}
|
|
52
109
|
export interface QueryDef<TSchema = any, TReturn = any> {
|
|
53
110
|
key: string;
|
|
54
111
|
handler: QueryHandler<InferArgs<TSchema>, TReturn>;
|
|
@@ -67,6 +124,17 @@ export interface MutationDef<TSchema = any, TReturn = any> {
|
|
|
67
124
|
_args?: InferArgs<TSchema>;
|
|
68
125
|
_return?: TReturn;
|
|
69
126
|
}
|
|
127
|
+
/** httpAction 정의. 커스텀 HTTP 엔드포인트를 선언적으로 등록. */
|
|
128
|
+
export interface HttpActionDef {
|
|
129
|
+
/** HTTP method */
|
|
130
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
131
|
+
/** URL path (Hono 패턴 — /api/cli/:code 형태 지원) */
|
|
132
|
+
path: string;
|
|
133
|
+
/** true = 인증 없이 접근 가능, false(기본) = auth 필수 */
|
|
134
|
+
isPublic: boolean;
|
|
135
|
+
/** 핸들러 */
|
|
136
|
+
handler: HttpActionHandler;
|
|
137
|
+
}
|
|
70
138
|
declare global {
|
|
71
139
|
var __gencow_queryRegistry: Map<string, QueryDef<any, any>>;
|
|
72
140
|
var __gencow_mutationRegistry: (MutationDef<any, any> & {
|
|
@@ -74,6 +142,7 @@ declare global {
|
|
|
74
142
|
})[];
|
|
75
143
|
var __gencow_subscribers: Map<string, Set<WSContext>>;
|
|
76
144
|
var __gencow_connectedClients: Set<WSContext>;
|
|
145
|
+
var __gencow_httpActionRegistry: HttpActionDef[];
|
|
77
146
|
}
|
|
78
147
|
export declare function query<TSchema = any, TReturn = any>(key: string, handlerOrDef: QueryHandler<InferArgs<TSchema>, TReturn> | {
|
|
79
148
|
args?: TSchema;
|
|
@@ -87,6 +156,38 @@ export declare function mutation<TSchema = any, TReturn = any>(invalidatesOrDef:
|
|
|
87
156
|
invalidates: string[];
|
|
88
157
|
handler: MutationHandler<InferArgs<TSchema>, TReturn>;
|
|
89
158
|
}, handler?: MutationHandler<InferArgs<TSchema>, TReturn>, name?: string): MutationDef<TSchema, TReturn>;
|
|
159
|
+
/**
|
|
160
|
+
* 커스텀 HTTP 엔드포인트를 선언적으로 등록합니다.
|
|
161
|
+
* query/mutation은 RPC 패턴이지만, httpAction은 RESTful HTTP 라우트를 직접 정의합니다.
|
|
162
|
+
*
|
|
163
|
+
* 사용 사례:
|
|
164
|
+
* - CLI Device Auth (POST /api/cli/auth-start)
|
|
165
|
+
* - 파일 업로드 (POST /api/apps/:id/deploy)
|
|
166
|
+
* - Webhook 수신
|
|
167
|
+
* - 외부 서비스 콜백
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```typescript
|
|
171
|
+
* import { httpAction } from "@gencow/core";
|
|
172
|
+
*
|
|
173
|
+
* export const authStart = httpAction({
|
|
174
|
+
* method: "POST",
|
|
175
|
+
* path: "/api/cli/auth-start",
|
|
176
|
+
* public: true,
|
|
177
|
+
* handler: async (ctx, req) => {
|
|
178
|
+
* const code = crypto.randomUUID().slice(0, 8);
|
|
179
|
+
* return { body: { code, url: `https://gencow.com/cli-auth?code=${code}` } };
|
|
180
|
+
* },
|
|
181
|
+
* });
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
export declare function httpAction(def: {
|
|
185
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
186
|
+
path: string;
|
|
187
|
+
public?: boolean;
|
|
188
|
+
handler: HttpActionHandler;
|
|
189
|
+
}): HttpActionDef;
|
|
190
|
+
export declare function getRegisteredHttpActions(): HttpActionDef[];
|
|
90
191
|
export declare function subscribe(queryKey: string, ws: WSContext): void;
|
|
91
192
|
export declare function unsubscribe(ws: WSContext): void;
|
|
92
193
|
/** Register a raw WS connection without any query subscription (e.g. admin dashboard) */
|
package/dist/reactive.js
CHANGED
|
@@ -6,8 +6,11 @@ if (!globalThis.__gencow_subscribers)
|
|
|
6
6
|
globalThis.__gencow_subscribers = new Map();
|
|
7
7
|
if (!globalThis.__gencow_connectedClients)
|
|
8
8
|
globalThis.__gencow_connectedClients = new Set();
|
|
9
|
+
if (!globalThis.__gencow_httpActionRegistry)
|
|
10
|
+
globalThis.__gencow_httpActionRegistry = [];
|
|
9
11
|
const queryRegistry = globalThis.__gencow_queryRegistry;
|
|
10
12
|
const mutationRegistry = globalThis.__gencow_mutationRegistry;
|
|
13
|
+
const httpActionRegistry = globalThis.__gencow_httpActionRegistry;
|
|
11
14
|
const subscribers = globalThis.__gencow_subscribers;
|
|
12
15
|
/**
|
|
13
16
|
* Every WebSocket client that ever establishes a connection is tracked here.
|
|
@@ -63,6 +66,45 @@ export function mutation(invalidatesOrDef, handler, name) {
|
|
|
63
66
|
mutationRegistry.push(def);
|
|
64
67
|
return def;
|
|
65
68
|
}
|
|
69
|
+
// ─── httpAction (커스텀 HTTP 엔드포인트) ────────────────
|
|
70
|
+
/**
|
|
71
|
+
* 커스텀 HTTP 엔드포인트를 선언적으로 등록합니다.
|
|
72
|
+
* query/mutation은 RPC 패턴이지만, httpAction은 RESTful HTTP 라우트를 직접 정의합니다.
|
|
73
|
+
*
|
|
74
|
+
* 사용 사례:
|
|
75
|
+
* - CLI Device Auth (POST /api/cli/auth-start)
|
|
76
|
+
* - 파일 업로드 (POST /api/apps/:id/deploy)
|
|
77
|
+
* - Webhook 수신
|
|
78
|
+
* - 외부 서비스 콜백
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* import { httpAction } from "@gencow/core";
|
|
83
|
+
*
|
|
84
|
+
* export const authStart = httpAction({
|
|
85
|
+
* method: "POST",
|
|
86
|
+
* path: "/api/cli/auth-start",
|
|
87
|
+
* public: true,
|
|
88
|
+
* handler: async (ctx, req) => {
|
|
89
|
+
* const code = crypto.randomUUID().slice(0, 8);
|
|
90
|
+
* return { body: { code, url: `https://gencow.com/cli-auth?code=${code}` } };
|
|
91
|
+
* },
|
|
92
|
+
* });
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
export function httpAction(def) {
|
|
96
|
+
const actionDef = {
|
|
97
|
+
method: def.method,
|
|
98
|
+
path: def.path,
|
|
99
|
+
isPublic: def.public === true,
|
|
100
|
+
handler: def.handler,
|
|
101
|
+
};
|
|
102
|
+
httpActionRegistry.push(actionDef);
|
|
103
|
+
return actionDef;
|
|
104
|
+
}
|
|
105
|
+
export function getRegisteredHttpActions() {
|
|
106
|
+
return [...httpActionRegistry];
|
|
107
|
+
}
|
|
66
108
|
// ─── WebSocket subscription management ──────────────────
|
|
67
109
|
export function subscribe(queryKey, ws) {
|
|
68
110
|
connectedClients.add(ws);
|
package/dist/storage.js
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
import * as fs from "fs/promises";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as crypto from "crypto";
|
|
4
|
+
// ─── Constants ──────────────────────────────────────────
|
|
5
|
+
/** 파일 업로드 최대 크기: 50MB (하드코딩 — 사용자가 오버라이드 불가) */
|
|
6
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
7
|
+
function formatBytes(bytes) {
|
|
8
|
+
if (bytes < 1024)
|
|
9
|
+
return `${bytes}B`;
|
|
10
|
+
if (bytes < 1024 * 1024)
|
|
11
|
+
return `${(bytes / 1024).toFixed(1)}KB`;
|
|
12
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
13
|
+
}
|
|
4
14
|
// ─── Implementation ─────────────────────────────────────
|
|
5
15
|
const metaStore = new Map();
|
|
6
16
|
/**
|
|
@@ -16,6 +26,10 @@ export function createStorage(dir = "./uploads") {
|
|
|
16
26
|
fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
17
27
|
return {
|
|
18
28
|
async store(file, filename) {
|
|
29
|
+
// 크기 제한 검증
|
|
30
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
31
|
+
throw new Error(`File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`);
|
|
32
|
+
}
|
|
19
33
|
const id = crypto.randomUUID();
|
|
20
34
|
const name = filename || (file instanceof File ? file.name : `${id}.bin`);
|
|
21
35
|
const filePath = path.join(dir, id);
|
|
@@ -33,6 +47,10 @@ export function createStorage(dir = "./uploads") {
|
|
|
33
47
|
async storeBuffer(buffer, filename, type = "application/octet-stream") {
|
|
34
48
|
const id = crypto.randomUUID();
|
|
35
49
|
const filePath = path.join(dir, id);
|
|
50
|
+
// 크기 제한 검증
|
|
51
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
52
|
+
throw new Error(`File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`);
|
|
53
|
+
}
|
|
36
54
|
await fs.writeFile(filePath, buffer);
|
|
37
55
|
metaStore.set(id, {
|
|
38
56
|
id,
|
package/package.json
CHANGED
|
@@ -1,39 +1,38 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
},
|
|
13
|
-
"./server": {
|
|
14
|
-
"import": "./dist/server.js",
|
|
15
|
-
"types": "./dist/server.d.ts"
|
|
16
|
-
}
|
|
2
|
+
"name": "@gencow/core",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Gencow core library — defineQuery, defineMutation, reactive subscriptions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
17
12
|
},
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
],
|
|
22
|
-
"scripts": {
|
|
23
|
-
"build": "tsc",
|
|
24
|
-
"typecheck": "tsc --noEmit",
|
|
25
|
-
"prepublishOnly": "npm run build"
|
|
26
|
-
},
|
|
27
|
-
"dependencies": {
|
|
28
|
-
"@electric-sql/pglite": "^0.3.15",
|
|
29
|
-
"drizzle-orm": "^0.45.1",
|
|
30
|
-
"hono": "^4.12.0",
|
|
31
|
-
"node-cron": "^4.2.1"
|
|
32
|
-
},
|
|
33
|
-
"devDependencies": {
|
|
34
|
-
"@types/bun": "^1.3.9",
|
|
35
|
-
"@types/node": "^25.3.0",
|
|
36
|
-
"@types/node-cron": "^3.0.11",
|
|
37
|
-
"typescript": "^5.9.3"
|
|
13
|
+
"./server": {
|
|
14
|
+
"import": "./dist/server.js",
|
|
15
|
+
"types": "./dist/server.d.ts"
|
|
38
16
|
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist/",
|
|
20
|
+
"src/"
|
|
21
|
+
],
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@electric-sql/pglite": "^0.3.15",
|
|
24
|
+
"drizzle-orm": "^0.45.1",
|
|
25
|
+
"hono": "^4.12.0",
|
|
26
|
+
"node-cron": "^4.2.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/bun": "^1.3.9",
|
|
30
|
+
"@types/node": "^25.3.0",
|
|
31
|
+
"@types/node-cron": "^3.0.11",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"typecheck": "tsc --noEmit"
|
|
37
|
+
}
|
|
39
38
|
}
|
package/src/index.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* All with Convex-compatible DX patterns.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx } from "./reactive";
|
|
9
|
-
export { query, mutation, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations } from "./reactive";
|
|
8
|
+
export type { GencowCtx, AuthCtx, UserIdentity, QueryDef, MutationDef, RealtimeCtx, HttpActionDef, HttpActionRequest, HttpActionResponse, HttpActionHandler, AIContext, AIMessage, AIResult } from "./reactive";
|
|
9
|
+
export { query, mutation, httpAction, invalidateQueries, buildRealtimeCtx, subscribe, unsubscribe, registerClient, deregisterClient, handleWsMessage, getQueryHandler, getQueryDef, getRegisteredQueries, getRegisteredMutations, getRegisteredHttpActions } from "./reactive";
|
|
10
10
|
export type { Storage } from "./storage";
|
|
11
11
|
export { createScheduler, getSchedulerInfo } from "./scheduler";
|
|
12
12
|
export type { Scheduler } from "./scheduler";
|
package/src/reactive.ts
CHANGED
|
@@ -39,6 +39,30 @@ export interface RealtimeCtx {
|
|
|
39
39
|
* Convex의 ctx 패턴과 동일하게, 이 객체를 통해서만 DB/Storage/Auth에 접근 가능.
|
|
40
40
|
* fs, child_process 등 원시 Node.js API는 노출되지 않음.
|
|
41
41
|
*/
|
|
42
|
+
// ─── AI Context ─────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
export interface AIMessage {
|
|
45
|
+
role: "user" | "system" | "assistant";
|
|
46
|
+
content: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AIResult {
|
|
50
|
+
text: string;
|
|
51
|
+
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
|
52
|
+
creditsCharged: number;
|
|
53
|
+
model: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AIContext {
|
|
57
|
+
/** AI 텍스트 생성 — ctx.ai.chat({ model, messages }) */
|
|
58
|
+
chat: (opts: {
|
|
59
|
+
model?: string;
|
|
60
|
+
messages: AIMessage[];
|
|
61
|
+
temperature?: number;
|
|
62
|
+
maxTokens?: number;
|
|
63
|
+
}) => Promise<AIResult>;
|
|
64
|
+
}
|
|
65
|
+
|
|
42
66
|
export interface GencowCtx {
|
|
43
67
|
/** Drizzle DB 인스턴스 — ctx.db.select().from(table) */
|
|
44
68
|
db: any; // typed per-app via generic
|
|
@@ -52,6 +76,8 @@ export interface GencowCtx {
|
|
|
52
76
|
realtime: RealtimeCtx;
|
|
53
77
|
/** 재시도 — ctx.retry(fn, opts) — exponential backoff + jitter */
|
|
54
78
|
retry: <T>(fn: () => Promise<T>, options?: import("./retry").RetryOptions) => Promise<T>;
|
|
79
|
+
/** AI 헬퍼 — ctx.ai.chat({ model, messages }) — 로컬: 직접 호출, BaaS: Control Plane 프록시 */
|
|
80
|
+
ai?: AIContext;
|
|
55
81
|
}
|
|
56
82
|
|
|
57
83
|
// ─── Types ──────────────────────────────────────────────
|
|
@@ -61,6 +87,41 @@ type QueryHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) =>
|
|
|
61
87
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
62
88
|
type MutationHandler<TArgs = any, TReturn = any> = (ctx: GencowCtx, args: TArgs) => Promise<TReturn>;
|
|
63
89
|
|
|
90
|
+
/**
|
|
91
|
+
* httpAction 핸들러의 Request/Response 타입.
|
|
92
|
+
* Hono의 Context를 직접 받아 full HTTP 제어 가능.
|
|
93
|
+
*/
|
|
94
|
+
export type HttpActionHandler = (ctx: GencowCtx, req: HttpActionRequest) => Promise<HttpActionResponse>;
|
|
95
|
+
|
|
96
|
+
export interface HttpActionRequest {
|
|
97
|
+
/** HTTP method (GET, POST, PUT, DELETE, PATCH) */
|
|
98
|
+
method: string;
|
|
99
|
+
/** Full URL */
|
|
100
|
+
url: string;
|
|
101
|
+
/** URL path (e.g. /api/cli/auth-start) */
|
|
102
|
+
path: string;
|
|
103
|
+
/** Route params (e.g. { id: "abc" } for /api/apps/:id) */
|
|
104
|
+
params: Record<string, string>;
|
|
105
|
+
/** Query string params */
|
|
106
|
+
query: Record<string, string>;
|
|
107
|
+
/** Request headers */
|
|
108
|
+
headers: Record<string, string>;
|
|
109
|
+
/** Parse JSON body */
|
|
110
|
+
json: <T = unknown>() => Promise<T>;
|
|
111
|
+
/** Parse form data */
|
|
112
|
+
formData: () => Promise<FormData>;
|
|
113
|
+
/** Raw body as ArrayBuffer */
|
|
114
|
+
arrayBuffer: () => Promise<ArrayBuffer>;
|
|
115
|
+
/** Raw body as text */
|
|
116
|
+
text: () => Promise<string>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface HttpActionResponse {
|
|
120
|
+
status?: number;
|
|
121
|
+
headers?: Record<string, string>;
|
|
122
|
+
body?: unknown;
|
|
123
|
+
}
|
|
124
|
+
|
|
64
125
|
export interface QueryDef<TSchema = any, TReturn = any> {
|
|
65
126
|
key: string;
|
|
66
127
|
handler: QueryHandler<InferArgs<TSchema>, TReturn>;
|
|
@@ -81,6 +142,18 @@ export interface MutationDef<TSchema = any, TReturn = any> {
|
|
|
81
142
|
_return?: TReturn;
|
|
82
143
|
}
|
|
83
144
|
|
|
145
|
+
/** httpAction 정의. 커스텀 HTTP 엔드포인트를 선언적으로 등록. */
|
|
146
|
+
export interface HttpActionDef {
|
|
147
|
+
/** HTTP method */
|
|
148
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
149
|
+
/** URL path (Hono 패턴 — /api/cli/:code 형태 지원) */
|
|
150
|
+
path: string;
|
|
151
|
+
/** true = 인증 없이 접근 가능, false(기본) = auth 필수 */
|
|
152
|
+
isPublic: boolean;
|
|
153
|
+
/** 핸들러 */
|
|
154
|
+
handler: HttpActionHandler;
|
|
155
|
+
}
|
|
156
|
+
|
|
84
157
|
// ─── Registry ───────────────────────────────────────────
|
|
85
158
|
//
|
|
86
159
|
// globalThis 기반 — 서버 번들(인라인)과 node_modules/@gencow/core 양쪽에서
|
|
@@ -96,15 +169,19 @@ declare global {
|
|
|
96
169
|
var __gencow_subscribers: Map<string, Set<WSContext>>;
|
|
97
170
|
// eslint-disable-next-line no-var
|
|
98
171
|
var __gencow_connectedClients: Set<WSContext>;
|
|
172
|
+
// eslint-disable-next-line no-var
|
|
173
|
+
var __gencow_httpActionRegistry: HttpActionDef[];
|
|
99
174
|
}
|
|
100
175
|
|
|
101
176
|
if (!globalThis.__gencow_queryRegistry) globalThis.__gencow_queryRegistry = new Map();
|
|
102
177
|
if (!globalThis.__gencow_mutationRegistry) globalThis.__gencow_mutationRegistry = [];
|
|
103
178
|
if (!globalThis.__gencow_subscribers) globalThis.__gencow_subscribers = new Map();
|
|
104
179
|
if (!globalThis.__gencow_connectedClients) globalThis.__gencow_connectedClients = new Set();
|
|
180
|
+
if (!globalThis.__gencow_httpActionRegistry) globalThis.__gencow_httpActionRegistry = [];
|
|
105
181
|
|
|
106
182
|
const queryRegistry = globalThis.__gencow_queryRegistry;
|
|
107
183
|
const mutationRegistry = globalThis.__gencow_mutationRegistry;
|
|
184
|
+
const httpActionRegistry = globalThis.__gencow_httpActionRegistry;
|
|
108
185
|
const subscribers = globalThis.__gencow_subscribers;
|
|
109
186
|
|
|
110
187
|
/**
|
|
@@ -174,6 +251,53 @@ export function mutation<TSchema = any, TReturn = any>(
|
|
|
174
251
|
return def;
|
|
175
252
|
}
|
|
176
253
|
|
|
254
|
+
// ─── httpAction (커스텀 HTTP 엔드포인트) ────────────────
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 커스텀 HTTP 엔드포인트를 선언적으로 등록합니다.
|
|
258
|
+
* query/mutation은 RPC 패턴이지만, httpAction은 RESTful HTTP 라우트를 직접 정의합니다.
|
|
259
|
+
*
|
|
260
|
+
* 사용 사례:
|
|
261
|
+
* - CLI Device Auth (POST /api/cli/auth-start)
|
|
262
|
+
* - 파일 업로드 (POST /api/apps/:id/deploy)
|
|
263
|
+
* - Webhook 수신
|
|
264
|
+
* - 외부 서비스 콜백
|
|
265
|
+
*
|
|
266
|
+
* @example
|
|
267
|
+
* ```typescript
|
|
268
|
+
* import { httpAction } from "@gencow/core";
|
|
269
|
+
*
|
|
270
|
+
* export const authStart = httpAction({
|
|
271
|
+
* method: "POST",
|
|
272
|
+
* path: "/api/cli/auth-start",
|
|
273
|
+
* public: true,
|
|
274
|
+
* handler: async (ctx, req) => {
|
|
275
|
+
* const code = crypto.randomUUID().slice(0, 8);
|
|
276
|
+
* return { body: { code, url: `https://gencow.com/cli-auth?code=${code}` } };
|
|
277
|
+
* },
|
|
278
|
+
* });
|
|
279
|
+
* ```
|
|
280
|
+
*/
|
|
281
|
+
export function httpAction(def: {
|
|
282
|
+
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
|
283
|
+
path: string;
|
|
284
|
+
public?: boolean;
|
|
285
|
+
handler: HttpActionHandler;
|
|
286
|
+
}): HttpActionDef {
|
|
287
|
+
const actionDef: HttpActionDef = {
|
|
288
|
+
method: def.method,
|
|
289
|
+
path: def.path,
|
|
290
|
+
isPublic: def.public === true,
|
|
291
|
+
handler: def.handler,
|
|
292
|
+
};
|
|
293
|
+
httpActionRegistry.push(actionDef);
|
|
294
|
+
return actionDef;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function getRegisteredHttpActions(): HttpActionDef[] {
|
|
298
|
+
return [...httpActionRegistry];
|
|
299
|
+
}
|
|
300
|
+
|
|
177
301
|
// ─── WebSocket subscription management ──────────────────
|
|
178
302
|
|
|
179
303
|
export function subscribe(queryKey: string, ws: WSContext) {
|
package/src/storage.ts
CHANGED
|
@@ -2,6 +2,17 @@ import * as fs from "fs/promises";
|
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import * as crypto from "crypto";
|
|
4
4
|
|
|
5
|
+
// ─── Constants ──────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
/** 파일 업로드 최대 크기: 50MB (하드코딩 — 사용자가 오버라이드 불가) */
|
|
8
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
function formatBytes(bytes: number): string {
|
|
11
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
12
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
13
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
// ─── Types ──────────────────────────────────────────────
|
|
6
17
|
|
|
7
18
|
interface StorageFile {
|
|
@@ -43,6 +54,13 @@ export function createStorage(dir: string = "./uploads"): Storage {
|
|
|
43
54
|
|
|
44
55
|
return {
|
|
45
56
|
async store(file: File | Blob, filename?: string): Promise<string> {
|
|
57
|
+
// 크기 제한 검증
|
|
58
|
+
if (file.size > MAX_FILE_SIZE) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`File too large: ${formatBytes(file.size)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
46
64
|
const id = crypto.randomUUID();
|
|
47
65
|
const name = filename || (file instanceof File ? file.name : `${id}.bin`);
|
|
48
66
|
const filePath = path.join(dir, id);
|
|
@@ -69,6 +87,13 @@ export function createStorage(dir: string = "./uploads"): Storage {
|
|
|
69
87
|
const id = crypto.randomUUID();
|
|
70
88
|
const filePath = path.join(dir, id);
|
|
71
89
|
|
|
90
|
+
// 크기 제한 검증
|
|
91
|
+
if (buffer.length > MAX_FILE_SIZE) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`File too large: ${formatBytes(buffer.length)} exceeds limit of ${formatBytes(MAX_FILE_SIZE)}`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
72
97
|
await fs.writeFile(filePath, buffer);
|
|
73
98
|
|
|
74
99
|
metaStore.set(id, {
|