@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,239 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import type { RuntimeProvider } from '@mohou/runtime-provider'
|
|
4
|
+
|
|
5
|
+
import { ConfigError } from './codes.ts'
|
|
6
|
+
import { hostConfigPath } from './layout.ts'
|
|
7
|
+
import { allocateHostPort } from './port.ts'
|
|
8
|
+
|
|
9
|
+
/** Appearance. A stored preference, not a resolved OS value. */
|
|
10
|
+
export const hostThemes = ['system', 'light', 'dark'] as const
|
|
11
|
+
|
|
12
|
+
export type HostTheme = (typeof hostThemes)[number]
|
|
13
|
+
|
|
14
|
+
export interface HostPolicy {
|
|
15
|
+
runtimeRoot: string
|
|
16
|
+
hostPort: number
|
|
17
|
+
theme: HostTheme
|
|
18
|
+
palette: string
|
|
19
|
+
locale: string
|
|
20
|
+
chatLanguage: string
|
|
21
|
+
llm: { provider: string; model: string } | null
|
|
22
|
+
runtimeProvider: {
|
|
23
|
+
id: string
|
|
24
|
+
config?: { provider?: string; model?: string; options?: Record<string, string> }
|
|
25
|
+
}
|
|
26
|
+
/** Absent, or `default`, means the builtin library. Not required to boot. */
|
|
27
|
+
defaultWorkbenchId?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Seed numbers and names. Host policy, not a locked product value. */
|
|
31
|
+
export interface HostSeed {
|
|
32
|
+
hostPort: number
|
|
33
|
+
theme: HostTheme
|
|
34
|
+
palette: string
|
|
35
|
+
locale: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Port bound. Host policy, not a locked product value. */
|
|
39
|
+
export interface PortBound {
|
|
40
|
+
min: number
|
|
41
|
+
max: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const DEFAULT_PORT_BOUND: PortBound = { min: 1024, max: 65535 }
|
|
45
|
+
|
|
46
|
+
const PUBLIC_KEYS = ['runtimeRoot', 'hostPort', 'theme', 'palette', 'locale', 'chatLanguage', 'llm', 'runtimeProvider'] as const
|
|
47
|
+
const OPTIONAL_KEYS = ['defaultWorkbenchId'] as const
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read `host.json`. A missing file writes a complete seed. A present bad file throws and is left as it was.
|
|
51
|
+
* @param runtimeRoot - directory that contains the file
|
|
52
|
+
* @param seed - values used only when the file is absent
|
|
53
|
+
* @param ports - accepted host port range
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveHostConfig(runtimeRoot: string, seed: HostSeed, ports: PortBound = DEFAULT_PORT_BOUND): Promise<HostPolicy> {
|
|
56
|
+
const file = hostConfigPath(runtimeRoot)
|
|
57
|
+
const text = await readFile(file, 'utf8').catch(() => undefined)
|
|
58
|
+
if (text === undefined) {
|
|
59
|
+
// First boot only: pick a free port before anything external can pin the MCP URL.
|
|
60
|
+
const hostPort = await allocateHostPort(seed.hostPort, ports)
|
|
61
|
+
const created = seedPolicy(runtimeRoot, { ...seed, hostPort })
|
|
62
|
+
await writeFile(file, `${JSON.stringify(created, null, 2)}\n`)
|
|
63
|
+
return created
|
|
64
|
+
}
|
|
65
|
+
let parsed: unknown
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(text) as unknown
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new ConfigError('config-invalid', 'host config is not JSON', { cause: error })
|
|
70
|
+
}
|
|
71
|
+
return admitPolicy(parsed, runtimeRoot, ports)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Write the public fields. `locale` and `chatLanguage` must be the same value.
|
|
76
|
+
* A new `runtimeProvider.id` does not switch the live brain.
|
|
77
|
+
* @param runtimeRoot - directory that contains the file
|
|
78
|
+
* @param current - the resolved file
|
|
79
|
+
* @param raw - untrusted form body
|
|
80
|
+
* @param ports - accepted host port range
|
|
81
|
+
*/
|
|
82
|
+
export async function writeHostPolicy(
|
|
83
|
+
runtimeRoot: string,
|
|
84
|
+
current: HostPolicy,
|
|
85
|
+
raw: unknown,
|
|
86
|
+
ports: PortBound = DEFAULT_PORT_BOUND,
|
|
87
|
+
): Promise<{ policy: HostPolicy; restartRequired: boolean }> {
|
|
88
|
+
const next = admitPolicy(raw, runtimeRoot, ports)
|
|
89
|
+
if (next.locale !== next.chatLanguage) {
|
|
90
|
+
throw new ConfigError('config-invalid', 'locale and chatLanguage must be the same value')
|
|
91
|
+
}
|
|
92
|
+
await writeFile(hostConfigPath(runtimeRoot), `${JSON.stringify(next, null, 2)}\n`)
|
|
93
|
+
return {
|
|
94
|
+
policy: next,
|
|
95
|
+
restartRequired: next.hostPort !== current.hostPort || next.runtimeProvider.id !== current.runtimeProvider.id,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Probe one registered provider. Does not write config and does not switch the live brain.
|
|
101
|
+
* @param id - provider id from the form
|
|
102
|
+
* @param providers - brains Shell registered
|
|
103
|
+
* @param liveId - the provider Host is running, if any
|
|
104
|
+
*/
|
|
105
|
+
export async function probeBrain(
|
|
106
|
+
id: string,
|
|
107
|
+
providers: readonly RuntimeProvider[],
|
|
108
|
+
liveId?: string,
|
|
109
|
+
): Promise<{ healthy: true } | { healthy: false; code: string; message: string }> {
|
|
110
|
+
const provider = providers.find(item => item.id === id)
|
|
111
|
+
if (provider === undefined) {
|
|
112
|
+
return { healthy: false, code: 'provider-missing', message: `no runtime provider: ${id}` }
|
|
113
|
+
}
|
|
114
|
+
if (id === liveId) {
|
|
115
|
+
const healthy = provider.healthy()
|
|
116
|
+
if (!healthy) {
|
|
117
|
+
return { healthy: false, code: 'provider-unhealthy', message: `runtime provider is not healthy: ${id}` }
|
|
118
|
+
}
|
|
119
|
+
return { healthy: true }
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
provider.configure?.({})
|
|
123
|
+
await provider.start()
|
|
124
|
+
const healthy = provider.healthy()
|
|
125
|
+
await provider.stop()
|
|
126
|
+
return healthy
|
|
127
|
+
? { healthy: true }
|
|
128
|
+
: { healthy: false, code: 'provider-unhealthy', message: `runtime provider is not healthy: ${id}` }
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await provider.stop().catch(() => undefined)
|
|
131
|
+
const message = error instanceof Error ? error.message : 'runtime provider probe failed'
|
|
132
|
+
const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
|
|
133
|
+
? error.code
|
|
134
|
+
: 'provider-unhealthy'
|
|
135
|
+
return { healthy: false, code, message }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function seedPolicy(runtimeRoot: string, seed: HostSeed): HostPolicy {
|
|
140
|
+
return {
|
|
141
|
+
runtimeRoot,
|
|
142
|
+
hostPort: seed.hostPort,
|
|
143
|
+
theme: seed.theme,
|
|
144
|
+
palette: seed.palette,
|
|
145
|
+
locale: seed.locale,
|
|
146
|
+
chatLanguage: seed.locale,
|
|
147
|
+
llm: null,
|
|
148
|
+
runtimeProvider: { id: 'echo' },
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function admitPolicy(raw: unknown, runtimeRoot: string, ports: PortBound): HostPolicy {
|
|
153
|
+
if (!isRecord(raw)) throw new ConfigError('config-invalid', 'host config must be an object')
|
|
154
|
+
for (const key of Object.keys(raw)) {
|
|
155
|
+
if (!(PUBLIC_KEYS as readonly string[]).includes(key) && !(OPTIONAL_KEYS as readonly string[]).includes(key)) {
|
|
156
|
+
throw new ConfigError('config-invalid', `host config field is not public: ${key}`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const key of PUBLIC_KEYS) {
|
|
160
|
+
if (!(key in raw)) throw new ConfigError('config-missing', `host config missing ${key}`)
|
|
161
|
+
}
|
|
162
|
+
if (raw.runtimeRoot !== runtimeRoot) {
|
|
163
|
+
throw new ConfigError('config-invalid', 'host config runtimeRoot does not match the runtime root')
|
|
164
|
+
}
|
|
165
|
+
const hostPort = raw.hostPort
|
|
166
|
+
if (typeof hostPort !== 'number' || !Number.isInteger(hostPort) || hostPort < ports.min || hostPort > ports.max) {
|
|
167
|
+
throw new ConfigError('config-invalid', 'host config hostPort is invalid')
|
|
168
|
+
}
|
|
169
|
+
const theme = raw.theme
|
|
170
|
+
if (typeof theme !== 'string' || !(hostThemes as readonly string[]).includes(theme)) {
|
|
171
|
+
throw new ConfigError('config-invalid', 'host config theme is invalid')
|
|
172
|
+
}
|
|
173
|
+
const palette = requiredText(raw.palette, 'palette')
|
|
174
|
+
const locale = requiredText(raw.locale, 'locale')
|
|
175
|
+
const chatLanguage = requiredText(raw.chatLanguage, 'chatLanguage')
|
|
176
|
+
return {
|
|
177
|
+
runtimeRoot,
|
|
178
|
+
hostPort,
|
|
179
|
+
theme: theme as HostTheme,
|
|
180
|
+
palette,
|
|
181
|
+
locale,
|
|
182
|
+
chatLanguage,
|
|
183
|
+
llm: admitLlm(raw.llm),
|
|
184
|
+
runtimeProvider: admitProvider(raw.runtimeProvider),
|
|
185
|
+
...admitWorkbench(raw.defaultWorkbenchId),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function admitWorkbench(value: unknown): { defaultWorkbenchId: string } | Record<string, never> {
|
|
190
|
+
if (value === undefined || value === 'default') return {}
|
|
191
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
192
|
+
throw new ConfigError('config-invalid', 'host config defaultWorkbenchId is invalid')
|
|
193
|
+
}
|
|
194
|
+
return { defaultWorkbenchId: value }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function admitLlm(value: unknown): HostPolicy['llm'] {
|
|
198
|
+
if (value === null) return null
|
|
199
|
+
if (!isRecord(value)) throw new ConfigError('config-invalid', 'host config llm is invalid')
|
|
200
|
+
return { provider: requiredText(value.provider, 'llm.provider'), model: requiredText(value.model, 'llm.model') }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function admitProvider(value: unknown): HostPolicy['runtimeProvider'] {
|
|
204
|
+
if (!isRecord(value)) throw new ConfigError('config-invalid', 'host config runtimeProvider is invalid')
|
|
205
|
+
const id = requiredText(value.id, 'runtimeProvider.id')
|
|
206
|
+
if (value.config === undefined) return { id }
|
|
207
|
+
if (!isRecord(value.config)) throw new ConfigError('config-invalid', 'host config runtimeProvider.config is invalid')
|
|
208
|
+
const config = value.config
|
|
209
|
+
if (config.provider !== undefined && typeof config.provider !== 'string') {
|
|
210
|
+
throw new ConfigError('config-invalid', 'host config runtimeProvider.config.provider is invalid')
|
|
211
|
+
}
|
|
212
|
+
if (config.model !== undefined && typeof config.model !== 'string') {
|
|
213
|
+
throw new ConfigError('config-invalid', 'host config runtimeProvider.config.model is invalid')
|
|
214
|
+
}
|
|
215
|
+
if (config.options !== undefined && !isStringRecord(config.options)) {
|
|
216
|
+
throw new ConfigError('config-invalid', 'host config runtimeProvider.config.options is invalid')
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
id,
|
|
220
|
+
config: {
|
|
221
|
+
...typeof config.provider === 'string' ? { provider: config.provider } : {},
|
|
222
|
+
...typeof config.model === 'string' ? { model: config.model } : {},
|
|
223
|
+
...isStringRecord(config.options) ? { options: config.options } : {},
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function requiredText(value: unknown, field: string): string {
|
|
229
|
+
if (typeof value !== 'string' || value.length === 0) throw new ConfigError('config-invalid', `host config ${field} is invalid`)
|
|
230
|
+
return value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
234
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
238
|
+
return isRecord(value) && Object.values(value).every(item => typeof item === 'string')
|
|
239
|
+
}
|
package/src/host/heat.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** One recorded open. The caller has already read it. */
|
|
2
|
+
export interface HeatSample {
|
|
3
|
+
readonly appId: string
|
|
4
|
+
readonly openedAt: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Observed use for one app. Absent means no sample, not zero. */
|
|
8
|
+
export interface HeatValue {
|
|
9
|
+
readonly lastOpenedAt: string
|
|
10
|
+
readonly openCount: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Heat keyed by app id. An empty map means the caller passed no samples. */
|
|
14
|
+
export type HeatSnapshot = Readonly<Record<string, HeatValue>>
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Fold open samples into heat. The latest `openedAt` string wins.
|
|
18
|
+
* This function does not read the clock or the disk.
|
|
19
|
+
* @param samples - opens the caller already read
|
|
20
|
+
*/
|
|
21
|
+
export function heat(samples: readonly HeatSample[]): HeatSnapshot {
|
|
22
|
+
const snapshot: Record<string, HeatValue> = {}
|
|
23
|
+
for (const sample of samples) {
|
|
24
|
+
const previous = snapshot[sample.appId]
|
|
25
|
+
if (previous === undefined) {
|
|
26
|
+
snapshot[sample.appId] = { lastOpenedAt: sample.openedAt, openCount: 1 }
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
snapshot[sample.appId] = {
|
|
30
|
+
lastOpenedAt: sample.openedAt > previous.lastOpenedAt ? sample.openedAt : previous.lastOpenedAt,
|
|
31
|
+
openCount: previous.openCount + 1,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return snapshot
|
|
35
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
/** Names under the runtime root. Spell them here, not at each call site. */
|
|
4
|
+
export const hostLayout = {
|
|
5
|
+
config: 'host.json',
|
|
6
|
+
apps: 'apps',
|
|
7
|
+
trash: 'trash',
|
|
8
|
+
trashSep: '_',
|
|
9
|
+
authoringToken: 'authoring.token',
|
|
10
|
+
mcp: 'mcp.json',
|
|
11
|
+
logs: 'logs',
|
|
12
|
+
appLog: 'app.log',
|
|
13
|
+
activity: 'activity.json',
|
|
14
|
+
/** The launcher records the outcome of an install here. The panel shows it once. */
|
|
15
|
+
updateResult: 'update-result.json',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export function hostConfigPath(runtimeRoot: string): string {
|
|
19
|
+
return path.join(runtimeRoot, hostLayout.config)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hostAppsDir(runtimeRoot: string): string {
|
|
23
|
+
return path.join(runtimeRoot, hostLayout.apps)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hostTrashDir(runtimeRoot: string): string {
|
|
27
|
+
return path.join(runtimeRoot, hostLayout.trash)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hostAuthoringToken(runtimeRoot: string): string {
|
|
31
|
+
return path.join(runtimeRoot, hostLayout.authoringToken)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function hostMcpPath(runtimeRoot: string): string {
|
|
35
|
+
return path.join(runtimeRoot, hostLayout.mcp)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Usage on this machine. Not source history. */
|
|
39
|
+
export function hostActivityPath(runtimeRoot: string): string {
|
|
40
|
+
return path.join(runtimeRoot, hostLayout.activity)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The launcher's record of the last install. Absent until an install has run. */
|
|
44
|
+
export function hostUpdateResultPath(runtimeRoot: string): string {
|
|
45
|
+
return path.join(runtimeRoot, hostLayout.updateResult)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Active app log. It sits at `apps/<appId>/logs/app.log`. */
|
|
49
|
+
export function hostAppLogPath(runtimeRoot: string, appId: string): string {
|
|
50
|
+
return path.join(runtimeRoot, hostLayout.apps, appId, hostLayout.logs, hostLayout.appLog)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Product home. Themes sit beside the runtime root, not inside it. */
|
|
54
|
+
export const homeLayout = {
|
|
55
|
+
dir: '.mini-app',
|
|
56
|
+
runtime: 'runtime',
|
|
57
|
+
themes: 'themes',
|
|
58
|
+
packages: 'packages',
|
|
59
|
+
credentials: 'credentials.json',
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
export function homeDir(home: string): string {
|
|
63
|
+
return path.join(home, homeLayout.dir)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function defaultRuntimeRoot(home: string): string {
|
|
67
|
+
return path.join(homeDir(home), homeLayout.runtime)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function homeThemesDir(home: string): string {
|
|
71
|
+
return path.join(homeDir(home), homeLayout.themes)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Drop folder for local tarball updates. Not user data. */
|
|
75
|
+
export function homePackagesDir(home: string): string {
|
|
76
|
+
return path.join(homeDir(home), homeLayout.packages)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function homeCredentialsPath(home: string): string {
|
|
80
|
+
return path.join(homeDir(home), homeLayout.credentials)
|
|
81
|
+
}
|
package/src/host/log.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { hostLayout } from './layout.ts'
|
|
5
|
+
|
|
6
|
+
/** Byte cap for one app. Host policy, not a locked number. */
|
|
7
|
+
export const DEFAULT_HOST_LOG_BYTES = 5 * 1024 * 1024
|
|
8
|
+
|
|
9
|
+
/** Sealed piece. A write never reads log bytes. Host policy, not a locked number. */
|
|
10
|
+
const SEGMENT_BYTES = 1024 * 1024
|
|
11
|
+
|
|
12
|
+
const ACTIVE = hostLayout.appLog
|
|
13
|
+
|
|
14
|
+
export interface HostLog {
|
|
15
|
+
write(appId: string, ...args: unknown[]): void
|
|
16
|
+
read(appId: string): readonly string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Append-only JSONL log at `apps/<appId>/logs`.
|
|
21
|
+
* One record is one line. A record is written whole, even when it is larger than the segment.
|
|
22
|
+
* The active file is sealed before a record would cross the segment size.
|
|
23
|
+
* Past `maxBytes`, the oldest sealed file is deleted. The newest file stays.
|
|
24
|
+
* `write` uses stat, rename, and unlink. It does not read the log.
|
|
25
|
+
* @param runtimeRoot - host runtime root
|
|
26
|
+
* @param maxBytes - cap across the segments of one app
|
|
27
|
+
*/
|
|
28
|
+
export function createHostLog(runtimeRoot: string, maxBytes = DEFAULT_HOST_LOG_BYTES): HostLog {
|
|
29
|
+
const segmentBytes = Math.min(SEGMENT_BYTES, maxBytes)
|
|
30
|
+
return {
|
|
31
|
+
write(appId, ...args) {
|
|
32
|
+
const dir = logDir(runtimeRoot, appId)
|
|
33
|
+
mkdirSync(dir, { recursive: true })
|
|
34
|
+
const line = jsonLine(args)
|
|
35
|
+
const active = path.join(dir, ACTIVE)
|
|
36
|
+
if (fileSize(active) > 0 && fileSize(active) + Buffer.byteLength(line) > segmentBytes) seal(dir)
|
|
37
|
+
appendFileSync(active, line)
|
|
38
|
+
if (fileSize(active) > segmentBytes) seal(dir)
|
|
39
|
+
dropOldest(dir, maxBytes)
|
|
40
|
+
},
|
|
41
|
+
read(appId) {
|
|
42
|
+
const lines: string[] = []
|
|
43
|
+
for (const name of segmentNames(logDir(runtimeRoot, appId))) {
|
|
44
|
+
let text: string
|
|
45
|
+
try {
|
|
46
|
+
text = readFileSync(path.join(logDir(runtimeRoot, appId), name), 'utf8')
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
lines.push(...recordLines(text))
|
|
51
|
+
}
|
|
52
|
+
return lines
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function logDir(runtimeRoot: string, appId: string): string {
|
|
58
|
+
if (appId === '' || appId.includes('/') || appId.includes('\\') || appId.includes('..')) {
|
|
59
|
+
throw new Error(`log app id is not a single segment: ${appId}`)
|
|
60
|
+
}
|
|
61
|
+
return path.join(runtimeRoot, hostLayout.apps, appId, hostLayout.logs)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function jsonLine(args: unknown[]): string {
|
|
65
|
+
return `${JSON.stringify({ t: new Date().toISOString(), line: args.map(textOf).join(' ') })}\n`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function recordLines(text: string): string[] {
|
|
69
|
+
const lines: string[] = []
|
|
70
|
+
for (const row of text.split('\n')) {
|
|
71
|
+
if (row === '') continue
|
|
72
|
+
try {
|
|
73
|
+
const value: unknown = JSON.parse(row)
|
|
74
|
+
if (typeof value === 'object' && value !== null && 'line' in value && typeof value.line === 'string') {
|
|
75
|
+
lines.push(value.line)
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// A line that is not a record is kept as text.
|
|
80
|
+
}
|
|
81
|
+
lines.push(row)
|
|
82
|
+
}
|
|
83
|
+
return lines
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fileSize(file: string): number {
|
|
87
|
+
try {
|
|
88
|
+
return statSync(file).size
|
|
89
|
+
} catch {
|
|
90
|
+
return 0
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function seal(dir: string): void {
|
|
95
|
+
const active = path.join(dir, ACTIVE)
|
|
96
|
+
if (fileSize(active) === 0) return
|
|
97
|
+
renameSync(active, path.join(dir, `${nextId(dir)}.log`))
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function nextId(dir: string): string {
|
|
101
|
+
let max = 0
|
|
102
|
+
for (const name of segmentNames(dir)) {
|
|
103
|
+
if (name === ACTIVE) continue
|
|
104
|
+
const id = Number(name.slice(0, -'.log'.length))
|
|
105
|
+
if (Number.isInteger(id) && id > max) max = id
|
|
106
|
+
}
|
|
107
|
+
return String(max + 1).padStart(6, '0')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function dropOldest(dir: string, maxBytes: number): void {
|
|
111
|
+
const segments = segmentNames(dir).map(name => ({
|
|
112
|
+
name,
|
|
113
|
+
path: path.join(dir, name),
|
|
114
|
+
size: fileSize(path.join(dir, name)),
|
|
115
|
+
}))
|
|
116
|
+
let total = segments.reduce((sum, item) => sum + item.size, 0)
|
|
117
|
+
for (const item of segments.slice(0, -1)) {
|
|
118
|
+
if (total <= maxBytes) return
|
|
119
|
+
unlinkSync(item.path)
|
|
120
|
+
total -= item.size
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function segmentNames(dir: string): string[] {
|
|
125
|
+
let names: string[]
|
|
126
|
+
try {
|
|
127
|
+
names = readdirSync(dir)
|
|
128
|
+
} catch {
|
|
129
|
+
return []
|
|
130
|
+
}
|
|
131
|
+
return names.filter(name => name.endsWith('.log')).sort()
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function textOf(value: unknown): string {
|
|
135
|
+
if (typeof value === 'string') return value
|
|
136
|
+
if (value instanceof Error) return value.message
|
|
137
|
+
try {
|
|
138
|
+
return JSON.stringify(value)
|
|
139
|
+
} catch {
|
|
140
|
+
return Object.prototype.toString.call(value)
|
|
141
|
+
}
|
|
142
|
+
}
|