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