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