@mohou/host 1.0.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +65 -0
- package/src/apps/monogram.ts +15 -0
- package/src/apps/registry.ts +238 -0
- package/src/apps/startup-seed.ts +95 -0
- package/src/bash/client.ts +21 -0
- package/src/bash/codes.ts +21 -0
- package/src/compile/allowlist.ts +89 -0
- package/src/compile/asset-path.ts +48 -0
- package/src/compile/autogen.ts +71 -0
- package/src/compile/build-vendor-cli.ts +8 -0
- package/src/compile/build-vendor.ts +160 -0
- package/src/compile/bundle-ui.ts +137 -0
- package/src/compile/codes.ts +22 -0
- package/src/compile/failure.ts +38 -0
- package/src/compile/imports.ts +43 -0
- package/src/compile/leave.ts +47 -0
- package/src/compile/load-backend.ts +95 -0
- package/src/compile/review.ts +342 -0
- package/src/compile/runner.ts +205 -0
- package/src/compile/sheet.ts +174 -0
- package/src/compile/wrapper.ts +297 -0
- package/src/credentials/codes.ts +26 -0
- package/src/credentials/create.ts +69 -0
- package/src/credentials/file.ts +113 -0
- package/src/credentials/provider.ts +66 -0
- package/src/events/app-events.ts +214 -0
- package/src/events/error-ring.ts +109 -0
- package/src/events/host-events.ts +39 -0
- package/src/events/view-bridge.ts +402 -0
- package/src/events/view-eval.ts +213 -0
- package/src/files/codes.ts +28 -0
- package/src/files/skip.ts +23 -0
- package/src/files/tools.ts +215 -0
- package/src/history/codes.ts +22 -0
- package/src/history/store.ts +287 -0
- package/src/host/activity.ts +110 -0
- package/src/host/author-mcp.ts +248 -0
- package/src/host/author-skill.ts +196 -0
- package/src/host/codes.ts +21 -0
- package/src/host/config.ts +239 -0
- package/src/host/heat.ts +35 -0
- package/src/host/layout.ts +81 -0
- package/src/host/log.ts +142 -0
- package/src/host/mcp-editor.ts +205 -0
- package/src/host/mcp-import.ts +141 -0
- package/src/host/mcp.ts +109 -0
- package/src/host/port.ts +83 -0
- package/src/host/session.ts +358 -0
- package/src/host/workbench.ts +38 -0
- package/src/http/app.ts +46 -0
- package/src/http/asset.ts +49 -0
- package/src/http/author.ts +157 -0
- package/src/http/bind.ts +217 -0
- package/src/http/client.ts +175 -0
- package/src/http/codes.ts +29 -0
- package/src/http/env.ts +3 -0
- package/src/http/guard.ts +20 -0
- package/src/http/iframe.ts +212 -0
- package/src/http/layout.ts +63 -0
- package/src/http/owner.ts +380 -0
- package/src/http/ports.ts +114 -0
- package/src/http/reply.ts +53 -0
- package/src/http/route-codes.ts +19 -0
- package/src/http/updates.ts +258 -0
- package/src/index.ts +201 -0
- package/src/install/codes.ts +21 -0
- package/src/install/install.ts +230 -0
- package/src/install/layout.ts +16 -0
- package/src/kernel/bind-brain.ts +117 -0
- package/src/kernel/call.ts +134 -0
- package/src/kernel/codes.ts +21 -0
- package/src/kernel/index.ts +13 -0
- package/src/kernel/model-policy.ts +83 -0
- package/src/kernel/model-stream.ts +86 -0
- package/src/kernel/present.ts +4 -0
- package/src/kernel/working-directory.ts +35 -0
- package/src/metrics/codes.ts +22 -0
- package/src/metrics/read.ts +48 -0
- package/src/owner/read.ts +85 -0
- package/src/pwsh/client.ts +47 -0
- package/src/pwsh/codes.ts +21 -0
- package/src/shell/command.ts +172 -0
- package/src/storage/codes.ts +33 -0
- package/src/storage/layout.ts +44 -0
- package/src/storage/open.ts +461 -0
- package/src/storage/schema.ts +176 -0
- package/src/storage/sql.ts +40 -0
- package/src/theme/codes.ts +20 -0
- package/src/theme/layout.ts +42 -0
- package/src/theme/paint.ts +108 -0
- package/src/theme/parse.ts +58 -0
- package/src/theme/pin.ts +197 -0
- package/src/theme/tokens.ts +30 -0
- package/src/tools/author.ts +970 -0
- package/src/tools/codes.ts +21 -0
- package/src/tools/diagnostics.ts +53 -0
- package/src/tools/http.ts +48 -0
- package/src/tools/mcp-list.ts +90 -0
- package/src/tools/schemas.ts +185 -0
- package/themes/theme-default.css +4 -0
- package/themes/theme-forest.css +4 -0
- package/themes/theme-hokage.css +4 -0
- package/themes/theme-matcha.css +4 -0
- package/themes/theme-slate.css +4 -0
- package/themes/theme-tokyo.css +4 -0
- package/themes/theme-yellow.css +4 -0
- package/themes/theme-zoro.css +4 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { themeTokens } from '../theme/tokens.ts'
|
|
2
|
+
|
|
3
|
+
/** Values the runner script may close over. Caps on a query arrive in the message. */
|
|
4
|
+
export interface ViewBridgeDeps {
|
|
5
|
+
readonly appId: string
|
|
6
|
+
readonly evalUrl: string
|
|
7
|
+
readonly tokens?: readonly string[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface BridgeBox {
|
|
11
|
+
left: number
|
|
12
|
+
top: number
|
|
13
|
+
width: number
|
|
14
|
+
height: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface BridgeElement {
|
|
18
|
+
nodeType: number
|
|
19
|
+
tagName: string
|
|
20
|
+
id: string
|
|
21
|
+
/** An SVG element exposes `SVGAnimatedString` here, not a string. */
|
|
22
|
+
className: string | { readonly baseVal: string }
|
|
23
|
+
isConnected: boolean
|
|
24
|
+
parentElement: BridgeElement | null
|
|
25
|
+
firstElementChild: BridgeElement | null
|
|
26
|
+
nextElementSibling: BridgeElement | null
|
|
27
|
+
previousElementSibling: BridgeElement | null
|
|
28
|
+
firstChild: { nodeType: number; nodeValue: string | null; nextSibling: BridgeElement['firstChild'] } | null
|
|
29
|
+
getBoundingClientRect(): BridgeBox
|
|
30
|
+
querySelector(selector: string): BridgeElement | null
|
|
31
|
+
querySelectorAll(selector: string): ArrayLike<BridgeElement>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ThemeRoot {
|
|
35
|
+
setAttribute(name: string, value: string): void
|
|
36
|
+
style: { setProperty(name: string, value: string): void }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface BridgeDocument {
|
|
40
|
+
body: BridgeElement | null
|
|
41
|
+
documentElement?: ThemeRoot | null
|
|
42
|
+
querySelector(selector: string): BridgeElement | null
|
|
43
|
+
querySelectorAll(selector: string): ArrayLike<BridgeElement>
|
|
44
|
+
getElementById?(id: string): { textContent: string } | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface BridgeMessage {
|
|
48
|
+
source: unknown
|
|
49
|
+
origin: string
|
|
50
|
+
data: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface BridgeWindow {
|
|
54
|
+
parent: unknown
|
|
55
|
+
document: BridgeDocument
|
|
56
|
+
addEventListener(type: string, listener: (event: BridgeMessage) => void): void
|
|
57
|
+
getComputedStyle?(element: BridgeElement): { display: string }
|
|
58
|
+
CSS?: { escape(value: string): string }
|
|
59
|
+
fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<unknown>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface EvalMessage {
|
|
63
|
+
type: 'app:eval'
|
|
64
|
+
appId: string
|
|
65
|
+
requestId: string
|
|
66
|
+
code: string
|
|
67
|
+
budgetMs: number
|
|
68
|
+
maxBytes: number
|
|
69
|
+
maxNodes: number
|
|
70
|
+
maxDepth: number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Install the three view helpers and answer `app:eval` from the parent origin.
|
|
75
|
+
* No imports and no closed-over module state: the runner emits this function as text.
|
|
76
|
+
* @param deps - app id and the diagnostic answer URL
|
|
77
|
+
*/
|
|
78
|
+
export function installViewBridge(deps: ViewBridgeDeps): void {
|
|
79
|
+
const realm = globalThis as { window?: BridgeWindow }
|
|
80
|
+
let win: BridgeWindow
|
|
81
|
+
if (realm.window !== undefined) win = realm.window
|
|
82
|
+
else win = globalThis
|
|
83
|
+
const names = { query: '$', queryAll: '$$', selector: 'selector' } as const
|
|
84
|
+
let parentOrigin: string | undefined
|
|
85
|
+
|
|
86
|
+
function escapeIdent(value: string): string {
|
|
87
|
+
if (win.CSS) return win.CSS.escape(value)
|
|
88
|
+
return value.replace(/([^a-zA-Z0-9_-])/g, '\\$1')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let matched = 0
|
|
92
|
+
|
|
93
|
+
function query(selector: string, root?: BridgeElement | null): BridgeElement | null {
|
|
94
|
+
const base = root ?? win.document
|
|
95
|
+
const found = base.querySelector(selector)
|
|
96
|
+
if (found !== null) matched += 1
|
|
97
|
+
return found
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function queryAll(selector: string, root?: BridgeElement | null): BridgeElement[] {
|
|
101
|
+
const base = root ?? win.document
|
|
102
|
+
const found = base.querySelectorAll(selector)
|
|
103
|
+
const list: BridgeElement[] = []
|
|
104
|
+
for (let index = 0; index < found.length; index += 1) {
|
|
105
|
+
const item = found[index]
|
|
106
|
+
if (item !== undefined) list.push(item)
|
|
107
|
+
}
|
|
108
|
+
matched += list.length
|
|
109
|
+
return list
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function selectorOf(element: BridgeElement): string {
|
|
113
|
+
if (element.nodeType !== 1) return ''
|
|
114
|
+
const tag = element.tagName.toLowerCase()
|
|
115
|
+
if (element.id) {
|
|
116
|
+
const byId = `#${escapeIdent(element.id)}`
|
|
117
|
+
if (queryAll(byId).length === 1) return byId
|
|
118
|
+
}
|
|
119
|
+
const classes = classNames(element)
|
|
120
|
+
let dots = ''
|
|
121
|
+
for (let index = 0; index < classes.length; index += 1) {
|
|
122
|
+
dots += `.${escapeIdent(classes[index] ?? '')}`
|
|
123
|
+
if (queryAll(tag + dots).length === 1) return tag + dots
|
|
124
|
+
}
|
|
125
|
+
const parts: string[] = []
|
|
126
|
+
let node: BridgeElement | null = element
|
|
127
|
+
while (node !== null && node.nodeType === 1 && node !== win.document.body) {
|
|
128
|
+
if (node.id) {
|
|
129
|
+
parts.unshift(`#${escapeIdent(node.id)}`)
|
|
130
|
+
return parts.join(' > ')
|
|
131
|
+
}
|
|
132
|
+
let nth = 1
|
|
133
|
+
for (let sibling = node.previousElementSibling; sibling !== null; sibling = sibling.previousElementSibling) {
|
|
134
|
+
if (sibling.tagName === node.tagName) nth += 1
|
|
135
|
+
}
|
|
136
|
+
parts.unshift(`${node.tagName.toLowerCase()}:nth-of-type(${nth})`)
|
|
137
|
+
node = node.parentElement
|
|
138
|
+
}
|
|
139
|
+
return parts.join(' > ')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const helpers = {
|
|
143
|
+
[names.query]: query,
|
|
144
|
+
[names.queryAll]: queryAll,
|
|
145
|
+
[names.selector]: selectorOf,
|
|
146
|
+
}
|
|
147
|
+
;(win as BridgeWindow & { mma?: typeof helpers }).mma = helpers
|
|
148
|
+
|
|
149
|
+
win.addEventListener('message', (event) => {
|
|
150
|
+
if (event.source !== win.parent) return
|
|
151
|
+
if (parentOrigin === undefined) parentOrigin = event.origin
|
|
152
|
+
if (event.origin !== parentOrigin) return
|
|
153
|
+
if (applyTheme(event.data)) return
|
|
154
|
+
const data = readEval(event.data)
|
|
155
|
+
if (data === undefined || data.appId !== deps.appId) return
|
|
156
|
+
void answer(data)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
async function answer(data: EvalMessage): Promise<void> {
|
|
160
|
+
const budget = {
|
|
161
|
+
lines: [] as string[],
|
|
162
|
+
bytes: 0,
|
|
163
|
+
visited: 0,
|
|
164
|
+
matched,
|
|
165
|
+
dropped: 0,
|
|
166
|
+
stopped: '' as '' | 'bytes' | 'nodes' | 'depth' | 'timeout',
|
|
167
|
+
maxBytes: data.maxBytes,
|
|
168
|
+
maxNodes: data.maxNodes,
|
|
169
|
+
maxDepth: data.maxDepth,
|
|
170
|
+
deadline: Date.now() + data.budgetMs,
|
|
171
|
+
}
|
|
172
|
+
let error: string | undefined
|
|
173
|
+
try {
|
|
174
|
+
// The query is an async function body supplied by the owner. It is not a string the host builds.
|
|
175
|
+
// oxlint-disable-next-line typescript/no-implied-eval
|
|
176
|
+
const run = new Function('mma', `return (async () => {\n${data.code}\n})()`) as (mma: typeof helpers) => Promise<unknown>
|
|
177
|
+
const value = await run(helpers)
|
|
178
|
+
renderValue(value, budget)
|
|
179
|
+
} catch (caught) {
|
|
180
|
+
error = caught instanceof Error ? caught.message : 'view query failed'
|
|
181
|
+
}
|
|
182
|
+
if (budget.stopped !== '') pushLine(budget, `truncated: ${budget.stopped}`, true)
|
|
183
|
+
const body: Record<string, unknown> = {
|
|
184
|
+
requestId: data.requestId,
|
|
185
|
+
result: budget.lines.join('\n'),
|
|
186
|
+
view: 'live',
|
|
187
|
+
bytes: budget.bytes,
|
|
188
|
+
truncated: budget.stopped !== '',
|
|
189
|
+
stoppedBy: budget.stopped === '' ? null : budget.stopped,
|
|
190
|
+
visited: budget.visited,
|
|
191
|
+
matched,
|
|
192
|
+
budgetMs: data.budgetMs,
|
|
193
|
+
}
|
|
194
|
+
if (error !== undefined) body.error = error
|
|
195
|
+
await win.fetch(deps.evalUrl, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
headers: { 'content-type': 'application/json' },
|
|
198
|
+
body: JSON.stringify(body),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function renderValue(value: unknown, budget: Budget): void {
|
|
203
|
+
if (isElement(value)) {
|
|
204
|
+
walk(value, 0, '', budget)
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
if (isElementList(value)) {
|
|
208
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
209
|
+
const item = value[index]
|
|
210
|
+
if (item === undefined) continue
|
|
211
|
+
walk(item, 0, '', budget)
|
|
212
|
+
if (budget.stopped !== '') return
|
|
213
|
+
}
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
pushLine(budget, textOf(value), false)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function walk(element: BridgeElement, depth: number, indent: string, budget: Budget): void {
|
|
220
|
+
if (budget.stopped !== '') return
|
|
221
|
+
if (Date.now() > budget.deadline) {
|
|
222
|
+
budget.stopped = 'timeout'
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
budget.visited += 1
|
|
226
|
+
if (budget.visited > budget.maxNodes) {
|
|
227
|
+
budget.stopped = 'nodes'
|
|
228
|
+
budget.visited -= 1
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (depth > budget.maxDepth) {
|
|
232
|
+
budget.stopped = 'depth'
|
|
233
|
+
budget.visited -= 1
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
pushLine(budget, indent + describe(element), false)
|
|
237
|
+
for (let child = element.firstElementChild; child !== null; child = child.nextElementSibling) {
|
|
238
|
+
walk(child, depth + 1, `${indent} `, budget)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function describe(element: BridgeElement): string {
|
|
243
|
+
let line = element.tagName.toLowerCase()
|
|
244
|
+
if (element.id) line += `#${element.id}`
|
|
245
|
+
const classes = classNames(element)
|
|
246
|
+
if (classes.length > 0) line += `.${classes.join('.')}`
|
|
247
|
+
if (!element.isConnected) return `${line} detached`
|
|
248
|
+
const box = element.getBoundingClientRect()
|
|
249
|
+
line += ` x=${Math.round(box.left)} y=${Math.round(box.top)} w=${Math.round(box.width)} h=${Math.round(box.height)}`
|
|
250
|
+
const kids = childCount(element)
|
|
251
|
+
if (kids > 0) line += ` (${kids} ${kids === 1 ? 'child' : 'children'})`
|
|
252
|
+
else {
|
|
253
|
+
const text = ownText(element)
|
|
254
|
+
if (text.length > 0) line += ` ${JSON.stringify(text)}`
|
|
255
|
+
}
|
|
256
|
+
const display = win.getComputedStyle?.(element).display
|
|
257
|
+
if (display === 'none') line += ' display:none'
|
|
258
|
+
return line
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pushLine(budget: Budget, text: string, force: boolean): void {
|
|
262
|
+
if (budget.stopped !== '' && !force) {
|
|
263
|
+
budget.dropped += 1
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
const cost = text.length + 1
|
|
267
|
+
if (!force && budget.bytes + cost > budget.maxBytes) {
|
|
268
|
+
budget.stopped = 'bytes'
|
|
269
|
+
budget.dropped += 1
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
budget.bytes += cost
|
|
273
|
+
budget.lines.push(text)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function classNames(element: BridgeElement): string[] {
|
|
277
|
+
const raw = element.className
|
|
278
|
+
const text = typeof raw === 'string' ? raw : raw.baseVal
|
|
279
|
+
return text.trim().split(/\s+/).filter((name) => {
|
|
280
|
+
return name.length > 0
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function childCount(element: BridgeElement): number {
|
|
285
|
+
let count = 0
|
|
286
|
+
for (let child = element.firstElementChild; child !== null; child = child.nextElementSibling) count += 1
|
|
287
|
+
return count
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function ownText(element: BridgeElement): string {
|
|
291
|
+
let text = ''
|
|
292
|
+
for (let node = element.firstChild; node !== null; node = node.nextSibling) {
|
|
293
|
+
if (node.nodeType === 3 && node.nodeValue !== null) text += node.nodeValue
|
|
294
|
+
}
|
|
295
|
+
return text.replace(/\s+/g, ' ').trim()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function isElement(value: unknown): value is BridgeElement {
|
|
299
|
+
return typeof value === 'object' && value !== null && 'tagName' in value && 'nodeType' in value && value.nodeType === 1
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function isElementList(value: unknown): value is BridgeElement[] {
|
|
303
|
+
return Array.isArray(value) && value.every(isElement)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function textOf(value: unknown): string {
|
|
307
|
+
if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
|
|
308
|
+
if (typeof value === 'string') return JSON.stringify(value)
|
|
309
|
+
try {
|
|
310
|
+
return JSON.stringify(value)
|
|
311
|
+
} catch {
|
|
312
|
+
return Object.prototype.toString.call(value)
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function applyTheme(value: unknown): boolean {
|
|
317
|
+
if (typeof value !== 'object' || value === null) return false
|
|
318
|
+
if (!('type' in value) || value.type !== 'theme') return false
|
|
319
|
+
const root = win.document.documentElement
|
|
320
|
+
if (root == null) return true
|
|
321
|
+
if ('mode' in value && (value.mode === 'light' || value.mode === 'dark') && typeof root.setAttribute === 'function') {
|
|
322
|
+
root.setAttribute('data-mode', value.mode)
|
|
323
|
+
}
|
|
324
|
+
if ('style' in value && typeof value.style === 'string') {
|
|
325
|
+
const painted = win.document.getElementById?.('mma-first-paint')
|
|
326
|
+
if (painted != null) painted.textContent = value.style
|
|
327
|
+
}
|
|
328
|
+
if (!('variables' in value) || typeof value.variables !== 'object' || value.variables === null) return true
|
|
329
|
+
const variables = value.variables as Record<string, unknown>
|
|
330
|
+
for (const key of Object.keys(variables)) {
|
|
331
|
+
const item = variables[key]
|
|
332
|
+
if (typeof item !== 'string' || !knownToken(key)) continue
|
|
333
|
+
root.style.setProperty(key, item)
|
|
334
|
+
}
|
|
335
|
+
return true
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function knownToken(name: string): boolean {
|
|
339
|
+
if (!name.startsWith('--')) return false
|
|
340
|
+
const tokens = deps.tokens
|
|
341
|
+
if (tokens === undefined) return false
|
|
342
|
+
const bare = name.slice(2)
|
|
343
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
344
|
+
if (tokens[index] === bare) return true
|
|
345
|
+
}
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function readEval(value: unknown): EvalMessage | undefined {
|
|
350
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
351
|
+
if (!('type' in value) || value.type !== 'app:eval') return undefined
|
|
352
|
+
if (!('appId' in value) || typeof value.appId !== 'string') return undefined
|
|
353
|
+
if (!('requestId' in value) || typeof value.requestId !== 'string') return undefined
|
|
354
|
+
if (!('code' in value) || typeof value.code !== 'string') return undefined
|
|
355
|
+
if (!('budgetMs' in value) || typeof value.budgetMs !== 'number') return undefined
|
|
356
|
+
if (!('maxBytes' in value) || typeof value.maxBytes !== 'number') return undefined
|
|
357
|
+
if (!('maxNodes' in value) || typeof value.maxNodes !== 'number') return undefined
|
|
358
|
+
if (!('maxDepth' in value) || typeof value.maxDepth !== 'number') return undefined
|
|
359
|
+
return {
|
|
360
|
+
type: 'app:eval',
|
|
361
|
+
appId: value.appId,
|
|
362
|
+
requestId: value.requestId,
|
|
363
|
+
code: value.code,
|
|
364
|
+
budgetMs: value.budgetMs,
|
|
365
|
+
maxBytes: value.maxBytes,
|
|
366
|
+
maxNodes: value.maxNodes,
|
|
367
|
+
maxDepth: value.maxDepth,
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
interface Budget {
|
|
373
|
+
lines: string[]
|
|
374
|
+
bytes: number
|
|
375
|
+
visited: number
|
|
376
|
+
matched: number
|
|
377
|
+
dropped: number
|
|
378
|
+
stopped: '' | 'bytes' | 'nodes' | 'depth' | 'timeout'
|
|
379
|
+
maxBytes: number
|
|
380
|
+
maxNodes: number
|
|
381
|
+
maxDepth: number
|
|
382
|
+
deadline: number
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const bridgeHelpers = new Set(['__name'])
|
|
386
|
+
|
|
387
|
+
/** Source the runner inlines. Caps stay on the query message, not in this string. */
|
|
388
|
+
export function renderViewBridge(deps: ViewBridgeDeps): string {
|
|
389
|
+
const payload = {
|
|
390
|
+
appId: deps.appId,
|
|
391
|
+
evalUrl: deps.evalUrl,
|
|
392
|
+
tokens: deps.tokens ?? themeTokens,
|
|
393
|
+
}
|
|
394
|
+
const source = `{\nconst __name = (target) => target;\n(${installViewBridge.toString()})(${JSON.stringify(payload)})\n}`
|
|
395
|
+
for (const match of source.matchAll(/\b(__[A-Za-z]+)\s*\(/g)) {
|
|
396
|
+
const name = match[1]
|
|
397
|
+
if (name !== undefined && !bridgeHelpers.has(name)) {
|
|
398
|
+
throw new Error(`view bridge helper is not declared: ${name}`)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return source
|
|
402
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { HostEvent } from './host-events.ts'
|
|
2
|
+
|
|
3
|
+
/** View states. Only `stuck` means the main thread is blocked. */
|
|
4
|
+
export const viewStates = ['live', 'not-open', 'runner-not-booted', 'pending', 'stuck'] as const
|
|
5
|
+
|
|
6
|
+
export type ViewState = (typeof viewStates)[number]
|
|
7
|
+
|
|
8
|
+
export type ViewStop = 'bytes' | 'nodes' | 'depth' | 'timeout'
|
|
9
|
+
|
|
10
|
+
export interface ViewEnvelope {
|
|
11
|
+
result: unknown
|
|
12
|
+
view: ViewState
|
|
13
|
+
tookMs: number
|
|
14
|
+
bytes: number
|
|
15
|
+
truncated: boolean
|
|
16
|
+
stoppedBy: ViewStop | null
|
|
17
|
+
visited: number
|
|
18
|
+
matched: number
|
|
19
|
+
dropped?: number
|
|
20
|
+
error?: string
|
|
21
|
+
hint?: string
|
|
22
|
+
budgetMs: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Query wait when the caller omits one. Host policy, not a locked number. */
|
|
26
|
+
export const DEFAULT_VIEW_TIMEOUT_MS = 3_000
|
|
27
|
+
|
|
28
|
+
/** Byte cap when the caller omits one. Host policy, not a locked number. */
|
|
29
|
+
export const DEFAULT_VIEW_MAX_BYTES = 64_000
|
|
30
|
+
|
|
31
|
+
/** Node cap when the caller omits one. Host policy, not a locked number. */
|
|
32
|
+
export const DEFAULT_VIEW_MAX_NODES = 200
|
|
33
|
+
|
|
34
|
+
/** Depth cap when the caller omits one. Host policy, not a locked number. */
|
|
35
|
+
export const DEFAULT_VIEW_MAX_DEPTH = 8
|
|
36
|
+
|
|
37
|
+
/** Omitted `code` reads the document root. No fourth helper name. */
|
|
38
|
+
export const DEFAULT_VIEW_CODE = 'return mma.$("#root")'
|
|
39
|
+
|
|
40
|
+
interface PendingQuery {
|
|
41
|
+
appId: string
|
|
42
|
+
budgetMs: number
|
|
43
|
+
started: number
|
|
44
|
+
settle: (envelope: ViewEnvelope) => void
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One in-flight view query per id. A late, duplicate, or wrong-app reply cannot settle it.
|
|
49
|
+
* The iframe runs the code. This module only routes it.
|
|
50
|
+
*/
|
|
51
|
+
export function createViewQueries(defaults?: { timeoutMs?: number; maxBytes?: number }) {
|
|
52
|
+
const alive = new Set<string>()
|
|
53
|
+
const pending = new Map<string, PendingQuery>()
|
|
54
|
+
const timeoutMs = defaults?.timeoutMs ?? DEFAULT_VIEW_TIMEOUT_MS
|
|
55
|
+
const maxBytes = defaults?.maxBytes ?? DEFAULT_VIEW_MAX_BYTES
|
|
56
|
+
let seq = 0
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
markAlive(appId: string) {
|
|
60
|
+
alive.add(appId)
|
|
61
|
+
},
|
|
62
|
+
forgetAlive(appId: string) {
|
|
63
|
+
alive.delete(appId)
|
|
64
|
+
},
|
|
65
|
+
/** A client that has no frame for this app. Pending queries settle `not-open`, and the stale flag goes. */
|
|
66
|
+
absent(appId: string) {
|
|
67
|
+
alive.delete(appId)
|
|
68
|
+
for (const [id, query] of [...pending]) {
|
|
69
|
+
if (query.appId !== appId) continue
|
|
70
|
+
pending.delete(id)
|
|
71
|
+
query.settle(envelope({
|
|
72
|
+
view: 'not-open',
|
|
73
|
+
budgetMs: query.budgetMs,
|
|
74
|
+
hint: 'open the app; no frame is mounted',
|
|
75
|
+
tookMs: Date.now() - query.started,
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
ask(input: {
|
|
80
|
+
appId: string
|
|
81
|
+
code?: string
|
|
82
|
+
timeoutMs?: number
|
|
83
|
+
maxBytes?: number
|
|
84
|
+
maxNodes?: number
|
|
85
|
+
maxDepth?: number
|
|
86
|
+
connected: boolean
|
|
87
|
+
publish: (event: HostEvent) => void
|
|
88
|
+
}): Promise<ViewEnvelope> {
|
|
89
|
+
const budgetMs = input.timeoutMs ?? timeoutMs
|
|
90
|
+
const bytes = input.maxBytes ?? maxBytes
|
|
91
|
+
const nodes = input.maxNodes ?? DEFAULT_VIEW_MAX_NODES
|
|
92
|
+
const depth = input.maxDepth ?? DEFAULT_VIEW_MAX_DEPTH
|
|
93
|
+
const code = input.code ?? DEFAULT_VIEW_CODE
|
|
94
|
+
if (!input.connected) {
|
|
95
|
+
return Promise.resolve(envelope({
|
|
96
|
+
view: 'not-open',
|
|
97
|
+
budgetMs,
|
|
98
|
+
hint: 'open the app; no panel is attached',
|
|
99
|
+
tookMs: 0,
|
|
100
|
+
}))
|
|
101
|
+
}
|
|
102
|
+
if (alive.has(input.appId) && [...pending.values()].some(query => query.appId === input.appId)) {
|
|
103
|
+
return Promise.resolve(envelope({
|
|
104
|
+
view: 'pending',
|
|
105
|
+
budgetMs,
|
|
106
|
+
hint: 'the expression is still running',
|
|
107
|
+
tookMs: 0,
|
|
108
|
+
}))
|
|
109
|
+
}
|
|
110
|
+
seq += 1
|
|
111
|
+
const requestId = `view-${seq}`
|
|
112
|
+
return new Promise((resolve) => {
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
pending.delete(requestId)
|
|
115
|
+
resolve(envelope({
|
|
116
|
+
view: alive.has(input.appId) ? 'stuck' : 'runner-not-booted',
|
|
117
|
+
budgetMs,
|
|
118
|
+
stoppedBy: 'timeout',
|
|
119
|
+
hint: alive.has(input.appId)
|
|
120
|
+
? 'the main thread is blocked; reload the tab'
|
|
121
|
+
: 'no live frame for this app; open or reload it in the panel',
|
|
122
|
+
tookMs: budgetMs,
|
|
123
|
+
}))
|
|
124
|
+
}, budgetMs)
|
|
125
|
+
pending.set(requestId, {
|
|
126
|
+
appId: input.appId,
|
|
127
|
+
budgetMs,
|
|
128
|
+
started: Date.now(),
|
|
129
|
+
settle: (value) => {
|
|
130
|
+
clearTimeout(timer)
|
|
131
|
+
resolve(value)
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
input.publish({
|
|
135
|
+
type: 'app:eval',
|
|
136
|
+
appId: input.appId,
|
|
137
|
+
requestId,
|
|
138
|
+
code,
|
|
139
|
+
budgetMs,
|
|
140
|
+
maxBytes: bytes,
|
|
141
|
+
maxNodes: nodes,
|
|
142
|
+
maxDepth: depth,
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
},
|
|
146
|
+
answer(requestId: string, appId: string, raw: unknown): boolean {
|
|
147
|
+
const query = pending.get(requestId)
|
|
148
|
+
if (query === undefined || query.appId !== appId) return false
|
|
149
|
+
pending.delete(requestId)
|
|
150
|
+
query.settle(envelopeFromAnswer(raw, query.budgetMs, Date.now() - query.started))
|
|
151
|
+
return true
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function envelope(input: {
|
|
157
|
+
view: ViewState
|
|
158
|
+
budgetMs: number
|
|
159
|
+
tookMs: number
|
|
160
|
+
hint?: string
|
|
161
|
+
stoppedBy?: ViewStop
|
|
162
|
+
result?: unknown
|
|
163
|
+
error?: string
|
|
164
|
+
}): ViewEnvelope {
|
|
165
|
+
return {
|
|
166
|
+
result: input.result ?? null,
|
|
167
|
+
view: input.view,
|
|
168
|
+
tookMs: input.tookMs,
|
|
169
|
+
bytes: 0,
|
|
170
|
+
truncated: input.stoppedBy !== undefined,
|
|
171
|
+
stoppedBy: input.stoppedBy ?? null,
|
|
172
|
+
visited: 0,
|
|
173
|
+
matched: 0,
|
|
174
|
+
budgetMs: input.budgetMs,
|
|
175
|
+
...input.hint === undefined ? {} : { hint: input.hint },
|
|
176
|
+
...input.error === undefined ? {} : { error: input.error },
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function envelopeFromAnswer(raw: unknown, budgetMs: number, tookMs: number): ViewEnvelope {
|
|
181
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
182
|
+
return envelope({ view: 'live', budgetMs, tookMs, result: raw })
|
|
183
|
+
}
|
|
184
|
+
const view = 'view' in raw && isViewState(raw.view) ? raw.view : 'live'
|
|
185
|
+
const stoppedBy = 'stoppedBy' in raw && isStop(raw.stoppedBy) ? raw.stoppedBy : null
|
|
186
|
+
return {
|
|
187
|
+
result: 'result' in raw ? raw.result : null,
|
|
188
|
+
view,
|
|
189
|
+
tookMs,
|
|
190
|
+
bytes: numberField(raw, 'bytes'),
|
|
191
|
+
truncated: stoppedBy !== null,
|
|
192
|
+
stoppedBy,
|
|
193
|
+
visited: numberField(raw, 'visited'),
|
|
194
|
+
matched: numberField(raw, 'matched'),
|
|
195
|
+
budgetMs,
|
|
196
|
+
...'error' in raw && typeof raw.error === 'string' ? { error: raw.error } : {},
|
|
197
|
+
...'hint' in raw && typeof raw.hint === 'string' ? { hint: raw.hint } : {},
|
|
198
|
+
...'dropped' in raw && typeof raw.dropped === 'number' ? { dropped: raw.dropped } : {},
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function numberField(raw: object, key: string): number {
|
|
203
|
+
const value = key in raw ? raw[key as keyof typeof raw] : undefined
|
|
204
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isViewState(value: unknown): value is ViewState {
|
|
208
|
+
return typeof value === 'string' && (viewStates as readonly string[]).includes(value)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function isStop(value: unknown): value is ViewStop {
|
|
212
|
+
return value === 'bytes' || value === 'nodes' || value === 'depth' || value === 'timeout'
|
|
213
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** Codes the file tools emit. */
|
|
2
|
+
|
|
3
|
+
export const fileToolCodes = [
|
|
4
|
+
'path-escape',
|
|
5
|
+
'path-is-directory',
|
|
6
|
+
'file-missing',
|
|
7
|
+
'edit-not-unique',
|
|
8
|
+
'manifest-protected',
|
|
9
|
+
'app-not-registered',
|
|
10
|
+
] as const
|
|
11
|
+
|
|
12
|
+
/** A file-tool failure. Callers match `code`. */
|
|
13
|
+
export type FileToolCode = (typeof fileToolCodes)[number]
|
|
14
|
+
|
|
15
|
+
/** Failure from an authoring file tool. The message is for a person. */
|
|
16
|
+
export class FileToolError extends Error {
|
|
17
|
+
readonly code: FileToolCode
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param code - one of {@link fileToolCodes}
|
|
21
|
+
* @param message - human text; not the match key
|
|
22
|
+
*/
|
|
23
|
+
constructor(code: FileToolCode, message: string) {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = 'FileToolError'
|
|
26
|
+
this.code = code
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { hostLayout } from '../host/layout.ts'
|
|
2
|
+
import { installLayout } from '../install/layout.ts'
|
|
3
|
+
import { storageLayout } from '../storage/layout.ts'
|
|
4
|
+
import { themeLayout } from '../theme/layout.ts'
|
|
5
|
+
|
|
6
|
+
/** Git metadata directory. History owns the repo; other modules only skip the name. */
|
|
7
|
+
export const gitDir = '.git'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Generated directories a snapshot and a file listing both skip.
|
|
11
|
+
* Host writes `.autogen`. It does not create `dist` or `.cache`. `coverage` is not in this list.
|
|
12
|
+
*/
|
|
13
|
+
export const buildCacheDirs = ['dist', '.cache', '.autogen'] as const
|
|
14
|
+
|
|
15
|
+
/** Names a snapshot and a file listing both skip. One table. */
|
|
16
|
+
export const snapshotSkip = new Set<string>([
|
|
17
|
+
gitDir,
|
|
18
|
+
storageLayout.dir,
|
|
19
|
+
installLayout.modules,
|
|
20
|
+
themeLayout.appPin,
|
|
21
|
+
hostLayout.logs,
|
|
22
|
+
...buildCacheDirs,
|
|
23
|
+
])
|