@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,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
+ }