@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,8 @@
1
+ import path from 'node:path'
2
+ import { fileURLToPath } from 'node:url'
3
+
4
+ import { buildVendorFiles } from './build-vendor.ts'
5
+
6
+ const outDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../vendor')
7
+ const written = await buildVendorFiles({ outDir })
8
+ for (const file of written) console.log(file)
@@ -0,0 +1,160 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+
7
+ import * as esbuild from 'esbuild'
8
+
9
+ import { platformModules, type PlatformModule } from './allowlist.ts'
10
+
11
+ const require = createRequire(import.meta.url)
12
+
13
+ /** Package directory the loopback server reads. Built at pack time, or once before listen if absent. */
14
+ export function vendorOutputDir(): string {
15
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), '../../vendor')
16
+ }
17
+
18
+ /**
19
+ * Write one file per distinct `file` in the table. Rows that share a `file` are one build,
20
+ * from the first specifier. A `/*` row is not a vendor file. Does not mount a route.
21
+ * @param options - output directory and the table, defaulting to `platformModules`
22
+ * @returns paths written
23
+ */
24
+ /** Bump when vendor transform contract changes so installed trees rebuild. */
25
+ const VENDOR_STAMP = 'jsx-automatic-1'
26
+
27
+ /** Build when a served file or the stamp is missing. A request never builds. */
28
+ export async function ensureVendorFiles(outDir: string): Promise<void> {
29
+ const names = ['runtime.js', 'lodash.js', 'motion.js', 'sdk.js']
30
+ const stampPath = path.join(outDir, '.stamp')
31
+ const stampText = await readFile(stampPath, 'utf8').catch(() => '')
32
+ const stampOk = stampText.trim() === VENDOR_STAMP
33
+ if (stampOk && names.every(name => existsSync(path.join(outDir, name)))) return
34
+ await buildVendorFiles({ outDir })
35
+ await writeFile(stampPath, `${VENDOR_STAMP}
36
+ `)
37
+ }
38
+
39
+ export async function buildVendorFiles(options: {
40
+ readonly outDir: string
41
+ readonly rows?: readonly PlatformModule[]
42
+ }): Promise<string[]> {
43
+ const rows = options.rows ?? platformModules
44
+ const groups = new Map<string, string[]>()
45
+ for (const row of rows) {
46
+ if (row.file === undefined || row.specifier.includes('*')) continue
47
+ const list = groups.get(row.file) ?? []
48
+ list.push(row.specifier)
49
+ groups.set(row.file, list)
50
+ }
51
+ const written: string[] = []
52
+ for (const [file, specifiers] of groups) {
53
+ const dest = path.join(options.outDir, path.basename(file))
54
+ await mkdir(options.outDir, { recursive: true })
55
+ await writeFile(dest, await buildGroup(file, specifiers))
56
+ written.push(dest)
57
+ }
58
+ return written
59
+ }
60
+
61
+ async function buildGroup(file: string, specifiers: string[]): Promise<string> {
62
+ const first = specifiers[0]
63
+ if (first === undefined) throw new Error(`vendor produced no module: ${file}`)
64
+ const result = await esbuild.build({
65
+ stdin: {
66
+ contents: facade(file, specifiers),
67
+ resolveDir: path.dirname(require.resolve(first)),
68
+ sourcefile: path.basename(file),
69
+ loader: 'js',
70
+ },
71
+ bundle: true,
72
+ format: 'esm',
73
+ platform: 'browser',
74
+ // Packaged @mohou/ui has no tsconfig; without this, esbuild falls back to
75
+ // classic JSX (`React.createElement`) and leaves bare `React` unbound in the iframe.
76
+ jsx: 'automatic',
77
+ external: vendorExternals(file),
78
+ plugins: [workspacePackageExtensions()],
79
+ ...file.endsWith('/sdk.js') ? { banner: { js: sdkReactRequire } } : {},
80
+ write: false,
81
+ })
82
+ const code = result.outputFiles[0]?.text
83
+ if (code === undefined) throw new Error(`vendor produced no module: ${file}`)
84
+ return code
85
+ }
86
+
87
+ const sdkReactRequire = [
88
+ "import * as ReactNs from 'react'",
89
+ 'const ReactMod = Object.assign({}, ReactNs.default, ReactNs)',
90
+ 'const require = (id) => {',
91
+ " if (id === 'react') return ReactMod",
92
+ " throw new Error('Dynamic require of \"' + id + '\" is not supported')",
93
+ '}',
94
+ ].join('\n')
95
+
96
+ function vendorExternals(file: string): string[] {
97
+ if (file.endsWith('/runtime.js')) return []
98
+ if (file.endsWith('/sdk.js')) return ['react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'motion', 'motion/react']
99
+ return ['react', 'react/jsx-runtime']
100
+ }
101
+
102
+ /**
103
+ * npm package exports map `@mohou/ui/*` → `./src/*` without a suffix.
104
+ * esbuild then looks for an extensionless file and fails outside the monorepo TS resolver.
105
+ */
106
+ function workspacePackageExtensions(): esbuild.Plugin {
107
+ const scopes = ['@mohou/ui/', '@mohou/app-view/'] as const
108
+ return {
109
+ name: 'mini-app-package-extensions',
110
+ setup(build) {
111
+ build.onResolve({ filter: /^@mohou\/(ui|app-view)\// }, (args) => {
112
+ const scope = scopes.find(prefix => args.path.startsWith(prefix))
113
+ if (scope === undefined) return undefined
114
+ const pkgName = scope.slice(0, -1)
115
+ const sub = args.path.slice(scope.length)
116
+ let pkgRoot: string
117
+ try {
118
+ pkgRoot = path.dirname(require.resolve(`${pkgName}/package.json`))
119
+ } catch {
120
+ return undefined
121
+ }
122
+ for (const ext of ['.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.js']) {
123
+ const candidate = path.join(pkgRoot, 'src', `${sub}${ext}`)
124
+ if (existsSync(candidate)) return { path: candidate }
125
+ }
126
+ return undefined
127
+ })
128
+ },
129
+ }
130
+ }
131
+
132
+ function facade(file: string, specifiers: string[]): string {
133
+ if (file.endsWith('/runtime.js')) {
134
+ return [
135
+ "import React from 'react'",
136
+ "import { jsx, jsxs, Fragment } from 'react/jsx-runtime'",
137
+ "import { createPortal, flushSync, unstable_batchedUpdates } from 'react-dom'",
138
+ "import { createRoot, hydrateRoot } from 'react-dom/client'",
139
+ 'export { jsx, jsxs, Fragment, createRoot, hydrateRoot, createPortal, flushSync, unstable_batchedUpdates }',
140
+ 'export const {',
141
+ ' Children, Component, PureComponent, StrictMode, Suspense,',
142
+ ' cloneElement, createContext, createElement, createRef, forwardRef, isValidElement,',
143
+ ' lazy, memo, startTransition, use, useCallback, useContext, useDebugValue,',
144
+ ' useDeferredValue, useEffect, useId, useImperativeHandle, useInsertionEffect,',
145
+ ' useLayoutEffect, useMemo, useReducer, useRef, useState, useSyncExternalStore, useTransition,',
146
+ ' version,',
147
+ '} = React',
148
+ 'export default Object.assign({}, React, { createRoot, hydrateRoot, jsx, jsxs, Fragment })',
149
+ ].join('\n')
150
+ }
151
+ if (file.endsWith('/motion.js')) {
152
+ return "export * from 'motion'\nexport * from 'motion/react'\n"
153
+ }
154
+ const entry = specifiers.find(specifier => specifier.endsWith('-es')) ?? specifiers[0] ?? ''
155
+ const lines = [`export * from ${JSON.stringify(entry)}`]
156
+ if (specifiers.some(specifier => specifier === 'lodash' || specifier === 'lodash-es')) {
157
+ lines.push(`export { default } from ${JSON.stringify(entry)}`)
158
+ }
159
+ return `${lines.join('\n')}\n`
160
+ }
@@ -0,0 +1,137 @@
1
+ import { realpathSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+
5
+ import { appEntries, ContractError } from '@mohou/contract'
6
+ import * as esbuild from 'esbuild'
7
+
8
+ import { platformBundled } from './allowlist.ts'
9
+ import { CompileError } from './codes.ts'
10
+ import { buildFailureText } from './failure.ts'
11
+ import { importDecision, sideOf } from './imports.ts'
12
+
13
+ const require = createRequire(import.meta.url)
14
+
15
+ export interface UiBundle {
16
+ readonly code: string
17
+ }
18
+
19
+ /**
20
+ * Bundle `ui.tsx` in memory. React and motion stay external. Lodash is included. Component names are kept.
21
+ * A relative import whose real path leaves the app is `import-escape`.
22
+ * @param appDir - absolute app directory
23
+ * @param logLevel - `silent` when the caller already expects the build to fail. Omitted, esbuild prints the failure.
24
+ */
25
+ export async function bundleUi(appDir: string, logLevel?: 'silent'): Promise<UiBundle> {
26
+ const stopped: { code: string; message: string } = { code: '', message: '' }
27
+ let result: esbuild.BuildResult
28
+ try {
29
+ result = await esbuild.build({
30
+ absWorkingDir: appDir,
31
+ entryPoints: [path.join(appDir, appEntries.ui)],
32
+ bundle: true,
33
+ format: 'esm',
34
+ platform: 'browser',
35
+ jsx: 'automatic',
36
+ keepNames: true,
37
+ write: false,
38
+ ...logLevel === undefined ? {} : { logLevel },
39
+ plugins: [uiPlugin(appDir, stopped)],
40
+ })
41
+ } catch (error) {
42
+ const code = stopped.code
43
+ if (code === 'import-forbidden' || code === 'import-escape') {
44
+ throw new ContractError(code, stopped.message, { cause: error })
45
+ }
46
+ const message = stopped.message || buildFailureText(error) || 'ui failed to bundle'
47
+ throw new CompileError('ui-invalid', message, { cause: error })
48
+ }
49
+ const code = result.outputFiles?.[0]?.text
50
+ if (code === undefined) throw new CompileError('ui-invalid', 'ui produced no bundle')
51
+ return { code }
52
+ }
53
+
54
+ function uiPlugin(appDir: string, stopped: { code: string; message: string }): esbuild.Plugin {
55
+ const root = realpathSync(appDir)
56
+ return {
57
+ name: 'mini-app-ui',
58
+ setup(build) {
59
+ build.onResolve({ filter: /.*/ }, (args) => {
60
+ if (args.kind === 'entry-point') return undefined
61
+ if (args.importer.length > 0 && inside(root, args.importer) === undefined) return undefined
62
+ const importer = args.importer.length > 0 ? inside(root, args.importer) ?? appEntries.ui : appEntries.ui
63
+ if (args.path.startsWith('.')) {
64
+ const decision = importDecision(sideOf(importer), args.path, importer)
65
+ if (decision !== 'allow') {
66
+ const denied = typeof decision === 'object'
67
+ ? decision
68
+ : { code: 'import-forbidden' as const, message: `ui cannot import ${args.path}` }
69
+ stopped.code = denied.code
70
+ stopped.message = denied.message
71
+ return { errors: [{ text: stopped.message }] }
72
+ }
73
+ return resolveInside(root, args, stopped)
74
+ }
75
+ const decision = importDecision(sideOf(importer), args.path, importer)
76
+ if (decision === 'allow' && platformBundled(args.path)) {
77
+ try {
78
+ return { path: require.resolve(args.path) }
79
+ } catch (error) {
80
+ stopped.code = 'ui-invalid'
81
+ stopped.message = `ui cannot resolve ${args.path}`
82
+ return { errors: [{ text: stopped.message, detail: error }] }
83
+ }
84
+ }
85
+ if (decision === 'allow') return { path: args.path, external: true }
86
+ const denied = typeof decision === 'object'
87
+ ? decision
88
+ : { code: 'import-forbidden' as const, message: `ui cannot import ${args.path}` }
89
+ stopped.code = denied.code
90
+ stopped.message = denied.message
91
+ return { errors: [{ text: stopped.message }] }
92
+ })
93
+ },
94
+ }
95
+ }
96
+
97
+ function resolveInside(
98
+ root: string,
99
+ args: { path: string; importer: string },
100
+ stopped: { code: string; message: string },
101
+ ): esbuild.OnResolveResult {
102
+ const base = path.dirname(args.importer)
103
+ const target = path.resolve(base, args.path)
104
+ const real = resolveSourceFile(target)
105
+ if (real === undefined) {
106
+ stopped.code = 'ui-invalid'
107
+ stopped.message = `ui cannot resolve ${args.path}`
108
+ return { errors: [{ text: stopped.message }] }
109
+ }
110
+ const rel = path.relative(root, real)
111
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
112
+ stopped.code = 'import-escape'
113
+ stopped.message = `ui cannot import ${args.path}`
114
+ return { errors: [{ text: stopped.message }] }
115
+ }
116
+ return { path: real }
117
+ }
118
+
119
+ /** Extensionless relative imports (templates often omit `.tsx`). */
120
+ function resolveSourceFile(target: string): string | undefined {
121
+ const suffixes = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js']
122
+ for (const suffix of suffixes) {
123
+ try {
124
+ return realpathSync(`${target}${suffix}`)
125
+ } catch {
126
+ // try next
127
+ }
128
+ }
129
+ return undefined
130
+ }
131
+
132
+ function inside(root: string, file: string): string | undefined {
133
+ const real = realpathSync(file)
134
+ const rel = path.relative(root, real)
135
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return undefined
136
+ return rel.split(path.sep).join('/')
137
+ }
@@ -0,0 +1,22 @@
1
+ /** Codes the backend loader emits. Import checks still use the definition codes. */
2
+
3
+ export const compileCodes = ['backend-invalid', 'ui-invalid', 'shared-invalid', 'event-undeclared'] as const
4
+
5
+ /** A backend load failure. Callers match `code`. */
6
+ export type CompileCode = (typeof compileCodes)[number]
7
+
8
+ /** Failure while loading `main.api.ts`. The message is for a person. */
9
+ export class CompileError extends Error {
10
+ readonly code: CompileCode
11
+
12
+ /**
13
+ * @param code - one of {@link compileCodes}
14
+ * @param message - human text; not the match key
15
+ * @param options - optional `cause`
16
+ */
17
+ constructor(code: CompileCode, message: string, options?: { cause?: unknown }) {
18
+ super(message, options)
19
+ this.name = 'CompileError'
20
+ this.code = code
21
+ }
22
+ }
@@ -0,0 +1,38 @@
1
+ import type * as esbuild from 'esbuild'
2
+
3
+ /** Error lines one compile message carries. Host policy, not a locked number. */
4
+ export const FAILURE_LINES_MAX = 5
5
+
6
+ /**
7
+ * What esbuild said went wrong, one line per message. A resolve plugin names its own failures; this covers the rest.
8
+ * @param error - the value `esbuild.build` rejected with
9
+ */
10
+ export function buildFailureText(error: unknown): string | undefined {
11
+ const errors = failureErrors(error)
12
+ if (errors.length === 0) return undefined
13
+ const lines = errors.slice(0, FAILURE_LINES_MAX).map(oneLine)
14
+ const hidden = errors.length - lines.length
15
+ if (hidden > 0) lines.push(`and ${hidden} more`)
16
+ return lines.join('\n')
17
+ }
18
+
19
+ function failureErrors(error: unknown): readonly esbuild.Message[] {
20
+ if (typeof error !== 'object' || error === null) return []
21
+ const found: unknown = 'errors' in error ? error.errors : undefined
22
+ if (!Array.isArray(found)) return []
23
+ return found.filter(isMessage)
24
+ }
25
+
26
+ function isMessage(value: unknown): value is esbuild.Message {
27
+ return typeof value === 'object'
28
+ && value !== null
29
+ && 'text' in value
30
+ && typeof value.text === 'string'
31
+ && value.text.length > 0
32
+ }
33
+
34
+ function oneLine(message: esbuild.Message): string {
35
+ const at = message.location
36
+ if (at === null) return message.text
37
+ return `${at.file}:${at.line}:${at.column}: ${message.text}`
38
+ }
@@ -0,0 +1,43 @@
1
+ import { builtinModules } from 'node:module'
2
+
3
+ import { appEntries, appTrees, classifyImport, type DefinitionCode, type ImportSide } from '@mohou/contract'
4
+
5
+ import { platformModuleAllowed } from './allowlist.ts'
6
+
7
+ const nodeBuiltins = new Set(builtinModules)
8
+
9
+ export type ImportDecision =
10
+ | { readonly code: DefinitionCode; readonly message: string }
11
+ | 'allow'
12
+ | 'builtin'
13
+ | 'install'
14
+
15
+ /** One decision for a specifier. The loader and the reload check both call this. */
16
+ export function importDecision(side: ImportSide, specifier: string, fromFile = 'ui.tsx'): ImportDecision {
17
+ const crossed = classifyImport(specifier, side, fromFile)
18
+ if (crossed !== undefined) return { code: crossed, message: `${side} cannot import ${specifier}` }
19
+ if (specifier.startsWith('.')) return 'allow'
20
+ if (isNodeBuiltin(specifier)) {
21
+ if (side === 'backend') return 'builtin'
22
+ return { code: 'import-forbidden', message: `${side} cannot import ${specifier}` }
23
+ }
24
+ if (side !== 'shared' && platformModuleAllowed(specifier, side)) return 'allow'
25
+ if (inPlatformTable(specifier)) return { code: 'import-forbidden', message: `${side} cannot import ${specifier}` }
26
+ if (side === 'backend') return 'install'
27
+ return { code: 'import-forbidden', message: `${side} cannot import ${specifier}` }
28
+ }
29
+
30
+ export function sideOf(rel: string): ImportSide {
31
+ if (rel === appEntries.ui || rel.startsWith(`${appTrees.ui}/`)) return 'ui'
32
+ if (rel.startsWith(`${appTrees.shared}/`)) return 'shared'
33
+ return 'backend'
34
+ }
35
+
36
+ function inPlatformTable(specifier: string): boolean {
37
+ return platformModuleAllowed(specifier, 'ui') || platformModuleAllowed(specifier, 'backend')
38
+ }
39
+
40
+ function isNodeBuiltin(specifier: string): boolean {
41
+ const name = specifier.startsWith('node:') ? specifier.slice('node:'.length) : specifier
42
+ return nodeBuiltins.has(name)
43
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Runner guard for links and forms.
3
+ * A same-frame external URL would replace the app document. The sandbox does not stop that.
4
+ * `javascript:` still runs. A same-origin URL stays. `http` and `https` off this origin, and `mailto:`, open outside.
5
+ * An app script can remove the listeners. That is the same limit as the sandbox attribute.
6
+ */
7
+ export function leaveGuardSource(): string {
8
+ return `function linkLeave(href, pageHref) {
9
+ let url
10
+ try {
11
+ url = new URL(href, pageHref)
12
+ } catch {
13
+ return 'block'
14
+ }
15
+ if (url.protocol === 'javascript:') return 'script'
16
+ if (url.protocol === 'mailto:') return 'mail'
17
+ const page = new URL(pageHref)
18
+ if (url.protocol === 'http:' || url.protocol === 'https:') {
19
+ return url.origin === page.origin ? 'stay' : 'browser'
20
+ }
21
+ return 'block'
22
+ }
23
+ document.addEventListener('click', (event) => {
24
+ if (event.defaultPrevented || event.button !== 0) return
25
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
26
+ const node = event.target
27
+ if (!node || typeof node.closest !== 'function') return
28
+ const anchor = node.closest('a[href]')
29
+ if (!anchor) return
30
+ const target = (anchor.getAttribute('target') || '').toLowerCase()
31
+ if (target === '_blank') return
32
+ const leave = linkLeave(anchor.getAttribute('href') || '', location.href)
33
+ if (leave === 'stay' || leave === 'script') return
34
+ event.preventDefault()
35
+ if (leave === 'browser' || leave === 'mail') window.open(anchor.href, '_blank', 'noopener')
36
+ }, true)
37
+ document.addEventListener('submit', (event) => {
38
+ const form = event.target
39
+ if (!(form instanceof HTMLFormElement)) return
40
+ const target = (form.getAttribute('target') || '').toLowerCase()
41
+ if (target === '_blank') return
42
+ const leave = linkLeave(form.action, location.href)
43
+ if (leave === 'stay' || leave === 'script') return
44
+ event.preventDefault()
45
+ if (leave === 'browser' || leave === 'mail') window.open(form.action, '_blank', 'noopener')
46
+ }, true)`
47
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+
5
+ import { appEntries, ContractError, type AppApiMethod } from '@mohou/contract'
6
+ import * as esbuild from 'esbuild'
7
+
8
+ import { CompileError } from './codes.ts'
9
+ import { buildFailureText } from './failure.ts'
10
+ import { importDecision, sideOf } from './imports.ts'
11
+
12
+ const require = createRequire(import.meta.url)
13
+
14
+ export interface LoadedBackend {
15
+ api: Record<string, AppApiMethod>
16
+ }
17
+
18
+ /**
19
+ * Transpile and load `main.api.ts`. `defineApp` is the real contract export.
20
+ * Installed libraries resolve only from this app's `node_modules`.
21
+ * @param appDir - absolute app directory
22
+ * @param logLevel - `silent` when the caller already expects the build to fail. Omitted, esbuild prints the failure.
23
+ */
24
+ export async function loadBackend(appDir: string, logLevel?: 'silent'): Promise<LoadedBackend> {
25
+ const stopped: { code: string; message: string } = { code: '', message: '' }
26
+ let result: esbuild.BuildResult
27
+ try {
28
+ result = await esbuild.build({
29
+ absWorkingDir: appDir,
30
+ entryPoints: [path.join(appDir, appEntries.backend)],
31
+ bundle: true,
32
+ format: 'esm',
33
+ platform: 'node',
34
+ write: false,
35
+ ...logLevel === undefined ? {} : { logLevel },
36
+ plugins: [allowlistPlugin(appDir, stopped)],
37
+ })
38
+ } catch (error) {
39
+ const code = stopped.code
40
+ if (code === 'import-forbidden' || code === 'import-escape') {
41
+ throw new ContractError(code, stopped.message, { cause: error })
42
+ }
43
+ const message = stopped.message || buildFailureText(error) || 'backend failed to load'
44
+ throw new CompileError('backend-invalid', message, { cause: error })
45
+ }
46
+ const output = result.outputFiles?.[0]?.text
47
+ if (output === undefined) throw new CompileError('backend-invalid', 'backend produced no module')
48
+ const loaded: unknown = await import(`data:text/javascript;base64,${Buffer.from(output).toString('base64')}`)
49
+ const app = isRecord(loaded) ? loaded.default : undefined
50
+ if (!isRecord(app) || !isRecord(app.api)) {
51
+ throw new CompileError('backend-invalid', 'backend must default-export defineApp(...)')
52
+ }
53
+ return { api: app.api as Record<string, AppApiMethod> }
54
+ }
55
+
56
+ function allowlistPlugin(appDir: string, stopped: { code: string; message: string }): esbuild.Plugin {
57
+ return {
58
+ name: 'mini-app-allowlist',
59
+ setup(build) {
60
+ build.onResolve({ filter: /.*/ }, (args) => {
61
+ if (args.kind === 'entry-point' || args.importer.length === 0) return undefined
62
+ const rel = insideApp(appDir, args.importer)
63
+ if (rel === undefined) return undefined
64
+ const decision = importDecision(sideOf(rel), args.path, rel)
65
+ if (decision === 'builtin') return { path: args.path, external: true }
66
+ if (decision === 'allow') {
67
+ if (args.path.startsWith('.')) return undefined
68
+ return { path: require.resolve(args.path) }
69
+ }
70
+ if (decision === 'install') {
71
+ const local = path.join(appDir, 'node_modules', args.path)
72
+ if (existsSync(local)) return { path: local }
73
+ stopped.code = 'backend-invalid'
74
+ stopped.message = `install ${args.path} with mini_app_install`
75
+ return { errors: [{ text: stopped.message }] }
76
+ }
77
+ stopped.code = decision.code
78
+ stopped.message = decision.message
79
+ return { errors: [{ text: decision.message }] }
80
+ })
81
+ },
82
+ }
83
+ }
84
+
85
+ function insideApp(appDir: string, importer: string): string | undefined {
86
+ const root = realpathSync(appDir)
87
+ const file = realpathSync(path.isAbsolute(importer) ? importer : path.resolve(appDir, importer))
88
+ const rel = path.relative(root, file)
89
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return undefined
90
+ return rel.split(path.sep).join('/')
91
+ }
92
+
93
+ function isRecord(value: unknown): value is Record<string, unknown> {
94
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
95
+ }