@mohou/host 1.0.20
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 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { mkdtempSync } from 'node:fs'
|
|
2
|
+
import { mkdir } from 'node:fs/promises'
|
|
3
|
+
import { homedir, tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { builtinWorkbenchId } from '@mohou/contract'
|
|
7
|
+
import { McpClient } from '@mohou/mcp-client'
|
|
8
|
+
import type { RuntimeProvider } from '@mohou/runtime-provider'
|
|
9
|
+
|
|
10
|
+
import { createAppRegistry, listedApp, type ListedApp } from '../apps/registry.ts'
|
|
11
|
+
import { ensureRuntimeAppsLayout } from '../apps/startup-seed.ts'
|
|
12
|
+
import { readActivity } from './activity.ts'
|
|
13
|
+
import { historyBounds } from '../history/store.ts'
|
|
14
|
+
import { ensureVendorFiles, vendorOutputDir } from '../compile/build-vendor.ts'
|
|
15
|
+
import { renderRunnerDocument } from '../compile/runner.ts'
|
|
16
|
+
import { bindLoopback } from '../http/bind.ts'
|
|
17
|
+
import { appResource, httpLayout } from '../http/layout.ts'
|
|
18
|
+
import { createOwnerReads, reloadView } from '../owner/read.ts'
|
|
19
|
+
import { resolveFirstPaint } from '../theme/paint.ts'
|
|
20
|
+
import { createThemePins, type AppPin } from '../theme/pin.ts'
|
|
21
|
+
import { createBash } from '../bash/client.ts'
|
|
22
|
+
import { createHttp } from '../http/client.ts'
|
|
23
|
+
import { readMetrics } from '../metrics/read.ts'
|
|
24
|
+
import { createPwsh } from '../pwsh/client.ts'
|
|
25
|
+
import type { CommandPolicy } from '../shell/command.ts'
|
|
26
|
+
import { createAuthorTools, ensureAuthoringToken, readAuthoringToken } from '../tools/author.ts'
|
|
27
|
+
import { startAuthorHttp } from '../tools/http.ts'
|
|
28
|
+
import { emptyCredentials, type CredentialProvider } from '../credentials/provider.ts'
|
|
29
|
+
import type { AuthorMcpLayout } from './author-mcp.ts'
|
|
30
|
+
import type { AuthorSkillLayout } from './author-skill.ts'
|
|
31
|
+
import { ConfigError } from './codes.ts'
|
|
32
|
+
import { probeBrain, resolveHostConfig, writeHostPolicy, type HostPolicy, type HostSeed } from './config.ts'
|
|
33
|
+
import { allocateHostPort, PortInUseError } from './port.ts'
|
|
34
|
+
import { homeThemesDir, hostAppsDir, hostTrashDir } from './layout.ts'
|
|
35
|
+
import { createHostLog, type HostLog } from './log.ts'
|
|
36
|
+
import { loadMcpServers, mcpReferenceSources, type McpLoadFailure } from './mcp.ts'
|
|
37
|
+
import { createAppWorkbench } from './workbench.ts'
|
|
38
|
+
|
|
39
|
+
/** Command and HTTP bounds for a booted host. Not locked numbers. */
|
|
40
|
+
const commandPolicy: CommandPolicy = { timeoutMs: 30_000, maxOutputBytes: 1_000_000 }
|
|
41
|
+
const httpPolicy = {
|
|
42
|
+
timeoutMs: { min: 1, max: 60_000, default: 10_000 },
|
|
43
|
+
maxBodyBytes: 1_000_000,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface HostSession {
|
|
47
|
+
readonly policy: HostPolicy
|
|
48
|
+
readonly author: ReturnType<typeof createAuthorTools>
|
|
49
|
+
readonly log: HostLog
|
|
50
|
+
readonly owner: ReturnType<typeof createOwnerReads> & {
|
|
51
|
+
reloadView(appId: string): Promise<void>
|
|
52
|
+
runnerDocument(appId: string): Promise<string>
|
|
53
|
+
themes: ReturnType<typeof createThemePins>
|
|
54
|
+
readPolicy(): HostPolicy
|
|
55
|
+
writePolicy(raw: unknown): Promise<{ policy: HostPolicy; restartRequired: boolean }>
|
|
56
|
+
probe(id: string): ReturnType<typeof probeBrain>
|
|
57
|
+
list(): Promise<ListedApp[]>
|
|
58
|
+
open(appId: string, title?: string): Promise<unknown>
|
|
59
|
+
deleteApp(appId: string): Promise<void>
|
|
60
|
+
undeleteApp(appId: string): Promise<unknown>
|
|
61
|
+
listTrash(): Promise<ListedApp[]>
|
|
62
|
+
restoreStorage(appId: string): Promise<void>
|
|
63
|
+
}
|
|
64
|
+
bindFrame(post: (message: unknown) => void): () => void
|
|
65
|
+
start(): Promise<{ port: number }>
|
|
66
|
+
dispose(): Promise<void>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function brainConfig(policy: HostPolicy): NonNullable<HostPolicy['runtimeProvider']['config']> {
|
|
70
|
+
const config = policy.runtimeProvider.config ?? {}
|
|
71
|
+
if (policy.llm === null || config.provider !== undefined || config.model !== undefined) return config
|
|
72
|
+
return { ...config, provider: policy.llm.provider, model: policy.llm.model }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Boot a host without starting it. Create registers no child.
|
|
77
|
+
* Start opens the authoring listener and the injected brain. A failed start leaves neither running.
|
|
78
|
+
* @param options - runtime root, seed used only when `host.json` is missing, and the brain Shell selected
|
|
79
|
+
*/
|
|
80
|
+
export async function createHost(options: {
|
|
81
|
+
readonly runtimeRoot: string
|
|
82
|
+
readonly seed: HostSeed
|
|
83
|
+
readonly provider: RuntimeProvider
|
|
84
|
+
readonly providers?: readonly RuntimeProvider[]
|
|
85
|
+
readonly credentials?: CredentialProvider
|
|
86
|
+
readonly env?: NodeJS.ProcessEnv
|
|
87
|
+
readonly themesDir?: string
|
|
88
|
+
readonly panel?: { readonly html: string; readonly script: string }
|
|
89
|
+
readonly mcpImports?: Readonly<Record<string, string>>
|
|
90
|
+
readonly restart?: () => Promise<void>
|
|
91
|
+
readonly authorSkill?: AuthorSkillLayout
|
|
92
|
+
readonly authorMcp?: AuthorMcpLayout
|
|
93
|
+
/** When false, skip copying skill startup samples (tests). Default true. */
|
|
94
|
+
readonly seedStartup?: boolean
|
|
95
|
+
}): Promise<HostSession> {
|
|
96
|
+
await mkdir(options.runtimeRoot, { recursive: true })
|
|
97
|
+
ensureAuthoringToken(options.runtimeRoot)
|
|
98
|
+
let policy = await resolveHostConfig(options.runtimeRoot, options.seed)
|
|
99
|
+
if (options.seedStartup !== false) {
|
|
100
|
+
await ensureRuntimeAppsLayout(options.runtimeRoot, options.authorSkill?.source)
|
|
101
|
+
} else {
|
|
102
|
+
await mkdir(hostAppsDir(options.runtimeRoot), { recursive: true })
|
|
103
|
+
await mkdir(hostTrashDir(options.runtimeRoot), { recursive: true })
|
|
104
|
+
}
|
|
105
|
+
const providers = options.providers ?? [options.provider]
|
|
106
|
+
const live = providers.find(item => item.id === policy.runtimeProvider.id)
|
|
107
|
+
if (live === undefined) {
|
|
108
|
+
throw new ConfigError('config-invalid', `runtime provider is not registered: ${policy.runtimeProvider.id}`)
|
|
109
|
+
}
|
|
110
|
+
const credentials = options.credentials ?? emptyCredentials()
|
|
111
|
+
const mcpSources = mcpReferenceSources(options.env ?? process.env, credentials)
|
|
112
|
+
const mcpLoaded = await loadMcpServers(options.runtimeRoot, options.env, mcpSources)
|
|
113
|
+
const mcp = new McpClient(mcpLoaded.servers, options.env)
|
|
114
|
+
// What the live client left out. Boot sets it, and every write replaces it, so the panel and the
|
|
115
|
+
// author list never report a boot-time answer about a file that has changed since.
|
|
116
|
+
let mcpFailures: readonly McpLoadFailure[] = mcpLoaded.failures
|
|
117
|
+
const readMcpFailures = (): readonly McpLoadFailure[] => mcpFailures
|
|
118
|
+
const recordMcpFailures = (failures: readonly McpLoadFailure[]): void => { mcpFailures = failures }
|
|
119
|
+
const bash = createBash(commandPolicy)
|
|
120
|
+
const pwsh = createPwsh(commandPolicy)
|
|
121
|
+
const registry = createAppRegistry(options.runtimeRoot)
|
|
122
|
+
const themesDir = options.themesDir ?? homeThemesDir(homedir())
|
|
123
|
+
const log = createHostLog(options.runtimeRoot)
|
|
124
|
+
const listOwnerApps = async () => {
|
|
125
|
+
const listed = await registry.list()
|
|
126
|
+
const activity = readActivity(registry.runtimeRoot)
|
|
127
|
+
return Promise.all(listed.apps.map(async app => listedApp(app, await historyBounds(app.directory), activity.apps[app.id])))
|
|
128
|
+
}
|
|
129
|
+
const author = createAuthorTools({
|
|
130
|
+
registry,
|
|
131
|
+
mcp,
|
|
132
|
+
readMcpFailures,
|
|
133
|
+
recordMcpFailures,
|
|
134
|
+
...options.env === undefined ? {} : { env: options.env },
|
|
135
|
+
onTreeChanged: (appId) => {
|
|
136
|
+
author.hostEvents.publish({ type: 'app:reload', appId })
|
|
137
|
+
},
|
|
138
|
+
ports: {
|
|
139
|
+
credentials,
|
|
140
|
+
config: {
|
|
141
|
+
get theme() { return policy.theme },
|
|
142
|
+
get palette() { return policy.palette },
|
|
143
|
+
get locale() { return policy.locale },
|
|
144
|
+
get chatLanguage() { return policy.chatLanguage },
|
|
145
|
+
get hostPort() { return policy.hostPort },
|
|
146
|
+
get llm() { return policy.llm },
|
|
147
|
+
},
|
|
148
|
+
log: (appId, ...args) => {
|
|
149
|
+
log.write(appId, ...args)
|
|
150
|
+
},
|
|
151
|
+
push() {},
|
|
152
|
+
http: createHttp(httpPolicy),
|
|
153
|
+
bash: command => bash.run(command),
|
|
154
|
+
pwsh: command => pwsh.run(command),
|
|
155
|
+
metrics: () => Promise.resolve(readMetrics()),
|
|
156
|
+
processDirectory: process.cwd(),
|
|
157
|
+
createTemp: () => mkdtempSync(join(tmpdir(), 'mma-')),
|
|
158
|
+
provider: live,
|
|
159
|
+
workbench: createAppWorkbench({
|
|
160
|
+
listApps: () => listOwnerApps(),
|
|
161
|
+
openApp: (appId, title) => author.invoke('mini_app_open', {
|
|
162
|
+
appId,
|
|
163
|
+
...title === undefined ? {} : { title },
|
|
164
|
+
}).then(() => undefined),
|
|
165
|
+
readDefault: () => policy.defaultWorkbenchId,
|
|
166
|
+
writeDefault: async (id) => {
|
|
167
|
+
const { defaultWorkbenchId: _gone, ...rest } = policy
|
|
168
|
+
void _gone
|
|
169
|
+
const written = await writeHostPolicy(options.runtimeRoot, policy, id === undefined ? rest : { ...rest, defaultWorkbenchId: id })
|
|
170
|
+
policy = written.policy
|
|
171
|
+
author.hostEvents.publish({ type: 'workbench:default', appId: id ?? builtinWorkbenchId })
|
|
172
|
+
},
|
|
173
|
+
locale: () => policy.locale,
|
|
174
|
+
}),
|
|
175
|
+
},
|
|
176
|
+
})
|
|
177
|
+
const themes = createThemePins(registry, themesDir)
|
|
178
|
+
const owner = {
|
|
179
|
+
...createOwnerReads(registry),
|
|
180
|
+
themes,
|
|
181
|
+
reloadView: (appId: string) => reloadView(registry, (event) => {
|
|
182
|
+
author.hostEvents.publish(event)
|
|
183
|
+
}, appId),
|
|
184
|
+
runnerDocument: (appId: string) => runnerDocument(registry, themes, policy.palette, policy.theme, policy.locale, themesDir, appId),
|
|
185
|
+
}
|
|
186
|
+
let http: { port: number; close: () => Promise<void> } | undefined
|
|
187
|
+
let started = false
|
|
188
|
+
return {
|
|
189
|
+
get policy() {
|
|
190
|
+
return policy
|
|
191
|
+
},
|
|
192
|
+
author,
|
|
193
|
+
log,
|
|
194
|
+
owner: {
|
|
195
|
+
...owner,
|
|
196
|
+
readPolicy: () => policy,
|
|
197
|
+
writePolicy: async (raw: unknown) => {
|
|
198
|
+
const written = await writeHostPolicy(options.runtimeRoot, policy, raw)
|
|
199
|
+
policy = written.policy
|
|
200
|
+
return written
|
|
201
|
+
},
|
|
202
|
+
probe: (id: string) => probeBrain(id, providers, live.id),
|
|
203
|
+
deleteApp: (appId: string) => author.deleteApp(appId),
|
|
204
|
+
undeleteApp: (appId: string) => author.undeleteApp(appId),
|
|
205
|
+
listTrash: async () => {
|
|
206
|
+
const trashed = await registry.listTrash()
|
|
207
|
+
const activity = readActivity(registry.runtimeRoot)
|
|
208
|
+
return Promise.all(trashed.map(async app => listedApp(app, await historyBounds(app.directory), activity.apps[app.id])))
|
|
209
|
+
},
|
|
210
|
+
restoreStorage: (appId: string) => author.restoreStorage(appId),
|
|
211
|
+
list: () => listOwnerApps(),
|
|
212
|
+
open: (appId: string, title?: string) => author.invoke('mini_app_open', {
|
|
213
|
+
appId,
|
|
214
|
+
...title === undefined ? {} : { title },
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
bindFrame(post) {
|
|
218
|
+
for (const row of author.appEvents.retained()) {
|
|
219
|
+
if (row.gap !== undefined) post({ type: 'app:gap', appId: row.appId, since: row.gap.since })
|
|
220
|
+
for (const event of row.events) post({ appId: row.appId, name: event.name, data: event.data, seq: event.seq })
|
|
221
|
+
}
|
|
222
|
+
const stopHost = author.hostEvents.subscribe((event) => {
|
|
223
|
+
if (event.type === 'app:reload' || event.type === 'app:eval') post(event)
|
|
224
|
+
})
|
|
225
|
+
const stopAuthor = author.appEvents.observe((appId, event) => {
|
|
226
|
+
post({ appId, name: event.name, data: event.data, seq: event.seq })
|
|
227
|
+
})
|
|
228
|
+
return () => {
|
|
229
|
+
stopHost()
|
|
230
|
+
stopAuthor()
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
async start() {
|
|
234
|
+
if (http !== undefined) return { port: http.port }
|
|
235
|
+
live.configure?.(brainConfig(policy))
|
|
236
|
+
const preferred = policy.hostPort
|
|
237
|
+
try {
|
|
238
|
+
await live.start()
|
|
239
|
+
started = true
|
|
240
|
+
const token = await readAuthoringToken(options.runtimeRoot)
|
|
241
|
+
const vendorDir = vendorOutputDir()
|
|
242
|
+
await ensureVendorFiles(vendorDir)
|
|
243
|
+
http = await startAuthorHttp({
|
|
244
|
+
author,
|
|
245
|
+
token,
|
|
246
|
+
port: preferred,
|
|
247
|
+
diagnostics: {
|
|
248
|
+
recordError: (appId, raw) => {
|
|
249
|
+
author.errors.record(appId, raw)
|
|
250
|
+
},
|
|
251
|
+
markAlive: (appId) => {
|
|
252
|
+
author.views.markAlive(appId)
|
|
253
|
+
},
|
|
254
|
+
markAbsent: (appId) => {
|
|
255
|
+
author.views.absent(appId)
|
|
256
|
+
},
|
|
257
|
+
answerView: (requestId, appId, raw) => {
|
|
258
|
+
return author.views.answer(requestId, appId, raw)
|
|
259
|
+
},
|
|
260
|
+
},
|
|
261
|
+
loopback: bindLoopback({
|
|
262
|
+
author,
|
|
263
|
+
owner,
|
|
264
|
+
registry,
|
|
265
|
+
mcp,
|
|
266
|
+
policy: () => policy,
|
|
267
|
+
writePolicy: async (raw) => {
|
|
268
|
+
const written = await writeHostPolicy(options.runtimeRoot, policy, raw)
|
|
269
|
+
policy = written.policy
|
|
270
|
+
return written
|
|
271
|
+
},
|
|
272
|
+
probe: id => probeBrain(id, providers, live.id),
|
|
273
|
+
providers,
|
|
274
|
+
token,
|
|
275
|
+
vendorDir,
|
|
276
|
+
runtimeRoot: options.runtimeRoot,
|
|
277
|
+
env: options.env ?? process.env,
|
|
278
|
+
credentials,
|
|
279
|
+
mcpFailures: readMcpFailures,
|
|
280
|
+
recordMcpFailures,
|
|
281
|
+
...options.mcpImports === undefined ? {} : { mcpImports: options.mcpImports },
|
|
282
|
+
...options.restart === undefined ? {} : { restart: options.restart },
|
|
283
|
+
...options.authorSkill === undefined ? {} : { authorSkill: options.authorSkill },
|
|
284
|
+
...options.authorMcp === undefined ? {} : { authorMcp: options.authorMcp },
|
|
285
|
+
...options.panel === undefined ? {} : {
|
|
286
|
+
panel: {
|
|
287
|
+
html: options.panel.html,
|
|
288
|
+
script: options.panel.script,
|
|
289
|
+
paint: async () => {
|
|
290
|
+
const painted = await resolveFirstPaint({
|
|
291
|
+
appDir: options.runtimeRoot,
|
|
292
|
+
pin: { kind: 'follow-host' },
|
|
293
|
+
hostPalette: policy.palette,
|
|
294
|
+
themesDir,
|
|
295
|
+
})
|
|
296
|
+
return { style: painted.style, appearance: policy.theme }
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
}),
|
|
301
|
+
})
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (started) await live.stop().catch(() => undefined)
|
|
304
|
+
started = false
|
|
305
|
+
http = undefined
|
|
306
|
+
throw await listenError(error, preferred)
|
|
307
|
+
}
|
|
308
|
+
return { port: http.port }
|
|
309
|
+
},
|
|
310
|
+
async dispose() {
|
|
311
|
+
const close = http?.close()
|
|
312
|
+
http = undefined
|
|
313
|
+
await close?.catch(() => undefined)
|
|
314
|
+
await author.settle()
|
|
315
|
+
if (started) await live.stop().catch(() => undefined)
|
|
316
|
+
started = false
|
|
317
|
+
await mcp.dispose()
|
|
318
|
+
await bash.dispose()
|
|
319
|
+
await pwsh.dispose()
|
|
320
|
+
author.dispose()
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function runnerDocument(
|
|
326
|
+
registry: { get: (appId: string) => Promise<{ id: string; directory: string }> },
|
|
327
|
+
themes: { readPin: (appId: string) => Promise<AppPin> },
|
|
328
|
+
hostPalette: string,
|
|
329
|
+
appearance: 'system' | 'light' | 'dark',
|
|
330
|
+
locale: string,
|
|
331
|
+
themesDir: string,
|
|
332
|
+
appId: string,
|
|
333
|
+
): Promise<string> {
|
|
334
|
+
const app = await registry.get(appId)
|
|
335
|
+
const paint = await resolveFirstPaint({
|
|
336
|
+
appDir: app.directory,
|
|
337
|
+
pin: await themes.readPin(app.id),
|
|
338
|
+
hostPalette,
|
|
339
|
+
themesDir,
|
|
340
|
+
})
|
|
341
|
+
return renderRunnerDocument({
|
|
342
|
+
appId: app.id,
|
|
343
|
+
entry: appResource(app.id, httpLayout.ui, httpLayout.entry),
|
|
344
|
+
style: paint.style,
|
|
345
|
+
appearance,
|
|
346
|
+
locale,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function listenError(error: unknown, port: number): Promise<unknown> {
|
|
351
|
+
if (!isCode(error, 'EADDRINUSE')) return error
|
|
352
|
+
const suggestedPort = await allocateHostPort(port + 1)
|
|
353
|
+
return new PortInUseError(port, suggestedPort)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function isCode(error: unknown, code: string): boolean {
|
|
357
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
|
358
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { builtinWorkbenchId, workbenchEntries, type AppListItem, type AppWorkbench } from '@mohou/contract'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backend desk for one workbench app.
|
|
5
|
+
* A rejected id does not write. `default` clears the stored id.
|
|
6
|
+
*/
|
|
7
|
+
export function createAppWorkbench(ports: {
|
|
8
|
+
listApps(): Promise<readonly AppListItem[]>
|
|
9
|
+
openApp(appId: string, title?: string): Promise<void>
|
|
10
|
+
readDefault(): string | undefined
|
|
11
|
+
writeDefault(id: string | undefined): Promise<void>
|
|
12
|
+
locale(): string
|
|
13
|
+
}): AppWorkbench {
|
|
14
|
+
return {
|
|
15
|
+
listApps: () => ports.listApps(),
|
|
16
|
+
openApp: (appId, title) => title === undefined ? ports.openApp(appId) : ports.openApp(appId, title),
|
|
17
|
+
async listWorkbenches() {
|
|
18
|
+
const apps = await ports.listApps()
|
|
19
|
+
return workbenchEntries(apps, ports.readDefault(), builtinName(ports.locale()))
|
|
20
|
+
},
|
|
21
|
+
async setDefaultWorkbench(id: string) {
|
|
22
|
+
if (id === builtinWorkbenchId) {
|
|
23
|
+
await ports.writeDefault(undefined)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
const apps = await ports.listApps()
|
|
27
|
+
const app = apps.find(item => item.id === id)
|
|
28
|
+
if (app === undefined || app.kind !== 'workbench') {
|
|
29
|
+
throw new Error('workbench is not available')
|
|
30
|
+
}
|
|
31
|
+
await ports.writeDefault(id)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function builtinName(locale: string): string {
|
|
37
|
+
return locale === 'zh-CN' ? '小程序库' : 'Library'
|
|
38
|
+
}
|
package/src/http/app.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Hono } from 'hono'
|
|
2
|
+
|
|
3
|
+
import { type createAuthorTools } from '../tools/author.ts'
|
|
4
|
+
import type { DiagnosticPorts } from '../tools/diagnostics.ts'
|
|
5
|
+
import { isLoopbackAddress } from './guard.ts'
|
|
6
|
+
import { mountAuthor } from './author.ts'
|
|
7
|
+
import type { HostEnv } from './env.ts'
|
|
8
|
+
import { mountIframe } from './iframe.ts'
|
|
9
|
+
import { mountOwner } from './owner.ts'
|
|
10
|
+
import type { LoopbackPorts } from './ports.ts'
|
|
11
|
+
|
|
12
|
+
type AuthorTools = ReturnType<typeof createAuthorTools>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One loopback app. Author, owner, and iframe are separate mounts.
|
|
16
|
+
* @param options - the tool implementation, the token, and the owner ports when the session has them
|
|
17
|
+
*/
|
|
18
|
+
export function createLoopbackApp(options: {
|
|
19
|
+
readonly author: AuthorTools
|
|
20
|
+
readonly token: string
|
|
21
|
+
readonly diagnostics?: DiagnosticPorts
|
|
22
|
+
readonly loopback?: LoopbackPorts
|
|
23
|
+
}): Hono<HostEnv> {
|
|
24
|
+
const app = new Hono<HostEnv>()
|
|
25
|
+
app.use('*', async (c, next) => {
|
|
26
|
+
const address = c.env.incoming.socket.remoteAddress
|
|
27
|
+
if (!isLoopbackAddress(address)) {
|
|
28
|
+
return c.json({ ok: false, error: { code: 'authoring-loopback', message: 'authoring caller is not loopback' } }, 403)
|
|
29
|
+
}
|
|
30
|
+
await next()
|
|
31
|
+
})
|
|
32
|
+
mountIframe(app, {
|
|
33
|
+
...options.diagnostics === undefined ? {} : { diagnostics: options.diagnostics },
|
|
34
|
+
...options.loopback === undefined ? {} : { loopback: options.loopback },
|
|
35
|
+
})
|
|
36
|
+
if (options.loopback !== undefined) mountOwner(app, options.loopback)
|
|
37
|
+
mountAuthor(app, options.author, options.token)
|
|
38
|
+
app.notFound((c) => {
|
|
39
|
+
const authorRoute = c.req.path === '/mcp' || c.req.path.startsWith('/api/tools')
|
|
40
|
+
if (authorRoute) {
|
|
41
|
+
return c.json({ ok: false, error: { code: 'unknown-tool', message: 'authoring route is not mounted' } }, 404)
|
|
42
|
+
}
|
|
43
|
+
return c.json({ ok: false, error: { code: 'not-found', message: 'route is not mounted' } }, 404)
|
|
44
|
+
})
|
|
45
|
+
return app
|
|
46
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { lstat, readFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { appTrees } from '@mohou/contract'
|
|
5
|
+
|
|
6
|
+
import { admitAssetInner, assetMediaType } from '../compile/asset-path.ts'
|
|
7
|
+
import { RouteError } from './route-codes.ts'
|
|
8
|
+
|
|
9
|
+
export interface AppAsset {
|
|
10
|
+
readonly bytes: Uint8Array<ArrayBuffer>
|
|
11
|
+
readonly type: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read one file under `assets/`. Missing, escaped, or illegal paths are `not-found`.
|
|
16
|
+
* @param appDir - absolute app directory
|
|
17
|
+
* @param rest - path after `/assets/`
|
|
18
|
+
*/
|
|
19
|
+
export async function readAppAsset(appDir: string, rest: string): Promise<AppAsset> {
|
|
20
|
+
let inner: string
|
|
21
|
+
try {
|
|
22
|
+
inner = admitAssetInner(rest)
|
|
23
|
+
} catch (error) {
|
|
24
|
+
throw missing(error)
|
|
25
|
+
}
|
|
26
|
+
let root: string
|
|
27
|
+
try {
|
|
28
|
+
root = await realpath(path.join(appDir, appTrees.assets))
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw missing(error)
|
|
31
|
+
}
|
|
32
|
+
let real: string
|
|
33
|
+
try {
|
|
34
|
+
real = await realpath(path.resolve(root, inner))
|
|
35
|
+
} catch (error) {
|
|
36
|
+
throw missing(error)
|
|
37
|
+
}
|
|
38
|
+
const rel = path.relative(root, real)
|
|
39
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) throw missing()
|
|
40
|
+
const info = await lstat(real).catch(() => undefined)
|
|
41
|
+
if (info === undefined || !info.isFile()) throw missing()
|
|
42
|
+
return { bytes: new Uint8Array(await readFile(real)), type: assetMediaType(inner) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function missing(cause?: unknown): RouteError {
|
|
46
|
+
return cause === undefined
|
|
47
|
+
? new RouteError('not-found', 'asset is missing')
|
|
48
|
+
: new RouteError('not-found', 'asset is missing', { cause })
|
|
49
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import type { Context, Hono } from 'hono'
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
5
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
7
|
+
|
|
8
|
+
import { isAuthorMcpTool, type createAuthorTools } from '../tools/author.ts'
|
|
9
|
+
import { AuthorError } from '../tools/codes.ts'
|
|
10
|
+
import { authorMcpToolList, authorToolList } from '../tools/schemas.ts'
|
|
11
|
+
import type { HostEnv } from './env.ts'
|
|
12
|
+
import { authoringTokenMatches } from './guard.ts'
|
|
13
|
+
import { httpLayout } from './layout.ts'
|
|
14
|
+
|
|
15
|
+
type AuthorTools = ReturnType<typeof createAuthorTools>
|
|
16
|
+
|
|
17
|
+
/** Authoring routes. Every one requires the token. */
|
|
18
|
+
export function mountAuthor(app: Hono<HostEnv>, author: AuthorTools, token: string): void {
|
|
19
|
+
app.get(httpLayout.tools, (c) => {
|
|
20
|
+
const denied = rejectToken(c, token)
|
|
21
|
+
if (denied !== undefined) return denied
|
|
22
|
+
return c.json({ tools: authorToolList() })
|
|
23
|
+
})
|
|
24
|
+
app.post(httpLayout.invoke, async (c) => {
|
|
25
|
+
const denied = rejectToken(c, token)
|
|
26
|
+
if (denied !== undefined) return denied
|
|
27
|
+
try {
|
|
28
|
+
const body = record(await c.req.json())
|
|
29
|
+
const result = await author.invoke(requiredString(body, 'name'), body.arguments ?? {})
|
|
30
|
+
return c.json({ ok: true, result })
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
|
|
33
|
+
? error.code
|
|
34
|
+
: 'tool-args'
|
|
35
|
+
const message = error instanceof Error ? error.message : 'tool failed'
|
|
36
|
+
return c.json({ ok: false, error: { code, message } }, 400)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
app.all(httpLayout.mcp, async (c) => {
|
|
40
|
+
const denied = rejectToken(c, token)
|
|
41
|
+
if (denied !== undefined) return denied
|
|
42
|
+
if (c.req.method !== 'POST' && c.req.method !== 'GET' && c.req.method !== 'DELETE') {
|
|
43
|
+
return c.json({ ok: false, error: { code: 'unknown-tool', message: 'MCP method is not part of the session lifecycle' } }, 405)
|
|
44
|
+
}
|
|
45
|
+
return handleMcp(author, c.req.raw)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rejectToken(c: Context, token: string): Response | undefined {
|
|
50
|
+
const header = c.req.header('authorization')
|
|
51
|
+
const presented = header?.startsWith('Bearer ') === true ? header.slice('Bearer '.length) : undefined
|
|
52
|
+
if (presented === '' || !authoringTokenMatches(token, presented)) {
|
|
53
|
+
return c.json({ ok: false, error: { code: 'authoring-token', message: 'authoring token is missing or wrong' } }, 401)
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type McpSession = {
|
|
59
|
+
server: McpServer
|
|
60
|
+
transport: WebStandardStreamableHTTPServerTransport
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const mcpSessions = new Map<string, McpSession>()
|
|
64
|
+
|
|
65
|
+
/** Close every stored MCP session. The HTTP listener calls this on close. */
|
|
66
|
+
export async function closeMcpSessions(): Promise<void> {
|
|
67
|
+
const open = [...mcpSessions.values()]
|
|
68
|
+
mcpSessions.clear()
|
|
69
|
+
await Promise.all(open.map(async (session) => {
|
|
70
|
+
await session.transport.close()
|
|
71
|
+
await session.server.close()
|
|
72
|
+
}))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function handleMcp(author: AuthorTools, request: Request): Promise<Response> {
|
|
76
|
+
const sessionId = request.headers.get('mcp-session-id')
|
|
77
|
+
if (sessionId !== null && sessionId.length > 0) {
|
|
78
|
+
const found = mcpSessions.get(sessionId)
|
|
79
|
+
if (found === undefined) {
|
|
80
|
+
return Response.json({ ok: false, error: { code: 'unknown-tool', message: 'MCP session is not open' } }, { status: 404 })
|
|
81
|
+
}
|
|
82
|
+
const response = await found.transport.handleRequest(request)
|
|
83
|
+
if (request.method === 'DELETE') {
|
|
84
|
+
mcpSessions.delete(sessionId)
|
|
85
|
+
await found.transport.close()
|
|
86
|
+
await found.server.close()
|
|
87
|
+
}
|
|
88
|
+
return response
|
|
89
|
+
}
|
|
90
|
+
if (request.method !== 'POST') {
|
|
91
|
+
return Response.json({ ok: false, error: { code: 'unknown-tool', message: 'MCP session is not open' } }, { status: 404 })
|
|
92
|
+
}
|
|
93
|
+
const stateful = wantsSession(request)
|
|
94
|
+
const session = openMcpSession(author, stateful)
|
|
95
|
+
await session.server.connect(session.transport)
|
|
96
|
+
const incoming = stateful ? withJsonAccept(request) : request
|
|
97
|
+
const response = await session.transport.handleRequest(incoming)
|
|
98
|
+
const opened = response.headers.get('mcp-session-id')
|
|
99
|
+
if (opened !== null && opened.length > 0) mcpSessions.set(opened, session)
|
|
100
|
+
else {
|
|
101
|
+
await session.transport.close()
|
|
102
|
+
await session.server.close()
|
|
103
|
+
}
|
|
104
|
+
return response
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** SSE-only Accept opens a session. A POST that also lists JSON stays stateless. */
|
|
108
|
+
function wantsSession(request: Request): boolean {
|
|
109
|
+
const accept = request.headers.get('accept') ?? ''
|
|
110
|
+
return accept.includes('text/event-stream') && !accept.includes('application/json')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The SDK POST handler requires both Accept types. Session open still uses SSE. */
|
|
114
|
+
function withJsonAccept(request: Request): Request {
|
|
115
|
+
const headers = new Headers(request.headers)
|
|
116
|
+
const accept = headers.get('accept') ?? ''
|
|
117
|
+
headers.set('accept', accept.length === 0 ? 'application/json, text/event-stream' : `application/json, ${accept}`)
|
|
118
|
+
return new Request(request, { headers })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function openMcpSession(author: AuthorTools, stateful: boolean): McpSession {
|
|
122
|
+
const server = new McpServer({ name: 'mini-app', version: '0.0.0' }, { capabilities: { tools: {} } })
|
|
123
|
+
server.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: authorMcpToolList() }))
|
|
124
|
+
server.server.setRequestHandler(CallToolRequestSchema, async (call) => {
|
|
125
|
+
try {
|
|
126
|
+
if (!isAuthorMcpTool(call.params.name)) {
|
|
127
|
+
throw new AuthorError('unknown-tool', `unknown tool: ${call.params.name}. Catalog: ${authorMcpToolList().map(tool => tool.name).join(', ')}`)
|
|
128
|
+
}
|
|
129
|
+
const result = await author.invoke(call.params.name, call.params.arguments ?? {})
|
|
130
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
|
|
131
|
+
} catch (error) {
|
|
132
|
+
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
|
|
133
|
+
? error.code
|
|
134
|
+
: 'tool-args'
|
|
135
|
+
const message = error instanceof Error ? error.message : 'tool failed'
|
|
136
|
+
return { isError: true, content: [{ type: 'text', text: JSON.stringify({ code, message }) }] }
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
140
|
+
enableJsonResponse: !stateful,
|
|
141
|
+
...stateful ? { sessionIdGenerator: () => randomUUID() } : {},
|
|
142
|
+
})
|
|
143
|
+
return { server, transport }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function record(value: unknown): Record<string, unknown> {
|
|
147
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
148
|
+
throw new AuthorError('tool-args', 'tool arguments must be an object')
|
|
149
|
+
}
|
|
150
|
+
return value as Record<string, unknown>
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function requiredString(args: Record<string, unknown>, key: string): string {
|
|
154
|
+
const value = args[key]
|
|
155
|
+
if (typeof value !== 'string' || value.length === 0) throw new AuthorError('tool-args', `${key} is required`)
|
|
156
|
+
return value
|
|
157
|
+
}
|