@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.
Files changed (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. 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
+ }
@@ -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
+ }