@underdogai/mesh-app-sdk 0.1.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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/action.d.ts +55 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +33 -0
- package/dist/action.js.map +1 -0
- package/dist/app.d.ts +126 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +214 -0
- package/dist/app.js.map +1 -0
- package/dist/builders.d.ts +77 -0
- package/dist/builders.d.ts.map +1 -0
- package/dist/builders.js +72 -0
- package/dist/builders.js.map +1 -0
- package/dist/host.d.ts +38 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +58 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime.d.ts +232 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +246 -0
- package/dist/runtime.js.map +1 -0
- package/dist/serve.d.ts +119 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +415 -0
- package/dist/serve.js.map +1 -0
- package/package.json +53 -0
- package/src/action.ts +74 -0
- package/src/app.ts +350 -0
- package/src/builders.ts +87 -0
- package/src/host.ts +87 -0
- package/src/index.ts +15 -0
- package/src/runtime.ts +462 -0
- package/src/serve.ts +520 -0
package/src/app.ts
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* defineApp — 한 "앱"(회사 ERP 등)을 한 폴더에서 선언한다.
|
|
3
|
+
*
|
|
4
|
+
* App 은 Plugin 의 상위집합이라, enabledPlugins(@chatops/app-plugins)에 한 줄 올리면 클라/AS/브리지가
|
|
5
|
+
* 같은 스키마를 등록한다(SEC-2 로드 경로 재사용 = "Matrix App Service 만 추가"의 봉합선).
|
|
6
|
+
*
|
|
7
|
+
* 한 앱이 묶는 삼각형:
|
|
8
|
+
* - events: 커스텀 이벤트 → 카드(데이터) 결합("어떤 UI가 어떤 이벤트에"). messageForEvent 로 AS가 전송.
|
|
9
|
+
* - actions/handlers: 카드 버튼 ↔ 서버 핸들러(동작). dispatchAction(runtime)이 게이트 후 실행.
|
|
10
|
+
* - on: 구독 선언("남이 낸 이벤트가 오면 반응"). dispatchEvent(runtime)가 라우팅(이벤트 드리븐 통로).
|
|
11
|
+
* - sidebar/header: 우측 셸 서피스(데이터). surfaceState 로 AS가 상태 이벤트(org.corp.app.surface)로 전송.
|
|
12
|
+
* + theme: 회사별 토큰 오버라이드(데이터). 전부 "기존 어휘 조합 = 데이터" → 클라 무변경.
|
|
13
|
+
*/
|
|
14
|
+
import type { z } from "zod";
|
|
15
|
+
import {
|
|
16
|
+
EventType,
|
|
17
|
+
NS,
|
|
18
|
+
buildAppSurface,
|
|
19
|
+
buildUiMessageContent,
|
|
20
|
+
registerEventEntry,
|
|
21
|
+
type AgentDockSpec,
|
|
22
|
+
type AppSurfaceContent,
|
|
23
|
+
type CanvasSpec,
|
|
24
|
+
type CommandPaletteSpec,
|
|
25
|
+
type ComposerSpec,
|
|
26
|
+
type HeaderSpec,
|
|
27
|
+
type ModalSpec,
|
|
28
|
+
type ObjectMapSpec,
|
|
29
|
+
type PanelSpec,
|
|
30
|
+
type Plugin,
|
|
31
|
+
type PluginCapabilities,
|
|
32
|
+
type Publisher,
|
|
33
|
+
type UiCardContent,
|
|
34
|
+
} from "@underdogai/mesh-event-schemas";
|
|
35
|
+
import type { AppAction } from "./action.js";
|
|
36
|
+
import type { ActionHandler, EventHandler } from "./runtime.js";
|
|
37
|
+
|
|
38
|
+
export interface AppEventDef<S extends z.ZodTypeAny = z.ZodTypeAny> {
|
|
39
|
+
/** 이 도메인 이벤트 content 스키마(레지스트리에 등록 → validateEvent/canPublish 가 인식). */
|
|
40
|
+
schema: S;
|
|
41
|
+
/** 이 이벤트가 들어오면 어떤 카드(기존 블록 조합)로 그릴지 — 이벤트 ↔ UI 결합. */
|
|
42
|
+
view: (content: z.infer<S>) => UiCardContent;
|
|
43
|
+
publishableBy?: Publisher[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface AppDef {
|
|
47
|
+
/** 전역 고유 id(패키지 1개 = 1개). */
|
|
48
|
+
id: string;
|
|
49
|
+
/** room.binding/app.surface 의 app 키(작성자/도메인 식별자). */
|
|
50
|
+
app: string;
|
|
51
|
+
displayName?: string;
|
|
52
|
+
entityTypes: string[];
|
|
53
|
+
/**
|
|
54
|
+
* 코어 앱 여부 — true 면 이 앱의 액션은 *모든* 방에서 동작한다(room.binding 격리 면제). 에이전트 자신의
|
|
55
|
+
* 내장 능력(예: finance 의 invoice 승인)을 표현한다. false(기본)인 도메인 앱(acme/northwind 등)은
|
|
56
|
+
* 자기 방에 바인딩됐을 때만 동작한다(per-room 격리). 보안 게이트(scope/PL)는 코어 앱에도 그대로 적용된다.
|
|
57
|
+
* ⚠️ 신뢰 경계: core 는 격리를 면제하므로 *호스트가 신뢰하는* 1st-party 앱만 가져야 한다. 서드파티 로딩(G5)에선
|
|
58
|
+
* 앱이 self-declare 한 core 를 그대로 믿으면 안 되고, 호스트가 manifest 검수 단계에서 core 부여를 통제해야 한다.
|
|
59
|
+
*/
|
|
60
|
+
core?: boolean;
|
|
61
|
+
/** 커스텀 이벤트 → 카드 결합(이벤트 타입 → { schema, view }). */
|
|
62
|
+
events?: Record<string, AppEventDef>;
|
|
63
|
+
/**
|
|
64
|
+
* 구독 선언 — "이 이벤트 타입이 오면 반응한다"(이벤트 타입 → 핸들러). events.view 가 *렌더링*이라면
|
|
65
|
+
* on 은 *반응*이다. 남(다른 앱·브리지)의 타입을 들어도 되고, 여러 앱이 같은 타입을 함께 들어도 된다
|
|
66
|
+
* (이벤트 드리븐 — 주소 없음). 라우팅은 호스트가 dispatchEvent 로 한다. org.corp.ui.action 구독은
|
|
67
|
+
* 정의 시점에 거부한다 — 액션의 유일한 진입로는 게이트를 지나는 dispatchAction 이다(권한 우회 차단).
|
|
68
|
+
*/
|
|
69
|
+
on?: Record<string, EventHandler>;
|
|
70
|
+
/** 액션 컨트랙트(버튼 + scope). */
|
|
71
|
+
actions?: AppAction[];
|
|
72
|
+
/** action_id → 서버 핸들러(동작). actions 와 짝(서비스 면). */
|
|
73
|
+
handlers?: Record<string, ActionHandler>;
|
|
74
|
+
/** 우측 셸 패널(데이터). surfaceState 로 상태 이벤트화. */
|
|
75
|
+
sidebar?: PanelSpec;
|
|
76
|
+
/** 메인 영역 서피스(데이터, 선택) — 룸 본문에 탭으로 그릴 중앙 콘텐츠. sidebar 와 함께 발행된다. */
|
|
77
|
+
main?: PanelSpec;
|
|
78
|
+
/** 룸 헤더(데이터, 선택). */
|
|
79
|
+
header?: HeaderSpec;
|
|
80
|
+
// --- M3 셸(선택, 전부 데이터) — 와이어(org.corp.app.surface)로 실려 클라가 렌더한다(외부 SDK 개방). ---
|
|
81
|
+
/** 이벤트 캔버스(보드). */
|
|
82
|
+
canvas?: CanvasSpec;
|
|
83
|
+
/** 우측 에이전트 도크. */
|
|
84
|
+
dock?: AgentDockSpec;
|
|
85
|
+
/** 컴포저(슬래시 명령·퀵액션·@엔티티 자동완성). */
|
|
86
|
+
composer?: ComposerSpec;
|
|
87
|
+
/** ⌘K 커맨드 팔레트 엔트리. */
|
|
88
|
+
commandPalette?: CommandPaletteSpec;
|
|
89
|
+
/** 객체 맵(엔티티 관계 그래프). */
|
|
90
|
+
objectMap?: ObjectMapSpec;
|
|
91
|
+
/** 선언형 모달/시트(확인 다이얼로그) — 같은 id 의 셸 액션이 열고, 확인 시 confirm.action_id 발행. */
|
|
92
|
+
modals?: ModalSpec[];
|
|
93
|
+
/** 회사별 브랜드 토큰 오버라이드(데이터). */
|
|
94
|
+
theme?: Record<string, string>;
|
|
95
|
+
/**
|
|
96
|
+
* 핸들러 지연 등록(런타임 경계 물리화) — true 면 defineApp 은 핸들러 없이 App 을 만들고, 파괴적-액션-핸들러
|
|
97
|
+
* 링크검사를 미룬다. 호스트(AS)가 부팅 시 attachHandlers(app, handlers) 로 *service 서브패스*의 핸들러를 붙이며
|
|
98
|
+
* 그때 전체 링크검사를 한다. 효과: 핸들러(동작) 코드가 클라 번들에 실리지 않는다(클라는 contract/surface 만 로드).
|
|
99
|
+
*/
|
|
100
|
+
deferHandlers?: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface App extends Plugin {
|
|
104
|
+
actions: Map<string, AppAction>;
|
|
105
|
+
handlers: Map<string, ActionHandler>;
|
|
106
|
+
events: Record<string, AppEventDef>;
|
|
107
|
+
/** 구독 선언(이벤트 타입 → 핸들러) — AppDef.on. 호스트가 dispatchEvent 로 라우팅한다. */
|
|
108
|
+
on: Map<string, EventHandler>;
|
|
109
|
+
/** 코어 앱(모든 방에서 동작, room.binding 격리 면제) 여부 — AppDef.core. 호스트 라우터가 격리 검사 때 본다. */
|
|
110
|
+
core: boolean;
|
|
111
|
+
/** 도메인 이벤트 content → 보낼 메시지(데이터, org.corp.ui 카드 포함). 미등록/검증실패 시 null. */
|
|
112
|
+
messageForEvent(type: string, content: unknown): ReturnType<typeof buildUiMessageContent> | null;
|
|
113
|
+
/** 우측 셸 서피스를 상태 이벤트 content(데이터)로. sidebar 없으면 null. */
|
|
114
|
+
surfaceState(): AppSurfaceContent | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function defineApp(def: AppDef): App {
|
|
118
|
+
const actions = new Map<string, AppAction>();
|
|
119
|
+
for (const a of def.actions ?? []) actions.set(a.id, a);
|
|
120
|
+
const handlers = new Map<string, ActionHandler>();
|
|
121
|
+
for (const [id, h] of Object.entries(def.handlers ?? {})) handlers.set(id, h);
|
|
122
|
+
const events = def.events ?? {};
|
|
123
|
+
const on = new Map<string, EventHandler>();
|
|
124
|
+
for (const [type, h] of Object.entries(def.on ?? {})) on.set(type, h);
|
|
125
|
+
|
|
126
|
+
// ui.action 구독 금지(fail-fast) — 액션은 권한 게이트를 지나는 dispatchAction 만이 진입로다.
|
|
127
|
+
// on 으로 받으면 scope/PL/발행자격 검사를 전부 우회하게 되므로 정의 시점에 거부한다.
|
|
128
|
+
if (on.has(EventType.UiAction)) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`[app-sdk] 앱 '${def.app}': '${EventType.UiAction}' 은 on 으로 구독할 수 없습니다 — ` +
|
|
131
|
+
`액션 수신은 actions/handlers(권한 게이트 경유)로 선언하세요.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 액션 id 네임스페이스 강제 — 핸들러 유무와 무관하게 정의 시점에 항상 검사한다(deferHandlers 여도).
|
|
136
|
+
assertActionNamespace(def.app, actions);
|
|
137
|
+
|
|
138
|
+
// 부팅 링크검사(fail-fast) — 흩어진 버튼/핸들러/스코프 연결의 정합성을 정의 시점에 확인한다.
|
|
139
|
+
// deferHandlers(런타임 경계 분리)면 핸들러는 나중에 attachHandlers 가 붙이며 그때 검사한다 → 여기선 건너뛴다.
|
|
140
|
+
if (!def.deferHandlers) assertActionHandlerLinks(def.app, actions, handlers);
|
|
141
|
+
|
|
142
|
+
// 앱은 자기 네임스페이스(org.corp.<app>.) 안에서만 이벤트를 등록할 수 있다(소유 강제).
|
|
143
|
+
const nsPrefix = `${NS}.${def.app}.`;
|
|
144
|
+
// Plugin.register — 부팅 시 1회(registerPlugin 가 plugin.id 로 멱등 가드). 도메인 이벤트 스키마를 레지스트리에 등록(클라/AS 공통).
|
|
145
|
+
const register = (): void => {
|
|
146
|
+
for (const [type, ev] of Object.entries(events)) {
|
|
147
|
+
if (!type.startsWith(nsPrefix)) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`[app-sdk] 앱 '${def.app}': 이벤트 타입 '${type}' 가 앱 네임스페이스 '${nsPrefix}' 밖입니다 — 남의 네임스페이스 등록 금지.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
// register 는 멱등 가드 뒤라 1회만 호출되므로, false 는 *진짜 충돌*(다른 앱이 같은 타입/접두사 섀도잉)이다 → throw.
|
|
153
|
+
const ok = registerEventEntry({
|
|
154
|
+
type,
|
|
155
|
+
kind: "message",
|
|
156
|
+
schema: ev.schema,
|
|
157
|
+
publishableBy: ev.publishableBy ?? ["bridge", "agent"],
|
|
158
|
+
description: `${def.app} 앱 도메인 이벤트`,
|
|
159
|
+
});
|
|
160
|
+
if (!ok) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`[app-sdk] 앱 '${def.app}': 이벤트 타입 '${type}' 등록 실패(이미 등록됨/접두사 충돌) — 타입 충돌을 확인하세요.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// 서피스는 데이터(org.corp.app.surface 상태 이벤트)로 흐르므로 registerAppPack(클라 평면) 불필요.
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
id: def.id,
|
|
171
|
+
app: def.app,
|
|
172
|
+
displayName: def.displayName,
|
|
173
|
+
entityTypes: def.entityTypes,
|
|
174
|
+
core: def.core ?? false,
|
|
175
|
+
provides: { events: Object.keys(events) },
|
|
176
|
+
capabilities: deriveCapabilities(actions),
|
|
177
|
+
register,
|
|
178
|
+
actions,
|
|
179
|
+
handlers,
|
|
180
|
+
events,
|
|
181
|
+
on,
|
|
182
|
+
messageForEvent(type, content) {
|
|
183
|
+
const ev = events[type];
|
|
184
|
+
if (!ev) return null;
|
|
185
|
+
const parsed = ev.schema.safeParse(content);
|
|
186
|
+
if (!parsed.success) return null;
|
|
187
|
+
return buildUiMessageContent(ev.view(parsed.data));
|
|
188
|
+
},
|
|
189
|
+
surfaceState() {
|
|
190
|
+
// panel(=sidebar)은 필수 와이어 필드라 sidebar 없으면 서피스를 발행하지 않는다.
|
|
191
|
+
// main 은 sidebar 와 함께만 실어 보낸다(메인 영역 탭 콘텐츠). main 만 있는 앱은 v1 미지원.
|
|
192
|
+
if (!def.sidebar) return null;
|
|
193
|
+
return buildAppSurface({
|
|
194
|
+
app: def.app,
|
|
195
|
+
display_name: def.displayName,
|
|
196
|
+
entity_type: def.entityTypes[0],
|
|
197
|
+
panel: def.sidebar,
|
|
198
|
+
main: def.main,
|
|
199
|
+
header: def.header,
|
|
200
|
+
canvas: def.canvas,
|
|
201
|
+
dock: def.dock,
|
|
202
|
+
composer: def.composer,
|
|
203
|
+
commandPalette: def.commandPalette,
|
|
204
|
+
objectMap: def.objectMap,
|
|
205
|
+
modals: def.modals,
|
|
206
|
+
theme: def.theme,
|
|
207
|
+
});
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 액션↔핸들러 부팅 링크검사(fail-fast). defineApp 과 withActions 가 공유한다.
|
|
214
|
+
* (1) 핸들러 키가 액션에 없으면 = 오타/유령 핸들러 → throw(게이트는 통과하나 효과 0개로 status:"ran" 인데
|
|
215
|
+
* 아무 일도 안 일어나는 조용한 함정 차단). (2) 파괴적 액션에 핸들러 없으면 = 게이트만 통과하고 효과 없음 → throw.
|
|
216
|
+
*/
|
|
217
|
+
/**
|
|
218
|
+
* 액션 id 네임스페이스 강제(부팅 fail-fast) — 액션 id 는 `<app>.` 으로 시작해야 한다.
|
|
219
|
+
*
|
|
220
|
+
* 이벤트 타입에 이미 적용하던 소유 규칙(register 의 nsPrefix 검사)을 액션에도 똑같이 건다. action_id 는
|
|
221
|
+
* App 간 전역 고유해야 하고(buildActionIndex 가 충돌을 throw), 코어 프리미티브(approve/reject/cancel/send…)
|
|
222
|
+
* 와도 겹치면 안 되는데 — 평문 id 는 앱이 늘수록 충돌이 확정적이다(실제로 forgejo 의 PR 승인이 코어 invoice
|
|
223
|
+
* 의 "approve" 와 부딪혀 한 차례 우회한 전례가 있다). 소유자를 id 에 박아 구조적으로 막는다.
|
|
224
|
+
*/
|
|
225
|
+
function assertActionNamespace(label: string, actions: Map<string, AppAction>): void {
|
|
226
|
+
const prefix = `${label}.`;
|
|
227
|
+
for (const id of actions.keys()) {
|
|
228
|
+
if (!id.startsWith(prefix)) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`[app-sdk] 앱 '${label}': 액션 id '${id}' 가 앱 네임스페이스 '${prefix}' 밖입니다 — ` +
|
|
231
|
+
`'${prefix}${id}' 처럼 앱 키를 접두사로 두세요(코어 프리미티브·타 앱과의 action_id 충돌 방지).`,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* App 의 액션에서 capability 표면(scope/backendTools)을 자동 도출한다 — manifest.capabilities 로 게시되며,
|
|
239
|
+
* 코드(액션)와 선언이 한 출처라 어긋나지 않는다(서드파티 로딩 감사용 신분증). 액션 0개면 undefined.
|
|
240
|
+
*/
|
|
241
|
+
function deriveCapabilities(actions: Map<string, AppAction>): PluginCapabilities | undefined {
|
|
242
|
+
const scopes = [...new Set([...actions.values()].map((a) => a.scope))];
|
|
243
|
+
const backendTools = [...new Set([...actions.values()].flatMap((a) => a.tools ?? []))];
|
|
244
|
+
if (!scopes.length && !backendTools.length) return undefined;
|
|
245
|
+
return { ...(scopes.length ? { scopes } : {}), ...(backendTools.length ? { backendTools } : {}) };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function assertActionHandlerLinks(
|
|
249
|
+
label: string,
|
|
250
|
+
actions: Map<string, AppAction>,
|
|
251
|
+
handlers: Map<string, ActionHandler>,
|
|
252
|
+
): void {
|
|
253
|
+
for (const id of handlers.keys()) {
|
|
254
|
+
if (!actions.has(id)) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
`[app-sdk] 앱 '${label}': 핸들러 '${id}' 에 대응하는 액션이 없습니다(오타 가능). actions 에 정의하거나 핸들러 키를 고치세요.`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
for (const a of actions.values()) {
|
|
261
|
+
if (a.destructive && !handlers.has(a.id)) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`[app-sdk] 앱 '${label}': 파괴적 액션 '${a.id}' 에 핸들러가 없습니다 — 핸들러를 정의하거나 destructive 를 내리세요.`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* service 핸들러를 (deferHandlers 로 만든) App 에 부착한다 — 런타임 경계 물리화의 호스트 측 절반.
|
|
271
|
+
* 클라는 contract/surface(핸들러 0)만 로드해 App 을 만들고, AS 만 `@chatops/app-<x>/service` 의 핸들러를 import 해
|
|
272
|
+
* 부팅 시 이걸 호출한다 → 핸들러(동작) 코드가 클라 번들에 실리지 않는다. 붙일 때 전체 링크검사를 수행한다(fail-fast).
|
|
273
|
+
* 같은 App 싱글톤의 handlers Map 을 채우므로 actionIndex/appsByName 가 참조하던 App 에 즉시 반영된다.
|
|
274
|
+
*/
|
|
275
|
+
export function attachHandlers(app: App, handlers: Record<string, ActionHandler>): App {
|
|
276
|
+
for (const [id, h] of Object.entries(handlers)) app.handlers.set(id, h);
|
|
277
|
+
assertActionHandlerLinks(app.app, app.actions, app.handlers);
|
|
278
|
+
return app;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 레거시 플러그인(forgejo/ops 등 = AppPack/템플릿으로 카드를 그리지만 핸들러가 없던 것)에 액션/핸들러를 부착해
|
|
283
|
+
* App(=actionIndex 가 픽업하는 상위집합)으로 증강한다. 플러그인의 register()(AppPack/템플릿/이벤트 등록)는
|
|
284
|
+
* 그대로 보존하므로 풍부한 서피스를 잃지 않는다 — defineApp 전체 전환 없이 "카드 버튼이 핸들러로 왕복"만 켠다.
|
|
285
|
+
*
|
|
286
|
+
* 코어 변경 0: enabledPlugins 의 원소를 plain plugin → withActions(plugin, …) 로 바꾸면 그 도메인의 버튼이 살아난다.
|
|
287
|
+
* 도메인 앱이므로 core=false(방의 room.binding 에 바인딩됐을 때만 동작) — 보안 게이트는 그대로 서버.
|
|
288
|
+
* App.surface(서피스-as-데이터)는 발행하지 않는다(레거시는 AppPack 클라 평면으로 렌더) → surfaceState=null.
|
|
289
|
+
*/
|
|
290
|
+
export function withActions(
|
|
291
|
+
plugin: Plugin,
|
|
292
|
+
opts: { actions: AppAction[]; handlers: Record<string, ActionHandler> },
|
|
293
|
+
): App {
|
|
294
|
+
const app = (plugin as Partial<App>).app ?? plugin.id;
|
|
295
|
+
const actions = new Map<string, AppAction>();
|
|
296
|
+
for (const a of opts.actions) actions.set(a.id, a);
|
|
297
|
+
const handlers = new Map<string, ActionHandler>();
|
|
298
|
+
for (const [id, h] of Object.entries(opts.handlers)) handlers.set(id, h);
|
|
299
|
+
assertActionNamespace(app, actions);
|
|
300
|
+
assertActionHandlerLinks(app, actions, handlers);
|
|
301
|
+
return {
|
|
302
|
+
...plugin,
|
|
303
|
+
app,
|
|
304
|
+
core: false,
|
|
305
|
+
// 레거시 manifest 의 capabilities 를 액션에서 도출한 값으로 덮어쓴다(withActions 로 부착한 액션의 실제 표면).
|
|
306
|
+
capabilities: deriveCapabilities(actions),
|
|
307
|
+
actions,
|
|
308
|
+
handlers,
|
|
309
|
+
events: {},
|
|
310
|
+
on: new Map(),
|
|
311
|
+
// 레거시 플러그인은 App.messageForEvent(이벤트→카드) 대신 자기 toCard/템플릿으로 렌더하고,
|
|
312
|
+
// 서피스는 AppPack(클라 평면)으로 등록한다 → App 의 데이터 평면 두 메서드는 비활성(null).
|
|
313
|
+
messageForEvent: () => null,
|
|
314
|
+
surfaceState: () => null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Plugin 이 App(=defineApp 산출물)인지 판정 — 액션/핸들러/이벤트를 가진 상위집합. 라이브 AS 가 enabledPlugins 에서 App 만 추릴 때 쓴다. */
|
|
319
|
+
export function isApp(p: Plugin): p is App {
|
|
320
|
+
const a = p as Partial<App>;
|
|
321
|
+
return a.actions instanceof Map && a.handlers instanceof Map && typeof a.messageForEvent === "function";
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Plugin 목록(클라/AS 공통 enabledPlugins)에서 App 만 추린다. */
|
|
325
|
+
export function enabledApps(plugins: Plugin[]): App[] {
|
|
326
|
+
return plugins.filter(isApp);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* action_id → 소유 App 인덱스 — 라이브 AS 가 들어온 org.corp.ui.action 을 어느 App 의 핸들러로 보낼지 결정한다
|
|
331
|
+
* (이게 onUiAction 을 도메인 하드코딩 대신 제너릭 dispatchAction 루프로 만드는 빠진 이음새였다).
|
|
332
|
+
* 충돌(같은 action_id 를 두 App 이 소유)은 부팅 시 즉시 throw(fail-fast) — registry 의 prefix-shadow
|
|
333
|
+
* 가드와 같은 보수적 거부 정책(조용한 first-writer-wins 금지). action_id 는 App 간 고유해야 하며,
|
|
334
|
+
* 앱은 자기 네임스페이스로 두는 게 안전하다.
|
|
335
|
+
*/
|
|
336
|
+
export function buildActionIndex(apps: App[]): Map<string, App> {
|
|
337
|
+
const index = new Map<string, App>();
|
|
338
|
+
for (const app of apps) {
|
|
339
|
+
for (const id of app.actions.keys()) {
|
|
340
|
+
const owner = index.get(id);
|
|
341
|
+
if (owner && owner !== app) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
`[app-sdk] action_id 충돌: '${id}' 를 앱 '${owner.app}' 와 '${app.app}' 가 모두 소유합니다. action_id 는 App 간 고유해야 합니다(앱 네임스페이스 권장).`,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
if (!owner) index.set(id, app);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return index;
|
|
350
|
+
}
|
package/src/builders.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 카드/패널 빌더 — "이미 있는 닫힌 블록/바디"를 조합해 데이터(UiCardContent / PanelSpec)를 만든다.
|
|
3
|
+
*
|
|
4
|
+
* 핵심: 새 블록을 만들지 않는다(코어 어휘만 조합). 그래서 결과가 "데이터"로 남고 — App Service 가
|
|
5
|
+
* 그대로 와이어에 실어 보내면 클라가 가진 렌더러로 그린다(클라 무변경 = "AS만 추가해도 UI 등록").
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
buildUiCardContent,
|
|
9
|
+
type Block,
|
|
10
|
+
type PanelSpec,
|
|
11
|
+
type SurfaceSection,
|
|
12
|
+
type UiCardContent,
|
|
13
|
+
} from "@underdogai/mesh-event-schemas";
|
|
14
|
+
import type { ButtonElement } from "./action.js";
|
|
15
|
+
|
|
16
|
+
export interface CardOpts {
|
|
17
|
+
title?: string;
|
|
18
|
+
fallback?: string;
|
|
19
|
+
state?: UiCardContent["state"];
|
|
20
|
+
context?: UiCardContent["context"];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 타임라인 카드(UiCardContent) 빌더 — 기존 블록 조합 → 검증된 데이터. */
|
|
24
|
+
export const card = {
|
|
25
|
+
/** 닫힌 블록 트리로 카드를 구성(반환 즉시 zod 검증된 UiCardContent). */
|
|
26
|
+
build(card_id: string, blocks: Block[], opts: CardOpts = {}): UiCardContent {
|
|
27
|
+
return buildUiCardContent({
|
|
28
|
+
card_id,
|
|
29
|
+
title: opts.title,
|
|
30
|
+
fallback: opts.fallback ?? opts.title ?? card_id,
|
|
31
|
+
state: opts.state,
|
|
32
|
+
context: opts.context,
|
|
33
|
+
blocks,
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
fields(items: { label: string; value: string; short?: boolean }[]): Block {
|
|
37
|
+
return { type: "fields", items };
|
|
38
|
+
},
|
|
39
|
+
text(text: string): Block {
|
|
40
|
+
return { type: "text", text };
|
|
41
|
+
},
|
|
42
|
+
actions(elements: ButtonElement[]): Block {
|
|
43
|
+
return { type: "actions", elements };
|
|
44
|
+
},
|
|
45
|
+
// --- 폼 입력 블록(2단계 폼 카드용 — 폼 방식 결정: 모달 폼 대신 폼 카드) ---
|
|
46
|
+
// 클라가 name 으로 값을 모아 제출 버튼의 action_id 로 org.corp.ui.action.values 에 실어 보낸다.
|
|
47
|
+
// 핸들러는 action.values.parse(ctx.values) 로 타입 확보(defineAction 의 values 스키마와 짝).
|
|
48
|
+
/** 텍스트/숫자/날짜 입력 — input 종류는 "text"(기본)·"textarea"·"number"·"date"·"datetime"·"time". */
|
|
49
|
+
input(name: string, opts: { label?: string; input?: "text" | "textarea" | "number" | "date" | "datetime" | "time"; placeholder?: string; required?: boolean; value?: string } = {}): Block {
|
|
50
|
+
return { type: "input", name, ...opts };
|
|
51
|
+
},
|
|
52
|
+
/** 드롭다운 선택(단일/다중). */
|
|
53
|
+
select(name: string, options: { value: string; label: string }[], opts: { label?: string; placeholder?: string; value?: string; multi?: boolean } = {}): Block {
|
|
54
|
+
return { type: "select", name, options, ...opts };
|
|
55
|
+
},
|
|
56
|
+
/** 라디오 그룹(옵션이 펼쳐져 보이는 단일 선택). */
|
|
57
|
+
radio(name: string, options: { value: string; label: string }[], opts: { label?: string; value?: string } = {}): Block {
|
|
58
|
+
return { type: "radio", name, options, ...opts };
|
|
59
|
+
},
|
|
60
|
+
/** 체크박스(불리언 수집). */
|
|
61
|
+
checkbox(name: string, label: string, opts: { checked?: boolean } = {}): Block {
|
|
62
|
+
return { type: "checkbox", name, label, ...opts };
|
|
63
|
+
},
|
|
64
|
+
/** 토글 스위치(불리언 수집, on/off 어포던스). */
|
|
65
|
+
toggle(name: string, label: string, opts: { checked?: boolean; onText?: string; offText?: string } = {}): Block {
|
|
66
|
+
return { type: "toggle", name, label, ...opts };
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** 우측 셸 패널(PanelSpec) 빌더 — 닫힌 SurfaceBody 조합. value 문자열의 {{entity.path}} 는 클라가 바인딩. */
|
|
71
|
+
export const panel = {
|
|
72
|
+
sections(sections: SurfaceSection[]): PanelSpec {
|
|
73
|
+
return { sections };
|
|
74
|
+
},
|
|
75
|
+
fields(id: string, title: string, items: { label: string; value: string }[]): SurfaceSection {
|
|
76
|
+
return { id, title, body: { kind: "fields", items } };
|
|
77
|
+
},
|
|
78
|
+
list(id: string, title: string, items: { primary: string; secondary?: string }[]): SurfaceSection {
|
|
79
|
+
return { id, title, body: { kind: "list", items } };
|
|
80
|
+
},
|
|
81
|
+
memo(id: string, title: string, summary: string, updates?: string[]): SurfaceSection {
|
|
82
|
+
return { id, title, body: { kind: "memo", summary, updates } };
|
|
83
|
+
},
|
|
84
|
+
card(id: string, title: string, embedded: UiCardContent): SurfaceSection {
|
|
85
|
+
return { id, title, body: { kind: "card", card: embedded } };
|
|
86
|
+
},
|
|
87
|
+
};
|
package/src/host.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 호스트 활성화 배선 — "봇이 초대되면 자동으로 참여하고, 그 방에 서피스를 **한 번** 발행한다"(킥오프 §2.4)를
|
|
3
|
+
* 호스트 무관으로 제공한다. AS 에이전트(appservice-agent)와 봇 모드 serve()(Phase 3)가 같은 코드를 쓴다.
|
|
4
|
+
*
|
|
5
|
+
* 여기 있는 것은 *앱-제너릭* 절반뿐이다 — 어떤 방이 어떤 앱을 호스팅하는가(room.apps/space.apps 거버넌스),
|
|
6
|
+
* capability 게시, 잔존 서피스 정리 같은 호스트 정책은 호스트가 소유한다(applyEffects 와 같은 의존성 역전).
|
|
7
|
+
*/
|
|
8
|
+
import { EventType } from "@underdogai/mesh-event-schemas";
|
|
9
|
+
import type { App } from "./app.js";
|
|
10
|
+
import type { MatrixSendClient } from "./runtime.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 호스트가 주입하는 Matrix 핸들(활성화용) — matrix-bot-sdk 의 MatrixClient 가 구조적으로 그대로 만족한다.
|
|
14
|
+
* getRoomStateEvent 는 선택(중복 발행 방지용) — 없거나 실패하면 무조건 발행으로 폴백한다.
|
|
15
|
+
*/
|
|
16
|
+
export interface MatrixHostClient extends MatrixSendClient {
|
|
17
|
+
/** 초대 수락(참여). */
|
|
18
|
+
joinRoom(roomId: string): Promise<unknown>;
|
|
19
|
+
/** 현재 상태 읽기(있으면 중복 발행 방지에 쓴다). 상태 없음은 throw 로 신호해도 된다(M_NOT_FOUND 등). */
|
|
20
|
+
getRoomStateEvent?(roomId: string, eventType: string, stateKey: string): Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PublishSurfaceResult = "published" | "unchanged" | "no_surface";
|
|
24
|
+
|
|
25
|
+
export interface PublishSurfaceInput {
|
|
26
|
+
client: MatrixHostClient | (MatrixSendClient & Pick<MatrixHostClient, "getRoomStateEvent">);
|
|
27
|
+
app: App;
|
|
28
|
+
roomId: string;
|
|
29
|
+
/** 멀티앱 방 키잉(보통 app.app). 레거시 단일-앱 방은 ""(기본). */
|
|
30
|
+
stateKey?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 앱 서피스(org.corp.app.surface)를 방에 **1회** 발행한다 — 이미 같은 내용이 올라가 있으면 재발행하지 않는다.
|
|
35
|
+
* (킥오프 §7 함정: 상태 이벤트를 매 틱 재발행하면 타임라인이 설정값으로 덮여 방을 열었을 때 카드가 안 보인다.
|
|
36
|
+
* 서피스는 상태 이벤트라 한 번 올리면 영속한다 — 부팅/초대/바인딩 변경마다 불러도 안전한 멱등 연산이어야 한다.)
|
|
37
|
+
* sidebar 없는 앱은 발행할 것이 없다(no_surface).
|
|
38
|
+
*/
|
|
39
|
+
export async function publishSurface(input: PublishSurfaceInput): Promise<PublishSurfaceResult> {
|
|
40
|
+
const surface = input.app.surfaceState();
|
|
41
|
+
if (!surface) return "no_surface";
|
|
42
|
+
const stateKey = input.stateKey ?? "";
|
|
43
|
+
if (input.client.getRoomStateEvent) {
|
|
44
|
+
try {
|
|
45
|
+
const existing = await input.client.getRoomStateEvent(input.roomId, EventType.AppSurface, stateKey);
|
|
46
|
+
// 서버가 돌려준 현재 상태가 지금 발행하려는 내용과 같으면 중복 — 재발행하지 않는다.
|
|
47
|
+
// ⚠️ 비교는 canonical(키 정렬) — 홈서버는 canonical JSON(키 알파벳순)으로 재직렬화해 돌려주는 반면
|
|
48
|
+
// zod 산출물은 스키마 선언 순서(v 가 맨 앞)라, 순서 민감 비교면 실서버 상대로 unchanged 가 절대 안 나온다.
|
|
49
|
+
if (existing && canonicalStringify(existing) === canonicalStringify(surface)) return "unchanged";
|
|
50
|
+
} catch {
|
|
51
|
+
// 상태 없음(M_NOT_FOUND)/조회 실패 → 발행 진행(중복 방지는 best-effort, 발행이 우선).
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await input.client.sendStateEvent(input.roomId, EventType.AppSurface, stateKey, surface);
|
|
55
|
+
return "published";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 키 순서 무관 비교용 canonical 직렬화(재귀 키 정렬) — Matrix canonical JSON 과 같은 정렬 기준(코드 유닛). */
|
|
59
|
+
function canonicalStringify(v: unknown): string {
|
|
60
|
+
return JSON.stringify(sortKeysDeep(v));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function sortKeysDeep(v: unknown): unknown {
|
|
64
|
+
if (Array.isArray(v)) return v.map(sortKeysDeep);
|
|
65
|
+
if (v && typeof v === "object") {
|
|
66
|
+
return Object.fromEntries(
|
|
67
|
+
Object.entries(v as Record<string, unknown>)
|
|
68
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
69
|
+
.map(([k, val]) => [k, sortKeysDeep(val)]),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 초대 활성화 시퀀스 — 참여 → 서피스 1회 발행. "봇을 초대하면 활성화된다, 사용자가 따로 설정할 게 없다"(§2.4)
|
|
77
|
+
* 의 앱-제너릭 절반. 호스트는 자기 정책(capability 게시·멀티앱 키잉 등)을 이 앞뒤에 끼운다.
|
|
78
|
+
*/
|
|
79
|
+
export async function activateOnInvite(input: {
|
|
80
|
+
client: MatrixHostClient;
|
|
81
|
+
app: App;
|
|
82
|
+
roomId: string;
|
|
83
|
+
stateKey?: string;
|
|
84
|
+
}): Promise<PublishSurfaceResult> {
|
|
85
|
+
await input.client.joinRoom(input.roomId);
|
|
86
|
+
return publishSurface(input);
|
|
87
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @underdogai/mesh-app-sdk — App Service ↔ UI 연결 SDK (v0).
|
|
3
|
+
*
|
|
4
|
+
* 한 폴더(한 App)에서 커스텀 이벤트 ↔ 카드(데이터) · 카드 버튼 ↔ 타입드 액션/핸들러 · 우측 셸 서피스(데이터)를
|
|
5
|
+
* 함께 선언한다. App = Plugin 상위집합 → enabledPlugins 한 줄로 클라/AS/브리지가 같은 스키마를 등록한다.
|
|
6
|
+
* 보안(권한 게이트)은 호스트가 주입(의존성 역전) — SDK 는 scope/destructive 를 데이터로만 공급한다.
|
|
7
|
+
*
|
|
8
|
+
* 설계/로드맵: docs/sdk-plan.md, 필요성 기록: docs/sdk.md.
|
|
9
|
+
*/
|
|
10
|
+
export * from "./action.js";
|
|
11
|
+
export * from "./builders.js";
|
|
12
|
+
export * from "./app.js";
|
|
13
|
+
export * from "./runtime.js";
|
|
14
|
+
export * from "./host.js";
|
|
15
|
+
export * from "./serve.js";
|