@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,174 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { compile } from '@tailwindcss/node'
|
|
7
|
+
import { Scanner } from '@tailwindcss/oxide'
|
|
8
|
+
import { appEntries } from '@mohou/contract'
|
|
9
|
+
import { appCardCss } from '@mohou/app-view/css'
|
|
10
|
+
|
|
11
|
+
import { readAutogen, sheetFile, writeAutogen } from './autogen.ts'
|
|
12
|
+
import { CompileError } from './codes.ts'
|
|
13
|
+
import { themeTokens } from '../theme/tokens.ts'
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url)
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compile one app with Tailwind. The Oxide scanner finds candidates. Host does not parse `className`.
|
|
19
|
+
* `@theme` only connects `themeTokens` to Tailwind color names.
|
|
20
|
+
* A matching `.autogen` stamp returns the file and does not keep the text.
|
|
21
|
+
* @param appDir - absolute app directory
|
|
22
|
+
*/
|
|
23
|
+
export async function compileAppStylesheet(appDir: string): Promise<string> {
|
|
24
|
+
const hit = await readAutogen(appDir, sheetFile)
|
|
25
|
+
if (hit !== undefined) return hit
|
|
26
|
+
await newest(appDir)
|
|
27
|
+
const built = await compileSources([appDir])
|
|
28
|
+
const author = await readFile(path.join(appDir, appEntries.stylesheet), 'utf8').catch(() => '')
|
|
29
|
+
const css = author.length === 0 ? `${built}\n${appCardCss}` : `${built}\n${appCardCss}\n${author}`
|
|
30
|
+
await writeAutogen(appDir, sheetFile, css).catch(() => undefined)
|
|
31
|
+
return css
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Compile the panel document. Scans panel source and the UI kit. No author `ui.css`.
|
|
36
|
+
* @param panelDir - panel `src` directory
|
|
37
|
+
*/
|
|
38
|
+
export async function compilePanelStylesheet(panelDir: string): Promise<string> {
|
|
39
|
+
const built = await compileSources([panelDir], false)
|
|
40
|
+
return `${built}\n${panelLayoutCss}\n${appCardCss}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const panelLayoutCss = [
|
|
44
|
+
'html,body,#root{height:100%;margin:0;overflow:hidden;}',
|
|
45
|
+
'#mma-host, #mma-host .overflow-auto, #mma-host .mma-frame{scrollbar-width:thin;scrollbar-color:color-mix(in oklch,var(--foreground) 28%,transparent) transparent;}',
|
|
46
|
+
'#mma-host::-webkit-scrollbar, #mma-host *::-webkit-scrollbar{-webkit-appearance:none;width:8px;height:8px;}',
|
|
47
|
+
'#mma-host::-webkit-scrollbar-track, #mma-host *::-webkit-scrollbar-track, #mma-host::-webkit-scrollbar-corner, #mma-host *::-webkit-scrollbar-corner{background:transparent;}',
|
|
48
|
+
'#mma-host::-webkit-scrollbar-thumb, #mma-host *::-webkit-scrollbar-thumb{background:color-mix(in oklch,var(--foreground) 28%,transparent);border:2px solid transparent;border-radius:999px;background-clip:padding-box;}',
|
|
49
|
+
'#mma-host{height:100%;min-height:0;overflow:hidden;display:flex;flex-direction:column;}',
|
|
50
|
+
'#mma-host.mma-command{background:radial-gradient(circle at 18% 0%,color-mix(in oklch,var(--foreground) 6%,transparent),transparent 32%),radial-gradient(circle at 100% 100%,color-mix(in oklch,var(--foreground) 5%,transparent),transparent 28%),var(--background);}',
|
|
51
|
+
'#mma-host .mma-chrome{position:relative;z-index:40;height:64px;display:flex;align-items:center;gap:12px;padding:0 24px;border-bottom:1px solid color-mix(in oklch,var(--foreground) 8%,transparent);background:color-mix(in oklch,var(--card) 72%,transparent);backdrop-filter:blur(20px) saturate(160%);}',
|
|
52
|
+
'#mma-host .mma-pill-rail{position:relative;display:flex;min-width:0;gap:8px;overflow-x:auto;padding:4px;border-radius:999px;border:1px solid color-mix(in oklch,var(--foreground) 8%,transparent);background:color-mix(in oklch,var(--foreground) 4%,transparent);scrollbar-width:none;}',
|
|
53
|
+
'#mma-host .mma-pill-rail::-webkit-scrollbar{display:none;height:0;}',
|
|
54
|
+
'#mma-host .mma-pill-glide{position:absolute;top:4px;left:0;height:calc(100% - 8px);border-radius:999px;background:var(--card);box-shadow:0 4px 12px color-mix(in oklch,var(--foreground) 8%,transparent);transition:transform .42s cubic-bezier(.23,1,.32,1),width .42s cubic-bezier(.23,1,.32,1);pointer-events:none;}',
|
|
55
|
+
'#mma-host .mma-pill{position:relative;z-index:1;display:inline-flex;max-width:11rem;shrink:0;align-items:center;gap:6px;border-radius:999px;padding:6px 14px;font-size:13px;font-weight:600;color:var(--muted-foreground);transition:color .22s ease;}',
|
|
56
|
+
'#mma-host .mma-pill[data-active="1"]{color:var(--foreground);}',
|
|
57
|
+
'[data-mode="dark"] #mma-host .mma-pill-glide{background:color-mix(in oklch,var(--foreground) 10%,transparent);box-shadow:inset 0 1px 0 color-mix(in oklch,var(--foreground) 16%,transparent);}',
|
|
58
|
+
'#mma-host .mma-toolbar{display:flex;align-items:center;gap:2px;padding:4px;border-radius:16px;border:1px solid color-mix(in oklch,var(--foreground) 8%,transparent);background:color-mix(in oklch,var(--foreground) 4%,transparent);}',
|
|
59
|
+
'#mma-host .mma-toolbar-rule{width:1px;height:14px;margin:0 4px;flex:none;background:color-mix(in oklch,var(--foreground) 18%,transparent);}',
|
|
60
|
+
'#mma-host .mma-tip{position:relative;display:inline-flex;align-items:center;}',
|
|
61
|
+
'#mma-host .mma-tip-label{position:absolute;top:calc(100% + 6px);left:50%;z-index:60;width:max-content;max-width:16rem;transform:translateX(-50%);padding:5px 8px;border-radius:8px;border:1px solid var(--border);background:var(--card);color:var(--card-foreground,var(--foreground));font-size:12px;font-weight:600;letter-spacing:0;line-height:1.3;text-align:center;text-transform:none;white-space:normal;opacity:0;pointer-events:none;box-shadow:var(--shadow);transition:opacity .12s ease;}',
|
|
62
|
+
'#mma-host .mma-tip[data-align="end"] .mma-tip-label{left:auto;right:0;transform:none;}',
|
|
63
|
+
'#mma-host .mma-tip:hover .mma-tip-label,#mma-host .mma-tip:focus-within .mma-tip-label{opacity:1;}',
|
|
64
|
+
'#mma-host .mma-desk{position:relative;display:flex;max-width:min(28rem,42vw);align-items:center;gap:2px;overflow-x:auto;padding:4px;border-radius:16px;border:1px solid color-mix(in oklch,var(--foreground) 8%,transparent);background:color-mix(in oklch,var(--foreground) 4%,transparent);backdrop-filter:blur(16px) saturate(160%);scrollbar-width:none;}',
|
|
65
|
+
'#mma-host .mma-desk::-webkit-scrollbar{display:none;height:0;}',
|
|
66
|
+
'#mma-host .mma-desk.mma-desk-fit{max-width:none;}',
|
|
67
|
+
'#mma-host .mma-desk-pill{position:absolute;top:4px;left:0;height:calc(100% - 8px);border-radius:12px;background:var(--card);box-shadow:0 8px 18px -10px color-mix(in oklch,var(--foreground) 28%,transparent);transition:transform .42s cubic-bezier(.23,1,.32,1),width .42s cubic-bezier(.23,1,.32,1);pointer-events:none;}',
|
|
68
|
+
'#mma-host .mma-desk-item{position:relative;z-index:1;display:inline-flex;height:32px;max-width:11rem;flex:0 0 auto;align-items:center;gap:6px;overflow:hidden;border-radius:12px;padding:0 12px;font-size:13px;font-weight:600;color:var(--muted-foreground);transition:color .22s ease;}',
|
|
69
|
+
'#mma-host .mma-desk-mark{display:inline-flex;box-sizing:border-box;min-width:18px;height:18px;flex:none;align-items:center;justify-content:center;padding:0 3px;border-radius:4px;background:color-mix(in oklch,var(--foreground) 12%,transparent);font-size:9px;font-weight:700;letter-spacing:0;line-height:1;text-align:center;}',
|
|
70
|
+
'#mma-host .mma-desk-item[data-on="1"] .mma-desk-mark{background:color-mix(in oklch,var(--foreground) 18%,transparent);}',
|
|
71
|
+
'#mma-host .mma-desk-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
|
|
72
|
+
'#mma-host .mma-desk-item[data-on="1"]{color:var(--foreground);}',
|
|
73
|
+
'[data-mode="dark"] #mma-host .mma-desk-pill{background:color-mix(in oklch,var(--foreground) 10%,transparent);box-shadow:inset 0 1px 0 color-mix(in oklch,var(--foreground) 16%,transparent);}',
|
|
74
|
+
'#mma-host .mma-status{display:flex;flex:none;align-items:center;gap:16px;padding:6px 24px;border-bottom:1px solid color-mix(in oklch,var(--foreground) 6%,transparent);font-size:10px;font-weight:600;line-height:1.2;letter-spacing:.12em;text-transform:uppercase;color:color-mix(in oklch,var(--foreground) 55%,transparent);}',
|
|
75
|
+
'#mma-host .mma-status .mma-tip{margin-left:auto;}',
|
|
76
|
+
'#mma-host .mma-status-action{display:inline-flex;align-items:center;gap:6px;padding:2px 4px 2px 8px;border:1px solid transparent;border-radius:999px;color:var(--foreground);font:inherit;font-size:11px;font-weight:600;line-height:1.2;letter-spacing:0;text-transform:none;cursor:pointer;}',
|
|
77
|
+
'#mma-host .mma-status-action:hover{border:1px solid color-mix(in oklch,var(--foreground) 10%,transparent);background:var(--card);}',
|
|
78
|
+
'#mma-host .mma-status-split{width:1px;height:12px;flex:none;background:color-mix(in oklch,var(--foreground) 12%,transparent);}',
|
|
79
|
+
'#mma-host .mma-status-more{color:var(--muted-foreground);}',
|
|
80
|
+
'#mma-host .mma-status-go{display:inline-flex;width:16px;height:16px;flex:none;align-items:center;justify-content:center;border-radius:5px;background:color-mix(in oklch,var(--foreground) 8%,transparent);}',
|
|
81
|
+
'#mma-host .mma-pane{animation:mma-pane-in .42s cubic-bezier(.16,1,.3,1) both;}',
|
|
82
|
+
'@keyframes mma-pane-in{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}',
|
|
83
|
+
'@media (prefers-reduced-motion:reduce){#mma-host .mma-pane{animation:none}}',
|
|
84
|
+
'#mma-host .mma-dot{width:6px;height:6px;border-radius:50%;color:color-mix(in oklch,oklch(0.55 0.05 150) 62%,var(--muted-foreground));background:currentColor;box-shadow:0 0 6px currentColor;}',
|
|
85
|
+
'#mma-host .mma-dot[data-status="failed"]{color:color-mix(in oklch,oklch(0.62 0.06 78) 58%,var(--muted-foreground));}',
|
|
86
|
+
'#mma-host .mma-dot[data-status="down"]{color:color-mix(in oklch,var(--destructive) 38%,var(--muted-foreground));}',
|
|
87
|
+
'#mma-host .mma-library{container-type:inline-size;padding:32px 32px 40px;}',
|
|
88
|
+
'#mma-host .mma-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));align-items:start;gap:24px;}',
|
|
89
|
+
'#mma-host .mma-grid:has([data-card="glass"]){align-items:stretch;}',
|
|
90
|
+
'#mma-host .mma-grid:has([data-card="glass"])>*{display:flex;height:100%;min-height:100%;min-width:0;flex-direction:column;}',
|
|
91
|
+
'#mma-host .mma-grid:has([data-card="glass"])>* .mma-slot{flex:1;min-height:0;height:100%;}',
|
|
92
|
+
'[data-mode="dark"] #mma-host .mma-chrome{background:color-mix(in oklch,var(--foreground) 4%,transparent);border-color:color-mix(in oklch,var(--foreground) 12%,transparent);}',
|
|
93
|
+
'#mma-host .pointer-events-none iframe{pointer-events:none;}',
|
|
94
|
+
'#mma-host .mma-stage{display:flex;min-height:0;flex:1;}',
|
|
95
|
+
'#mma-host .mma-frame{min-height:0;flex:1;overflow:hidden;}',
|
|
96
|
+
'#mma-host .mma-app-frame{padding:16px;}',
|
|
97
|
+
'#mma-host .mma-app-frame>.mma-frame{border-radius:24px;border:1px solid color-mix(in oklch,var(--foreground) 10%,transparent);background:var(--card);box-shadow:0 20px 40px -18px color-mix(in oklch,var(--foreground) 18%,transparent);}',
|
|
98
|
+
'#mma-host .mma-app-frame>.mma-frame:has(>.mma-library){overflow:auto;background:color-mix(in oklch,var(--card) 36%,transparent);backdrop-filter:blur(18px) saturate(160%);}',
|
|
99
|
+
'[data-mode="dark"] #mma-host .mma-app-frame>.mma-frame:has(>.mma-library){background:color-mix(in oklch,var(--foreground) 5%,transparent);}',
|
|
100
|
+
].join('\n')
|
|
101
|
+
|
|
102
|
+
async function compileSources(dirs: readonly string[], includeKit = true): Promise<string> {
|
|
103
|
+
const compiler = await compile(inputCss(dirs, includeKit), {
|
|
104
|
+
base: hostPackageDir(),
|
|
105
|
+
onDependency() {},
|
|
106
|
+
})
|
|
107
|
+
return compiler.build(new Scanner({ sources: compiler.sources }).scan())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function inputCss(dirs: readonly string[], includeKit = true): string {
|
|
111
|
+
const roots = includeKit ? [kitSourceDir(), viewSourceDir(), ...dirs] : [viewSourceDir(), ...dirs]
|
|
112
|
+
const sources = roots.map(dir => `@source ${JSON.stringify(`${dir}/**/*.{tsx,ts,jsx,js}`)};`)
|
|
113
|
+
const colors = themeTokens.flatMap((token) => {
|
|
114
|
+
if (token === 'radius' || token === 'shadow') return []
|
|
115
|
+
return [` --color-${token}: var(--${token});`]
|
|
116
|
+
})
|
|
117
|
+
return [
|
|
118
|
+
'@import "tailwindcss" source(none);',
|
|
119
|
+
'@import "tw-animate-css";',
|
|
120
|
+
...sources,
|
|
121
|
+
'@custom-variant dark (&:where([data-mode="dark"], [data-mode="dark"] *));',
|
|
122
|
+
':root { --primary-svg-color: var(--primary); }',
|
|
123
|
+
'@theme inline {',
|
|
124
|
+
...colors,
|
|
125
|
+
' --radius-sm: calc(var(--radius) * 0.6);',
|
|
126
|
+
' --radius-md: calc(var(--radius) * 0.8);',
|
|
127
|
+
' --radius-lg: var(--radius);',
|
|
128
|
+
' --radius-xl: calc(var(--radius) * 1.4);',
|
|
129
|
+
' --radius-2xl: calc(var(--radius) * 1.8);',
|
|
130
|
+
' --radius-3xl: calc(var(--radius) * 2.2);',
|
|
131
|
+
' --radius-4xl: calc(var(--radius) * 2.6);',
|
|
132
|
+
' --font-sans: ui-sans-serif, system-ui, sans-serif;',
|
|
133
|
+
'}',
|
|
134
|
+
'@layer base {',
|
|
135
|
+
' * { @apply border-border outline-ring/50; }',
|
|
136
|
+
' html, body, #root { height: 100%; margin: 0; }',
|
|
137
|
+
' body { @apply bg-background text-foreground; }',
|
|
138
|
+
'}',
|
|
139
|
+
'',
|
|
140
|
+
].join('\n')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function kitSourceDir(): string {
|
|
144
|
+
return path.dirname(require.resolve('@mohou/ui'))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function viewSourceDir(): string {
|
|
148
|
+
return path.dirname(require.resolve('@mohou/app-view'))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hostPackageDir(): string {
|
|
152
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function newest(appDir: string): Promise<number> {
|
|
156
|
+
const names = await readdir(appDir).catch((error: unknown) => {
|
|
157
|
+
if (isCode(error, 'ENOENT')) throw new CompileError('ui-invalid', 'app stylesheet is missing')
|
|
158
|
+
throw error
|
|
159
|
+
})
|
|
160
|
+
let max = 0
|
|
161
|
+
for (const name of names) {
|
|
162
|
+
if (name === 'node_modules' || name.startsWith('.')) continue
|
|
163
|
+
const file = path.join(appDir, name)
|
|
164
|
+
const info = await stat(file).catch(() => undefined)
|
|
165
|
+
if (info === undefined) continue
|
|
166
|
+
if (info.isDirectory()) max = Math.max(max, await newest(file))
|
|
167
|
+
else max = Math.max(max, info.mtimeMs)
|
|
168
|
+
}
|
|
169
|
+
return max
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isCode(error: unknown, code: string): boolean {
|
|
173
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
|
|
174
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { appResource, httpLayout } from '../http/layout.ts'
|
|
2
|
+
import { admitAssetRef, assetInvalidCode } from './asset-path.ts'
|
|
3
|
+
|
|
4
|
+
/** Codes the host wrapper emits in the iframe. Callers match `code`. */
|
|
5
|
+
export const hostWrapperCodes = {
|
|
6
|
+
outside: 'call-outside-wrapper',
|
|
7
|
+
unmounted: 'call-unmounted',
|
|
8
|
+
assetInvalid: assetInvalidCode,
|
|
9
|
+
} as const
|
|
10
|
+
|
|
11
|
+
/** Names the runner and the future kit share. Spell them here. */
|
|
12
|
+
export const hostWrapperBinding = {
|
|
13
|
+
hook: 'useApp',
|
|
14
|
+
host: 'miniAppHost',
|
|
15
|
+
} as const
|
|
16
|
+
|
|
17
|
+
interface WrapperDeps {
|
|
18
|
+
readonly outsideCode: string
|
|
19
|
+
readonly unmountedCode: string
|
|
20
|
+
readonly hook: string
|
|
21
|
+
readonly host: string
|
|
22
|
+
readonly appId: string
|
|
23
|
+
readonly callUrl: string
|
|
24
|
+
readonly assetRoot: string
|
|
25
|
+
admitAssetRef(raw: string): string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface WrapperMessage {
|
|
29
|
+
source: unknown
|
|
30
|
+
origin: string
|
|
31
|
+
data: unknown
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface WrapperWindow extends HostWindow {
|
|
35
|
+
parent: unknown
|
|
36
|
+
location?: { reload(): void }
|
|
37
|
+
addEventListener(type: string, listener: (event: WrapperMessage) => void): void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface HostWindow {
|
|
41
|
+
[key: string]: unknown
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Install `useApp`. `call` throws outside the host wrapper.
|
|
46
|
+
* The handle is one object for the life of the document, so an effect may depend on `call`.
|
|
47
|
+
* Inside the wrapper `call` posts to the loopback call route and reads one JSON result.
|
|
48
|
+
* `streamCall` is the same route with `accept: text/event-stream`. The body is that call's events.
|
|
49
|
+
* A parent-posted author event reaches `on` and `onAny`. A gap reaches `onAny` only.
|
|
50
|
+
* Ping and the retry hint stay off this hook. No imports.
|
|
51
|
+
* @param deps - codes and the binding names
|
|
52
|
+
*/
|
|
53
|
+
export function installHostWrapper(deps: WrapperDeps): void {
|
|
54
|
+
const realm = globalThis as unknown as { window?: WrapperWindow }
|
|
55
|
+
let win: WrapperWindow
|
|
56
|
+
if (realm.window !== undefined) win = realm.window
|
|
57
|
+
else win = globalThis
|
|
58
|
+
let parentOrigin: string | undefined
|
|
59
|
+
let inside = 0
|
|
60
|
+
let mounted = false
|
|
61
|
+
const named = new Map<string, Array<(data: unknown) => void>>()
|
|
62
|
+
const any: Array<(event: { name: string; data: unknown }) => void> = []
|
|
63
|
+
|
|
64
|
+
function fail(code: string, message: string): Error {
|
|
65
|
+
const error = new Error(message) as Error & { code: string }
|
|
66
|
+
error.code = code
|
|
67
|
+
return error
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readStreamFrame(line: string, code: string): object {
|
|
71
|
+
const raw = line.slice(5).trim()
|
|
72
|
+
try {
|
|
73
|
+
const parsed: unknown = JSON.parse(raw)
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw fail(code, 'call failed')
|
|
75
|
+
return parsed
|
|
76
|
+
} catch (error) {
|
|
77
|
+
if (error instanceof Error && 'code' in error) throw error
|
|
78
|
+
throw fail(code, raw.length > 0 ? raw : 'call failed')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const handle = {
|
|
83
|
+
call(method: string, args?: unknown) {
|
|
84
|
+
if (!mounted && inside === 0) throw fail(deps.outsideCode, 'useApp() call is outside the host wrapper')
|
|
85
|
+
return fetch(deps.callUrl, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'content-type': 'application/json' },
|
|
88
|
+
body: JSON.stringify({ appId: deps.appId, method, args }),
|
|
89
|
+
}).then(async (response) => {
|
|
90
|
+
const body = await response.json() as { ok?: boolean; value?: unknown; error?: string }
|
|
91
|
+
if (body.ok !== true) throw fail(deps.unmountedCode, typeof body.error === 'string' ? body.error : 'call failed')
|
|
92
|
+
return body.value
|
|
93
|
+
})
|
|
94
|
+
},
|
|
95
|
+
streamCall(method: string, args?: unknown) {
|
|
96
|
+
if (!mounted && inside === 0) throw fail(deps.outsideCode, 'useApp() call is outside the host wrapper')
|
|
97
|
+
return openStream(method, args)
|
|
98
|
+
},
|
|
99
|
+
on(name: string, cb: (data: unknown) => void) {
|
|
100
|
+
const list = named.get(name) ?? []
|
|
101
|
+
list.push(cb)
|
|
102
|
+
named.set(name, list)
|
|
103
|
+
return () => {
|
|
104
|
+
const next = (named.get(name) ?? []).filter((item) => {
|
|
105
|
+
return item !== cb
|
|
106
|
+
})
|
|
107
|
+
named.set(name, next)
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
onAny(cb: (event: { name: string; data: unknown }) => void) {
|
|
111
|
+
any.push(cb)
|
|
112
|
+
return () => {
|
|
113
|
+
const index = any.indexOf(cb)
|
|
114
|
+
if (index >= 0) any.splice(index, 1)
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
resolveAssetUrl(path: string) {
|
|
118
|
+
const inner = deps.admitAssetRef(path)
|
|
119
|
+
return `${deps.assetRoot}/${inner.split('/').map(encodeURIComponent).join('/')}`
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function openStream(method: string, args: unknown) {
|
|
124
|
+
const queued: unknown[] = []
|
|
125
|
+
let waiting: { resolve: (step: IteratorResult<unknown>) => void; reject: (error: unknown) => void } | undefined
|
|
126
|
+
let settled = false
|
|
127
|
+
let failure: unknown
|
|
128
|
+
let started = false
|
|
129
|
+
let resolveText: (value: unknown) => void
|
|
130
|
+
let rejectText: (error: unknown) => void
|
|
131
|
+
const text = new Promise<unknown>((resolve, reject) => {
|
|
132
|
+
resolveText = resolve
|
|
133
|
+
rejectText = reject
|
|
134
|
+
})
|
|
135
|
+
void text.catch(() => undefined)
|
|
136
|
+
const push = (value: unknown): void => {
|
|
137
|
+
if (waiting !== undefined) {
|
|
138
|
+
const resume = waiting
|
|
139
|
+
waiting = undefined
|
|
140
|
+
resume.resolve({ value, done: false })
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
queued.push(value)
|
|
144
|
+
}
|
|
145
|
+
const close = (): void => {
|
|
146
|
+
const resume = waiting
|
|
147
|
+
waiting = undefined
|
|
148
|
+
if (resume === undefined) return
|
|
149
|
+
if (failure instanceof Error) resume.reject(failure)
|
|
150
|
+
else resume.resolve({ value: undefined, done: true })
|
|
151
|
+
}
|
|
152
|
+
const start = (): void => {
|
|
153
|
+
if (started) return
|
|
154
|
+
started = true
|
|
155
|
+
readFrames(method, args, push).then((value) => {
|
|
156
|
+
settled = true
|
|
157
|
+
resolveText(value)
|
|
158
|
+
close()
|
|
159
|
+
}, (error: unknown) => {
|
|
160
|
+
settled = true
|
|
161
|
+
failure = error
|
|
162
|
+
rejectText(error)
|
|
163
|
+
close()
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
[Symbol.asyncIterator](): AsyncIterator<unknown> {
|
|
168
|
+
return {
|
|
169
|
+
next() {
|
|
170
|
+
start()
|
|
171
|
+
if (queued.length > 0) return Promise.resolve({ value: queued.shift(), done: false as const })
|
|
172
|
+
if (failure !== undefined) return Promise.reject(failure instanceof Error ? failure : new Error('call failed'))
|
|
173
|
+
if (settled) return Promise.resolve({ value: undefined, done: true as const })
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
waiting = { resolve, reject }
|
|
176
|
+
})
|
|
177
|
+
},
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
then(onFulfilled?: (value: unknown) => unknown, onRejected?: (error: unknown) => unknown) {
|
|
181
|
+
start()
|
|
182
|
+
return text.then(onFulfilled, onRejected)
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function readFrames(method: string, args: unknown, push: (value: unknown) => void): Promise<unknown> {
|
|
188
|
+
const response = await fetch(deps.callUrl, {
|
|
189
|
+
method: 'POST',
|
|
190
|
+
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
|
|
191
|
+
body: JSON.stringify({ appId: deps.appId, method, args }),
|
|
192
|
+
})
|
|
193
|
+
const kind = response.headers.get('content-type') ?? ''
|
|
194
|
+
if (!response.ok || !kind.includes('text/event-stream') || response.body === null) {
|
|
195
|
+
const body = await response.json() as { error?: string }
|
|
196
|
+
throw fail(deps.unmountedCode, typeof body.error === 'string' ? body.error : 'call failed')
|
|
197
|
+
}
|
|
198
|
+
const reader = response.body.getReader()
|
|
199
|
+
const decoder = new TextDecoder()
|
|
200
|
+
let pending = ''
|
|
201
|
+
let returned: unknown
|
|
202
|
+
for (;;) {
|
|
203
|
+
const step = await reader.read()
|
|
204
|
+
if (step.done) return returned
|
|
205
|
+
pending += decoder.decode(step.value, { stream: true })
|
|
206
|
+
let split = pending.indexOf('\n\n')
|
|
207
|
+
while (split >= 0) {
|
|
208
|
+
const block = pending.slice(0, split)
|
|
209
|
+
pending = pending.slice(split + 2)
|
|
210
|
+
const line = block.split('\n').find(item => item.startsWith('data:'))
|
|
211
|
+
if (line !== undefined) {
|
|
212
|
+
const frame = readStreamFrame(line, deps.unmountedCode)
|
|
213
|
+
if ('error' in frame && typeof frame.error === 'string') throw fail(deps.unmountedCode, frame.error)
|
|
214
|
+
if ('return' in frame) returned = frame.return
|
|
215
|
+
else if ('value' in frame) push(frame.value)
|
|
216
|
+
}
|
|
217
|
+
split = pending.indexOf('\n\n')
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function useApp(): typeof handle {
|
|
223
|
+
return handle
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function runInside<T>(render: () => T): T {
|
|
227
|
+
mounted = true
|
|
228
|
+
inside += 1
|
|
229
|
+
try {
|
|
230
|
+
return render()
|
|
231
|
+
} finally {
|
|
232
|
+
inside -= 1
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
win[deps.hook] = useApp
|
|
237
|
+
win[deps.host] = { useApp, runInside }
|
|
238
|
+
win.addEventListener('message', (event) => {
|
|
239
|
+
if (event.source !== win.parent) return
|
|
240
|
+
if (parentOrigin === undefined) parentOrigin = event.origin
|
|
241
|
+
if (event.origin !== parentOrigin) return
|
|
242
|
+
if (isReload(event.data)) {
|
|
243
|
+
win.location?.reload()
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
deliver(event.data)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
function isReload(value: unknown): boolean {
|
|
250
|
+
return typeof value === 'object'
|
|
251
|
+
&& value !== null
|
|
252
|
+
&& 'type' in value
|
|
253
|
+
&& value.type === 'app:reload'
|
|
254
|
+
&& 'appId' in value
|
|
255
|
+
&& value.appId === deps.appId
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function deliver(value: unknown): void {
|
|
259
|
+
if (typeof value !== 'object' || value === null) return
|
|
260
|
+
if (!('appId' in value) || value.appId !== deps.appId) return
|
|
261
|
+
if ('type' in value && value.type === 'app:gap') {
|
|
262
|
+
for (const cb of [...any]) cb({ name: '*', data: { gap: true } })
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
if (!('name' in value) || typeof value.name !== 'string') return
|
|
266
|
+
if (!('seq' in value) || typeof value.seq !== 'number') return
|
|
267
|
+
const data = 'data' in value ? value.data : undefined
|
|
268
|
+
for (const [name, list] of named) {
|
|
269
|
+
if (name !== '*' && name !== value.name) continue
|
|
270
|
+
for (const cb of [...list]) cb(data)
|
|
271
|
+
}
|
|
272
|
+
for (const cb of [...any]) cb({ name: value.name, data })
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const wrapperHelpers = new Set(['__name'])
|
|
277
|
+
|
|
278
|
+
/** Source the runner inlines before the app module. */
|
|
279
|
+
export function renderHostWrapper(appId: string, callUrl = httpLayout.call): string {
|
|
280
|
+
const deps = {
|
|
281
|
+
outsideCode: hostWrapperCodes.outside,
|
|
282
|
+
unmountedCode: hostWrapperCodes.unmounted,
|
|
283
|
+
hook: hostWrapperBinding.hook,
|
|
284
|
+
host: hostWrapperBinding.host,
|
|
285
|
+
appId,
|
|
286
|
+
callUrl,
|
|
287
|
+
assetRoot: appResource(appId, httpLayout.assets),
|
|
288
|
+
}
|
|
289
|
+
const source = `{\nconst __name = (target) => target;\nconst admitAssetRef = ${admitAssetRef.toString()};\n(${installHostWrapper.toString()})(Object.assign(${JSON.stringify(deps)}, { admitAssetRef }))\n}`
|
|
290
|
+
for (const match of source.matchAll(/\b(__[A-Za-z]+)\s*\(/g)) {
|
|
291
|
+
const name = match[1]
|
|
292
|
+
if (name !== undefined && !wrapperHelpers.has(name)) {
|
|
293
|
+
throw new Error(`host wrapper helper is not declared: ${name}`)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return source
|
|
297
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Codes the credential provider emits. Callers match `code`. */
|
|
2
|
+
|
|
3
|
+
export const credentialCodes = [
|
|
4
|
+
'credential-invalid',
|
|
5
|
+
'credential-unreadable',
|
|
6
|
+
'credential-duplicate',
|
|
7
|
+
'credential-write-unavailable',
|
|
8
|
+
] as const
|
|
9
|
+
|
|
10
|
+
export type CredentialCode = (typeof credentialCodes)[number]
|
|
11
|
+
|
|
12
|
+
/** A credential read failed. The file is unchanged. */
|
|
13
|
+
export class CredentialError extends Error {
|
|
14
|
+
readonly code: CredentialCode
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param code - one of {@link credentialCodes}
|
|
18
|
+
* @param message - human text; not the match key. Never includes a secret.
|
|
19
|
+
* @param options - optional `cause`
|
|
20
|
+
*/
|
|
21
|
+
constructor(code: CredentialCode, message: string, options?: { cause?: unknown }) {
|
|
22
|
+
super(message, options)
|
|
23
|
+
this.name = 'CredentialError'
|
|
24
|
+
this.code = code
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { CredentialError } from './codes.ts'
|
|
2
|
+
import { createFileCredentials } from './file.ts'
|
|
3
|
+
import { admitCredentialName, cannotWrite, emptyCredentials, type CredentialListing, type CredentialProvider } from './provider.ts'
|
|
4
|
+
|
|
5
|
+
/** Sources this package knows how to open. A later source adds one member. */
|
|
6
|
+
export const credentialSourceKinds = ['builtin-json'] as const
|
|
7
|
+
|
|
8
|
+
export type CredentialSourceKind = (typeof credentialSourceKinds)[number]
|
|
9
|
+
|
|
10
|
+
/** JSON file. The path is this kind's target, not a field of every source. */
|
|
11
|
+
export interface BuiltinJsonCredentialSource {
|
|
12
|
+
readonly kind: 'builtin-json'
|
|
13
|
+
readonly file: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** One source spec. Each kind carries its own target. */
|
|
17
|
+
export type CredentialSource = BuiltinJsonCredentialSource
|
|
18
|
+
|
|
19
|
+
const openers: {
|
|
20
|
+
readonly [Kind in CredentialSourceKind]: (source: Extract<CredentialSource, { kind: Kind }>) => CredentialProvider
|
|
21
|
+
} = {
|
|
22
|
+
'builtin-json': source => createFileCredentials(source.file),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One read port over every source. Each kind carries its own target.
|
|
27
|
+
* The same name in two sources emits `credential-duplicate`. No source wins silently.
|
|
28
|
+
* An empty list is `emptyCredentials`.
|
|
29
|
+
* @param sources - source specs. Opening happens here, not at the call site.
|
|
30
|
+
*/
|
|
31
|
+
export function createCredentials(sources: readonly CredentialSource[]): CredentialProvider {
|
|
32
|
+
if (sources.length === 0) return emptyCredentials()
|
|
33
|
+
return combineCredentials(sources.map(source => openers[source.kind](source)))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function combineCredentials(providers: readonly CredentialProvider[]): CredentialProvider {
|
|
37
|
+
// One store owns writes: the first source that accepts them. With one source that is unambiguous,
|
|
38
|
+
// and a second read-only source cannot silently take a write meant for the first.
|
|
39
|
+
const writer = providers.find(provider => provider.writable)
|
|
40
|
+
return {
|
|
41
|
+
writable: writer !== undefined,
|
|
42
|
+
async list() {
|
|
43
|
+
const rows = await Promise.all(providers.map(provider => provider.list()))
|
|
44
|
+
const seen = new Set<string>()
|
|
45
|
+
const listings: CredentialListing[] = []
|
|
46
|
+
for (const row of rows) {
|
|
47
|
+
for (const item of row) {
|
|
48
|
+
if (seen.has(item.name)) {
|
|
49
|
+
throw new CredentialError('credential-duplicate', `credential name is in more than one source: ${item.name}`)
|
|
50
|
+
}
|
|
51
|
+
seen.add(item.name)
|
|
52
|
+
listings.push(item)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return listings.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0)
|
|
56
|
+
},
|
|
57
|
+
async get(name) {
|
|
58
|
+
admitCredentialName(name)
|
|
59
|
+
const values = await Promise.all(providers.map(provider => provider.get(name)))
|
|
60
|
+
const found = values.filter((value): value is string => value !== undefined)
|
|
61
|
+
if (found.length > 1) {
|
|
62
|
+
throw new CredentialError('credential-duplicate', `credential name is in more than one source: ${name}`)
|
|
63
|
+
}
|
|
64
|
+
return found[0]
|
|
65
|
+
},
|
|
66
|
+
put: (name, description, secret) => writer === undefined ? Promise.reject(cannotWrite()) : writer.put(name, description, secret),
|
|
67
|
+
remove: name => writer === undefined ? Promise.reject(cannotWrite()) : writer.remove(name),
|
|
68
|
+
}
|
|
69
|
+
}
|