@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,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
+ }
@@ -0,0 +1,3 @@
1
+ import type { HttpBindings } from '@hono/node-server'
2
+
3
+ export type HostEnv = { Bindings: HttpBindings }
@@ -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
+ }