@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
package/src/http/bind.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import type { McpClient } from '@mohou/mcp-client'
|
|
2
|
+
import type { RuntimeProvider } from '@mohou/runtime-provider'
|
|
3
|
+
|
|
4
|
+
import type { CredentialProvider } from '../credentials/provider.ts'
|
|
5
|
+
|
|
6
|
+
import { listedApp, type AppSummary } from '../apps/registry.ts'
|
|
7
|
+
import { readActivity } from '../host/activity.ts'
|
|
8
|
+
import { historyBounds } from '../history/store.ts'
|
|
9
|
+
import { cachedUiBundle } from '../compile/autogen.ts'
|
|
10
|
+
import { forwardFrames } from '../events/app-events.ts'
|
|
11
|
+
import { compileAppStylesheet } from '../compile/sheet.ts'
|
|
12
|
+
import { readAppAsset } from './asset.ts'
|
|
13
|
+
import { httpLayout } from './layout.ts'
|
|
14
|
+
import { readVendorFile } from './ports.ts'
|
|
15
|
+
import type { LoopbackPorts } from './ports.ts'
|
|
16
|
+
import { authoringTokenMatches } from './guard.ts'
|
|
17
|
+
import { probeBrain, type HostPolicy } from '../host/config.ts'
|
|
18
|
+
import { checkPackageUpdate, discardUpdateResult, readUpdateResult, stagePackageUpdate } from './updates.ts'
|
|
19
|
+
import { McpError } from '@mohou/mcp-client'
|
|
20
|
+
|
|
21
|
+
import { admitMcpText } from '../host/mcp-import.ts'
|
|
22
|
+
import { CredentialError } from '../credentials/codes.ts'
|
|
23
|
+
import { mcpReferenceSources, type McpLoadFailure } from '../host/mcp.ts'
|
|
24
|
+
import { checkMcpEditor, readMcpEditor, readMcpImport, writeMcpEditor } from '../host/mcp-editor.ts'
|
|
25
|
+
import { readAuthorMcp, revealAuthorMcp, writeAuthorMcp, type AuthorMcpLayout } from '../host/author-mcp.ts'
|
|
26
|
+
import { readAuthorSkill, revealAuthorSkill, writeAuthorSkill, type AuthorSkillLayout } from '../host/author-skill.ts'
|
|
27
|
+
import type { createAuthorTools } from '../tools/author.ts'
|
|
28
|
+
import type { createOwnerReads } from '../owner/read.ts'
|
|
29
|
+
import type { createThemePins } from '../theme/pin.ts'
|
|
30
|
+
|
|
31
|
+
type Author = ReturnType<typeof createAuthorTools>
|
|
32
|
+
|
|
33
|
+
interface OwnerSlice {
|
|
34
|
+
reloadView: (appId: string) => Promise<void>
|
|
35
|
+
runnerDocument: (appId: string) => Promise<string>
|
|
36
|
+
readHistory: ReturnType<typeof createOwnerReads>['readHistory']
|
|
37
|
+
readCommit: ReturnType<typeof createOwnerReads>['readCommit']
|
|
38
|
+
readStorage: ReturnType<typeof createOwnerReads>['readStorage']
|
|
39
|
+
readTable: ReturnType<typeof createOwnerReads>['readTable']
|
|
40
|
+
themes: ReturnType<typeof createThemePins>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RegistrySlice {
|
|
44
|
+
get: (appId: string) => Promise<AppSummary>
|
|
45
|
+
list: () => Promise<{ apps: AppSummary[] }>
|
|
46
|
+
listTrash: () => Promise<AppSummary[]>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Project the live session onto the loopback routes. Does not open a socket.
|
|
51
|
+
* @param input - the session pieces the routes call
|
|
52
|
+
*/
|
|
53
|
+
export function bindLoopback(input: {
|
|
54
|
+
readonly author: Author
|
|
55
|
+
readonly owner: OwnerSlice
|
|
56
|
+
readonly registry: RegistrySlice
|
|
57
|
+
/** Live MCP sessions. A written server file is handed to this client, so it takes effect now. */
|
|
58
|
+
readonly mcp: McpClient
|
|
59
|
+
readonly policy: () => HostPolicy
|
|
60
|
+
readonly writePolicy: (raw: unknown) => Promise<{ policy: HostPolicy; restartRequired: boolean }>
|
|
61
|
+
readonly probe: (id: string) => ReturnType<typeof probeBrain>
|
|
62
|
+
readonly providers: readonly RuntimeProvider[]
|
|
63
|
+
readonly token: string
|
|
64
|
+
readonly vendorDir: string
|
|
65
|
+
readonly runtimeRoot: string
|
|
66
|
+
readonly env: NodeJS.ProcessEnv
|
|
67
|
+
/** The read port Shell injected. Only names and descriptions reach the panel surface. */
|
|
68
|
+
readonly credentials: CredentialProvider
|
|
69
|
+
/** Servers the live client left out: boot's list, replaced by every write. */
|
|
70
|
+
readonly mcpFailures: () => readonly McpLoadFailure[]
|
|
71
|
+
readonly recordMcpFailures: (failures: readonly McpLoadFailure[]) => void
|
|
72
|
+
readonly mcpImports?: Readonly<Record<string, string>>
|
|
73
|
+
readonly panel?: LoopbackPorts['panel']
|
|
74
|
+
readonly restart?: () => Promise<void>
|
|
75
|
+
readonly authorSkill?: AuthorSkillLayout
|
|
76
|
+
readonly authorMcp?: AuthorMcpLayout
|
|
77
|
+
}): LoopbackPorts {
|
|
78
|
+
const skill = input.authorSkill
|
|
79
|
+
const mcpAgents = input.authorMcp
|
|
80
|
+
const authoringLive = (description: string) => ({
|
|
81
|
+
url: `http://127.0.0.1:${input.policy().hostPort}${httpLayout.mcp}`,
|
|
82
|
+
token: input.token,
|
|
83
|
+
description,
|
|
84
|
+
})
|
|
85
|
+
return {
|
|
86
|
+
list: async () => {
|
|
87
|
+
const listed = await input.registry.list()
|
|
88
|
+
const activity = readActivity(input.runtimeRoot)
|
|
89
|
+
return Promise.all(listed.apps.map(async app => ({
|
|
90
|
+
...listedApp(app, await historyBounds(app.directory), activity.apps[app.id]),
|
|
91
|
+
pin: await input.owner.themes.readPin(app.id),
|
|
92
|
+
appFile: await input.owner.themes.appFile(app.id),
|
|
93
|
+
})))
|
|
94
|
+
},
|
|
95
|
+
listTrash: async () => {
|
|
96
|
+
const trashed = await input.registry.listTrash()
|
|
97
|
+
const activity = readActivity(input.runtimeRoot)
|
|
98
|
+
return Promise.all(trashed.map(async app => listedApp(app, await historyBounds(app.directory), activity.apps[app.id])))
|
|
99
|
+
},
|
|
100
|
+
deleteApp: appId => input.author.deleteApp(appId),
|
|
101
|
+
undeleteApp: appId => input.author.undeleteApp(appId),
|
|
102
|
+
open: (appId, title) => input.author.invoke('mini_app_open', {
|
|
103
|
+
appId,
|
|
104
|
+
...title === undefined ? {} : { title },
|
|
105
|
+
}),
|
|
106
|
+
reload: appId => input.owner.reloadView(appId),
|
|
107
|
+
call: (appId, method, args, observe) => {
|
|
108
|
+
if (observe !== undefined) return input.author.callObserved(appId, method, args, observe)
|
|
109
|
+
return input.author.invoke('mini_app_call', {
|
|
110
|
+
appId,
|
|
111
|
+
method,
|
|
112
|
+
...args === undefined ? {} : { args },
|
|
113
|
+
})
|
|
114
|
+
},
|
|
115
|
+
readPolicy: () => input.policy(),
|
|
116
|
+
writePolicy: raw => input.writePolicy(raw),
|
|
117
|
+
probe: id => input.probe(id),
|
|
118
|
+
providers: input.providers,
|
|
119
|
+
listPalettes: () => input.owner.themes.listPalettes(),
|
|
120
|
+
readPin: appId => input.owner.themes.readPin(appId),
|
|
121
|
+
setPin: (appId, pin) => input.owner.themes.setPin(appId, pin),
|
|
122
|
+
appFile: appId => input.owner.themes.appFile(appId),
|
|
123
|
+
readAppTheme: appId => input.owner.themes.readAppTheme(appId),
|
|
124
|
+
readHistory: appId => input.owner.readHistory(appId),
|
|
125
|
+
readCommit: (appId, commitId) => input.owner.readCommit(appId, commitId),
|
|
126
|
+
readStorage: appId => input.owner.readStorage(appId),
|
|
127
|
+
readTable: (appId, table) => input.owner.readTable(appId, table),
|
|
128
|
+
runnerDocument: appId => input.owner.runnerDocument(appId),
|
|
129
|
+
entryScript: async (appId) => {
|
|
130
|
+
const app = await input.registry.get(appId)
|
|
131
|
+
return cachedUiBundle(app.directory)
|
|
132
|
+
},
|
|
133
|
+
stylesheet: async (appId) => {
|
|
134
|
+
const app = await input.registry.get(appId)
|
|
135
|
+
return compileAppStylesheet(app.directory)
|
|
136
|
+
},
|
|
137
|
+
assetFile: async (appId, rest) => {
|
|
138
|
+
const app = await input.registry.get(appId)
|
|
139
|
+
return readAppAsset(app.directory, rest)
|
|
140
|
+
},
|
|
141
|
+
restoreStorage: appId => input.author.restoreStorage(appId),
|
|
142
|
+
readErrors: appId => input.author.errors.read(appId),
|
|
143
|
+
vendorFile: name => readVendorFile(input.vendorDir, name),
|
|
144
|
+
...input.panel === undefined ? {} : { panel: input.panel },
|
|
145
|
+
authoringToken: input.token,
|
|
146
|
+
authorized: header => authoringTokenMatches(input.token, bearer(header)),
|
|
147
|
+
subscribeHost: listener => input.author.hostEvents.subscribe(listener),
|
|
148
|
+
subscribeFrames: listener => forwardFrames(input.author.appEvents, listener),
|
|
149
|
+
subscribeApp: (appId, since, listener) => input.author.appEvents.subscribe(appId, since, listener),
|
|
150
|
+
checkUpdate: async () => {
|
|
151
|
+
const attempt = readUpdateResult(input.runtimeRoot)
|
|
152
|
+
return {
|
|
153
|
+
...await checkPackageUpdate(),
|
|
154
|
+
...attempt === undefined ? {} : { lastAttempt: attempt },
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
installUpdate: (version) => {
|
|
158
|
+
stagePackageUpdate(version)
|
|
159
|
+
if (input.restart === undefined) return Promise.resolve()
|
|
160
|
+
const restart = input.restart
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
void restart()
|
|
163
|
+
}, 200)
|
|
164
|
+
return Promise.resolve()
|
|
165
|
+
},
|
|
166
|
+
ackUpdate: (at) => {
|
|
167
|
+
discardUpdateResult(input.runtimeRoot, at)
|
|
168
|
+
return Promise.resolve()
|
|
169
|
+
},
|
|
170
|
+
...input.restart === undefined ? {} : { restart: input.restart },
|
|
171
|
+
...skill === undefined ? {} : {
|
|
172
|
+
authorSkill: skill,
|
|
173
|
+
readAuthorSkill: customDirs => readAuthorSkill(skill, customDirs ?? []),
|
|
174
|
+
writeAuthorSkill: (agentIds, customDirs) => writeAuthorSkill(skill, agentIds, customDirs),
|
|
175
|
+
revealAuthorSkill: dest => revealAuthorSkill(skill, dest),
|
|
176
|
+
},
|
|
177
|
+
...mcpAgents === undefined ? {} : {
|
|
178
|
+
authorMcp: mcpAgents,
|
|
179
|
+
readAuthorMcp: () => readAuthorMcp(mcpAgents, authoringLive('')),
|
|
180
|
+
writeAuthorMcp: (agentIds, description) => writeAuthorMcp(mcpAgents, agentIds, authoringLive(description)),
|
|
181
|
+
revealAuthorMcp: dest => revealAuthorMcp(mcpAgents, dest),
|
|
182
|
+
},
|
|
183
|
+
readMcp: async () => {
|
|
184
|
+
return { servers: await readMcpEditor(input.runtimeRoot, input.env), unresolved: input.mcpFailures() }
|
|
185
|
+
},
|
|
186
|
+
writeMcp: async (servers) => {
|
|
187
|
+
const resolved = await writeMcpEditor(input.runtimeRoot, servers, input.env, mcpReferenceSources(input.env, input.credentials))
|
|
188
|
+
await input.mcp.setServers(resolved.servers)
|
|
189
|
+
input.recordMcpFailures(resolved.failures)
|
|
190
|
+
return { unresolved: resolved.failures }
|
|
191
|
+
},
|
|
192
|
+
checkMcp: server => checkMcpEditor(server, input.env, mcpReferenceSources(input.env, input.credentials)),
|
|
193
|
+
admitMcp: text => admitMcpText(text),
|
|
194
|
+
importMcp: (source) => {
|
|
195
|
+
const file = input.mcpImports?.[source]
|
|
196
|
+
if (file === undefined) return Promise.reject(new McpError('config-invalid', `mcp import is unknown: ${source}`))
|
|
197
|
+
return readMcpImport(file)
|
|
198
|
+
},
|
|
199
|
+
readCredentials: async () => ({ credentials: await input.credentials.list(), writable: input.credentials.writable }),
|
|
200
|
+
putCredential: async (name, description, secret) => {
|
|
201
|
+
// A blank field means "keep the stored secret". Host resolves it here: the panel never reads a
|
|
202
|
+
// secret back, so it has no value to send, and the store stays a store with one write op.
|
|
203
|
+
const keep = secret === undefined || secret.length === 0 ? await input.credentials.get(name) : secret
|
|
204
|
+
if (keep === undefined || keep.length === 0) {
|
|
205
|
+
throw new CredentialError('credential-invalid', `credential has no secret to keep: ${name}`)
|
|
206
|
+
}
|
|
207
|
+
await input.credentials.put(name, description, keep)
|
|
208
|
+
},
|
|
209
|
+
removeCredential: name => input.credentials.remove(name),
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function bearer(header: string | undefined): string | undefined {
|
|
214
|
+
if (header === undefined || !header.startsWith('Bearer ')) return undefined
|
|
215
|
+
const token = header.slice('Bearer '.length)
|
|
216
|
+
return token.length === 0 ? undefined : token
|
|
217
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { AppHttpRequest, AppHttpResponse } from '@mohou/contract'
|
|
2
|
+
|
|
3
|
+
import { present } from '../kernel/present.ts'
|
|
4
|
+
import { HttpError } from './codes.ts'
|
|
5
|
+
|
|
6
|
+
/** Injected bounds. The numbers are host policy and are not locked here. */
|
|
7
|
+
export interface HttpPolicy {
|
|
8
|
+
readonly timeoutMs: {
|
|
9
|
+
readonly min: number
|
|
10
|
+
readonly max: number
|
|
11
|
+
readonly default: number
|
|
12
|
+
}
|
|
13
|
+
readonly maxBodyBytes: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type HttpFn = (url: string | AppHttpRequest, opts?: Omit<AppHttpRequest, 'url'>) => Promise<AppHttpResponse>
|
|
17
|
+
|
|
18
|
+
/** Identifies our timer so a caller abort is not reported as a timeout. */
|
|
19
|
+
class TimeoutReason extends Error {
|
|
20
|
+
readonly code = 'http-timeout'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param timeoutMs - the deadline that fired
|
|
24
|
+
*/
|
|
25
|
+
constructor(timeoutMs: number) {
|
|
26
|
+
super(`request timed out after ${timeoutMs}ms`)
|
|
27
|
+
this.name = 'TimeoutReason'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build `ctx.http` for one call. 4xx and 5xx stay in the result.
|
|
33
|
+
* Policy failures throw before the socket opens.
|
|
34
|
+
* @param policy - resolved host bounds
|
|
35
|
+
* @param callSignal - this call's signal, aborted with any request signal
|
|
36
|
+
*/
|
|
37
|
+
export function createHttp(policy: HttpPolicy, callSignal?: AbortSignal): HttpFn {
|
|
38
|
+
return (url, opts) => request(policy, callSignal, url, opts)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function request(
|
|
42
|
+
policy: HttpPolicy,
|
|
43
|
+
callSignal: AbortSignal | undefined,
|
|
44
|
+
url: string | AppHttpRequest,
|
|
45
|
+
opts?: Omit<AppHttpRequest, 'url'>,
|
|
46
|
+
): Promise<AppHttpResponse> {
|
|
47
|
+
const input = typeof url === 'string' ? { url, ...opts } : url
|
|
48
|
+
const target = parseUrl(input)
|
|
49
|
+
const timeoutMs = resolveTimeout(policy, input.timeout)
|
|
50
|
+
const body = encodeBody(input.body, input.headers, policy.maxBodyBytes)
|
|
51
|
+
const timer = new AbortController()
|
|
52
|
+
const timerId = setTimeout(() => {
|
|
53
|
+
timer.abort(new TimeoutReason(timeoutMs))
|
|
54
|
+
}, timeoutMs)
|
|
55
|
+
const upstream = [callSignal, input.signal].filter((item): item is AbortSignal => item !== undefined)
|
|
56
|
+
const signal = AbortSignal.any([timer.signal, ...upstream])
|
|
57
|
+
try {
|
|
58
|
+
const response = await fetch(target, {
|
|
59
|
+
signal,
|
|
60
|
+
...present('method', input.method),
|
|
61
|
+
...present('headers', body.headers),
|
|
62
|
+
...present('body', body.payload),
|
|
63
|
+
})
|
|
64
|
+
return await readResponse(response, policy.maxBodyBytes, signal)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw classify(error, signal)
|
|
67
|
+
} finally {
|
|
68
|
+
clearTimeout(timerId)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseUrl(input: AppHttpRequest): URL {
|
|
73
|
+
let target: URL
|
|
74
|
+
try {
|
|
75
|
+
target = new URL(input.url)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
throw new HttpError('http-scheme', 'URL is not http or https', { cause: error })
|
|
78
|
+
}
|
|
79
|
+
if (target.protocol !== 'http:' && target.protocol !== 'https:') {
|
|
80
|
+
throw new HttpError('http-scheme', 'URL is not http or https')
|
|
81
|
+
}
|
|
82
|
+
for (const [key, value] of Object.entries(input.query ?? {})) {
|
|
83
|
+
if (value === undefined) continue
|
|
84
|
+
target.searchParams.append(key, String(value))
|
|
85
|
+
}
|
|
86
|
+
return target
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveTimeout(policy: HttpPolicy, timeout: number | undefined): number {
|
|
90
|
+
const resolved = timeout ?? policy.timeoutMs.default
|
|
91
|
+
if (resolved < policy.timeoutMs.min || resolved > policy.timeoutMs.max) {
|
|
92
|
+
throw new HttpError('http-policy', 'timeout is outside the host policy')
|
|
93
|
+
}
|
|
94
|
+
return resolved
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function encodeBody(
|
|
98
|
+
body: unknown,
|
|
99
|
+
headers: Record<string, string> | undefined,
|
|
100
|
+
maxBodyBytes: number,
|
|
101
|
+
): { payload?: string; headers?: Record<string, string> } {
|
|
102
|
+
if (body === undefined) return headers === undefined ? {} : { headers }
|
|
103
|
+
if (typeof body === 'function' || typeof body === 'symbol') {
|
|
104
|
+
throw new HttpError('http-policy', 'request body is not admissible')
|
|
105
|
+
}
|
|
106
|
+
let payload: string
|
|
107
|
+
try {
|
|
108
|
+
payload = typeof body === 'string' ? body : JSON.stringify(body)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new HttpError('http-policy', 'request body is not admissible', { cause: error })
|
|
111
|
+
}
|
|
112
|
+
if (Buffer.byteLength(payload) > maxBodyBytes) {
|
|
113
|
+
throw new HttpError('http-too-large', 'request body exceeds the host cap')
|
|
114
|
+
}
|
|
115
|
+
if (typeof body === 'string') return headers === undefined ? { payload } : { payload, headers }
|
|
116
|
+
return {
|
|
117
|
+
payload,
|
|
118
|
+
headers: { ...headers, 'content-type': headers?.['content-type'] ?? 'application/json' },
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readResponse(response: Response, maxBodyBytes: number, signal: AbortSignal): Promise<AppHttpResponse> {
|
|
123
|
+
const declared = Number(response.headers.get('content-length'))
|
|
124
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes) {
|
|
125
|
+
await response.body?.cancel()
|
|
126
|
+
throw new HttpError('http-too-large', 'response body exceeds the host cap')
|
|
127
|
+
}
|
|
128
|
+
const text = await readText(response, maxBodyBytes, signal)
|
|
129
|
+
const headers: Record<string, string> = {}
|
|
130
|
+
response.headers.forEach((value, key) => {
|
|
131
|
+
headers[key] = value
|
|
132
|
+
})
|
|
133
|
+
const type = response.headers.get('content-type') ?? ''
|
|
134
|
+
let json: unknown = null
|
|
135
|
+
if (type.includes('json')) {
|
|
136
|
+
try {
|
|
137
|
+
json = JSON.parse(text) as unknown
|
|
138
|
+
} catch {
|
|
139
|
+
json = null
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { ok: response.ok, status: response.status, headers, text, json }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readText(response: Response, maxBodyBytes: number, signal: AbortSignal): Promise<string> {
|
|
146
|
+
if (response.body === null) return ''
|
|
147
|
+
const reader = response.body.getReader()
|
|
148
|
+
const chunks: Uint8Array[] = []
|
|
149
|
+
let total = 0
|
|
150
|
+
try {
|
|
151
|
+
for (;;) {
|
|
152
|
+
const { done, value } = await reader.read()
|
|
153
|
+
if (done) break
|
|
154
|
+
total += value.byteLength
|
|
155
|
+
if (total > maxBodyBytes) {
|
|
156
|
+
throw new HttpError('http-too-large', 'response body exceeds the host cap')
|
|
157
|
+
}
|
|
158
|
+
chunks.push(value)
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw classify(error, signal)
|
|
162
|
+
} finally {
|
|
163
|
+
await reader.cancel().catch(() => undefined)
|
|
164
|
+
}
|
|
165
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function classify(error: unknown, signal: AbortSignal): HttpError {
|
|
169
|
+
if (error instanceof HttpError) return error
|
|
170
|
+
if (signal.reason instanceof TimeoutReason) {
|
|
171
|
+
return new HttpError('http-timeout', 'request timed out', { cause: error })
|
|
172
|
+
}
|
|
173
|
+
if (signal.aborted) return new HttpError('cancelled', 'request was cancelled', { cause: error })
|
|
174
|
+
return new HttpError('http-network', 'request failed', { cause: error })
|
|
175
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Codes the HTTP call emits. */
|
|
2
|
+
|
|
3
|
+
export const httpCodes = [
|
|
4
|
+
'http-timeout',
|
|
5
|
+
'http-scheme',
|
|
6
|
+
'http-too-large',
|
|
7
|
+
'http-network',
|
|
8
|
+
'http-policy',
|
|
9
|
+
'cancelled',
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
/** An HTTP failure. Callers match `code`. */
|
|
13
|
+
export type HttpCode = (typeof httpCodes)[number]
|
|
14
|
+
|
|
15
|
+
/** Failure from `ctx.http`. The message is for a person. */
|
|
16
|
+
export class HttpError extends Error {
|
|
17
|
+
readonly code: HttpCode
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param code - one of {@link httpCodes}
|
|
21
|
+
* @param message - human text; not the match key
|
|
22
|
+
* @param options - optional `cause`
|
|
23
|
+
*/
|
|
24
|
+
constructor(code: HttpCode, message: string, options?: { cause?: unknown }) {
|
|
25
|
+
super(message, options)
|
|
26
|
+
this.name = 'HttpError'
|
|
27
|
+
this.code = code
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/http/env.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
/** A caller is loopback only for these addresses. A missing address is refused. */
|
|
4
|
+
export function isLoopbackAddress(address: string | undefined): boolean {
|
|
5
|
+
if (address === undefined || address.length === 0) return false
|
|
6
|
+
const host = address.replace(/^::ffff:/, '')
|
|
7
|
+
return host === '127.0.0.1' || host === '::1' || host === 'localhost'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Compare a presented token without leaking the length in the match. */
|
|
11
|
+
export function authoringTokenMatches(expected: string, presented: string | undefined): boolean {
|
|
12
|
+
if (presented === undefined) return false
|
|
13
|
+
const left = Buffer.from(expected)
|
|
14
|
+
const right = Buffer.from(presented)
|
|
15
|
+
if (left.length !== right.length) {
|
|
16
|
+
timingSafeEqual(left, left)
|
|
17
|
+
return false
|
|
18
|
+
}
|
|
19
|
+
return timingSafeEqual(left, right)
|
|
20
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { parseAppId } from '@mohou/contract'
|
|
2
|
+
import type { Context, Hono } from 'hono'
|
|
3
|
+
|
|
4
|
+
import type { HostEnv } from './env.ts'
|
|
5
|
+
import { streamSSE } from 'hono/streaming'
|
|
6
|
+
|
|
7
|
+
import { platformLayout, platformModules, platformRuntimePath, platformSdkPath, platformVendorPath } from '../compile/allowlist.ts'
|
|
8
|
+
import { acceptDiagnostic, DEFAULT_DIAGNOSTIC_MAX_BYTES, diagnosticLayout, type DiagnosticPorts } from '../tools/diagnostics.ts'
|
|
9
|
+
import { httpLayout } from './layout.ts'
|
|
10
|
+
import type { LoopbackPorts } from './ports.ts'
|
|
11
|
+
import { RouteError } from './route-codes.ts'
|
|
12
|
+
import { authoringDenied, errorBody, isRecord } from './reply.ts'
|
|
13
|
+
|
|
14
|
+
/** Iframe document, call, platform files, and diagnostic posts. */
|
|
15
|
+
export function mountIframe(app: Hono<HostEnv>, options: {
|
|
16
|
+
readonly diagnostics?: DiagnosticPorts
|
|
17
|
+
readonly loopback?: LoopbackPorts
|
|
18
|
+
}): void {
|
|
19
|
+
if (options.diagnostics !== undefined) mountDiagnostics(app, options.diagnostics)
|
|
20
|
+
const ports = options.loopback
|
|
21
|
+
if (ports === undefined) return
|
|
22
|
+
app.post(httpLayout.call, c => call(c, ports))
|
|
23
|
+
app.get(platformRuntimePath(), async (c) => {
|
|
24
|
+
return script(c, await ports.vendorFile('runtime.js'))
|
|
25
|
+
})
|
|
26
|
+
app.get(platformSdkPath(), async (c) => {
|
|
27
|
+
return script(c, await ports.vendorFile('sdk.js'))
|
|
28
|
+
})
|
|
29
|
+
app.get(`${platformLayout.root}/${platformLayout.vendors}/:file`, c => vendor(c, ports))
|
|
30
|
+
app.get(`${httpLayout.runner}/:appId`, c => document(c, ports))
|
|
31
|
+
app.get(`${diagnosticLayout.root}/:appId/events`, c => events(c, (listener) => {
|
|
32
|
+
const since = Number(c.req.query('since') ?? '0')
|
|
33
|
+
return ports.subscribeApp(c.req.param('appId'), Number.isFinite(since) ? since : 0, listener)
|
|
34
|
+
}))
|
|
35
|
+
app.get(httpLayout.events, c => events(c, (listener) => {
|
|
36
|
+
const stopHost = ports.subscribeHost(listener)
|
|
37
|
+
const stopFrames = ports.subscribeFrames(listener)
|
|
38
|
+
return () => {
|
|
39
|
+
stopHost()
|
|
40
|
+
stopFrames()
|
|
41
|
+
}
|
|
42
|
+
}))
|
|
43
|
+
app.get(`${diagnosticLayout.root}/:appId/${httpLayout.ui}/${httpLayout.sheet}`, (c) => {
|
|
44
|
+
return text(c, () => ports.stylesheet(c.req.param('appId')), 'text/css; charset=utf-8')
|
|
45
|
+
})
|
|
46
|
+
app.get(`${diagnosticLayout.root}/:appId/${httpLayout.ui}/${httpLayout.entry}`, (c) => {
|
|
47
|
+
return entryModule(c, () => ports.entryScript(c.req.param('appId')))
|
|
48
|
+
})
|
|
49
|
+
app.get(`${diagnosticLayout.root}/:appId/${httpLayout.assets}/*`, c => asset(c, ports))
|
|
50
|
+
app.get(`${diagnosticLayout.root}/:appId/${diagnosticLayout.errors}`, (c) => {
|
|
51
|
+
if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
|
|
52
|
+
return c.json(ports.readErrors(c.req.param('appId')))
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function mountDiagnostics(app: Hono<HostEnv>, ports: DiagnosticPorts): void {
|
|
57
|
+
const max = ports.maxBodyBytes ?? DEFAULT_DIAGNOSTIC_MAX_BYTES
|
|
58
|
+
const post = (kind: string) => async (c: Context) => {
|
|
59
|
+
const raw = await c.req.text()
|
|
60
|
+
const appId = admittedAppId(c.req.param('appId') ?? '')
|
|
61
|
+
if (appId !== undefined && Buffer.byteLength(raw) <= max) acceptDiagnostic(ports, appId, kind, raw)
|
|
62
|
+
return c.body(null, 204)
|
|
63
|
+
}
|
|
64
|
+
app.post(`${diagnosticLayout.root}/:appId/${diagnosticLayout.errors}`, post(diagnosticLayout.errors))
|
|
65
|
+
app.post(`${diagnosticLayout.root}/:appId/${diagnosticLayout.alive}`, post(diagnosticLayout.alive))
|
|
66
|
+
app.post(`${diagnosticLayout.root}/:appId/${diagnosticLayout.absent}`, post(diagnosticLayout.absent))
|
|
67
|
+
app.post(`${diagnosticLayout.root}/:appId/${diagnosticLayout.viewEval}`, post(diagnosticLayout.viewEval))
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function callBody(result: unknown): { ok: true; value: unknown } | { ok: false; error: string } {
|
|
71
|
+
if (isRecord(result) && result.ok === true && 'value' in result) return { ok: true, value: result.value }
|
|
72
|
+
if (isRecord(result) && result.ok === false) {
|
|
73
|
+
const error = result.error
|
|
74
|
+
const message = isRecord(error) && typeof error.message === 'string'
|
|
75
|
+
? error.message
|
|
76
|
+
: typeof error === 'string' ? error : 'call failed'
|
|
77
|
+
return { ok: false, error: message }
|
|
78
|
+
}
|
|
79
|
+
return { ok: true, value: result }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function call(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
83
|
+
let body: unknown
|
|
84
|
+
try {
|
|
85
|
+
body = await c.req.json()
|
|
86
|
+
} catch {
|
|
87
|
+
return c.json({ ok: false, error: 'invalid json' }, 400)
|
|
88
|
+
}
|
|
89
|
+
if (!isRecord(body) || typeof body.appId !== 'string' || body.appId.length === 0 || typeof body.method !== 'string' || body.method.length === 0) {
|
|
90
|
+
return c.json({ ok: false, error: 'missing appId or method' }, 400)
|
|
91
|
+
}
|
|
92
|
+
const appId = body.appId
|
|
93
|
+
const method = body.method
|
|
94
|
+
const args = body.args
|
|
95
|
+
if (!wantsStream(c.req.header('accept'))) {
|
|
96
|
+
try {
|
|
97
|
+
const result = callBody(await ports.call(appId, method, args))
|
|
98
|
+
return result.ok ? c.json(result) : c.json(result, 400)
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return c.json({ ok: false, error: error instanceof Error ? error.message : 'call failed' }, 400)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return streamSSE(c, async (sse) => {
|
|
104
|
+
let chain = Promise.resolve()
|
|
105
|
+
const write = (event: unknown): void => {
|
|
106
|
+
const next = chain.then(() => sse.writeSSE({ data: JSON.stringify(event) }))
|
|
107
|
+
chain = next.then(() => undefined, () => undefined)
|
|
108
|
+
}
|
|
109
|
+
const result = callBody(await ports.call(appId, method, args, (value) => {
|
|
110
|
+
write({ value })
|
|
111
|
+
}))
|
|
112
|
+
await chain
|
|
113
|
+
await sse.writeSSE({
|
|
114
|
+
data: JSON.stringify(result.ok ? { return: result.value } : { error: result.error }),
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function wantsStream(header: string | undefined): boolean {
|
|
120
|
+
return (header ?? '').toLowerCase().includes('text/event-stream')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function asset(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
124
|
+
const marker = `/${httpLayout.assets}/`
|
|
125
|
+
const at = c.req.path.indexOf(marker)
|
|
126
|
+
const rest = at < 0 ? '' : c.req.path.slice(at + marker.length)
|
|
127
|
+
try {
|
|
128
|
+
const file = await ports.assetFile(c.req.param('appId') ?? '', rest)
|
|
129
|
+
return new Response(file.bytes, {
|
|
130
|
+
status: 200,
|
|
131
|
+
headers: {
|
|
132
|
+
'content-type': file.type,
|
|
133
|
+
'cache-control': 'no-cache',
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const status = error instanceof RouteError && error.code === 'not-found' ? 404 : 400
|
|
138
|
+
return c.json({ ok: false, error: errorBody(error) }, status)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function vendor(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
143
|
+
const file = c.req.param('file') ?? ''
|
|
144
|
+
const id = file.replace(/\.js$/, '')
|
|
145
|
+
if (!platformModules.some(row => row.file === platformVendorPath(id))) {
|
|
146
|
+
const error = new RouteError('unknown-vendor', `unknown vendor: ${id}`)
|
|
147
|
+
return c.json({ ok: false, error: { code: error.code, message: error.message } }, 404)
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return script(c, await ports.vendorFile(file))
|
|
151
|
+
} catch (error) {
|
|
152
|
+
return c.json({ ok: false, error: errorBody(error) }, 400)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function document(c: Context, ports: LoopbackPorts): Promise<Response> {
|
|
157
|
+
const appId = c.req.param('appId') ?? ''
|
|
158
|
+
return text(c, () => ports.runnerDocument(appId), 'text/html; charset=utf-8')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function events(c: Context, subscribe: (listener: (event: unknown) => void) => () => void): Response {
|
|
162
|
+
return streamSSE(c, async (stream) => {
|
|
163
|
+
await new Promise<void>((resolve) => {
|
|
164
|
+
const stop = subscribe((event) => {
|
|
165
|
+
void stream.writeSSE({ data: JSON.stringify(event) })
|
|
166
|
+
})
|
|
167
|
+
stream.onAbort(() => {
|
|
168
|
+
stop()
|
|
169
|
+
resolve()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function admittedAppId(value: string): string | undefined {
|
|
176
|
+
try {
|
|
177
|
+
return parseAppId(value)
|
|
178
|
+
} catch {
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function script(c: Context, code: string): Response {
|
|
184
|
+
return c.body(code, 200, { 'content-type': 'text/javascript; charset=utf-8' })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function text(c: Context, work: () => Promise<string>, type: string): Promise<Response> {
|
|
188
|
+
try {
|
|
189
|
+
return c.body(await work(), 200, { 'content-type': type })
|
|
190
|
+
} catch (error) {
|
|
191
|
+
return c.json({ ok: false, error: errorBody(error) }, 400)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* UI entry must stay a JS module. A JSON 400 makes the browser say only
|
|
197
|
+
* "Importing a module script failed" and hides the compile message.
|
|
198
|
+
*/
|
|
199
|
+
async function entryModule(c: Context, work: () => Promise<string>): Promise<Response> {
|
|
200
|
+
try {
|
|
201
|
+
return script(c, await work())
|
|
202
|
+
} catch (error) {
|
|
203
|
+
const { code, message } = errorBody(error)
|
|
204
|
+
const detail = code === 'failed' ? message : `${code}: ${message}`
|
|
205
|
+
return script(c, [
|
|
206
|
+
`const __mmaUiError = ${JSON.stringify(detail)}`,
|
|
207
|
+
'throw new Error(__mmaUiError)',
|
|
208
|
+
'export default function BrokenUi() { throw new Error(__mmaUiError) }',
|
|
209
|
+
'',
|
|
210
|
+
].join('\n'))
|
|
211
|
+
}
|
|
212
|
+
}
|