@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,113 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { CredentialError } from './codes.ts'
|
|
5
|
+
import { admitCredentialName, type CredentialProvider } from './provider.ts'
|
|
6
|
+
|
|
7
|
+
interface CredentialEntry {
|
|
8
|
+
readonly description: string
|
|
9
|
+
readonly secret: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One JSON file as one credential store. Read and write reach the same file, so the two sides
|
|
14
|
+
* cannot drift. A missing file is an empty source.
|
|
15
|
+
* A present bad file throws `credential-unreadable` and is not rewritten.
|
|
16
|
+
* @param file - absolute path. The name lives in `homeLayout`.
|
|
17
|
+
*/
|
|
18
|
+
export function createFileCredentials(file: string): CredentialProvider {
|
|
19
|
+
return {
|
|
20
|
+
writable: true,
|
|
21
|
+
async list() {
|
|
22
|
+
const entries = await readCredentialFile(file)
|
|
23
|
+
return [...entries.entries()]
|
|
24
|
+
.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
|
|
25
|
+
.map(([name, entry]) => ({ name, description: entry.description }))
|
|
26
|
+
},
|
|
27
|
+
async get(name) {
|
|
28
|
+
admitCredentialName(name)
|
|
29
|
+
const entries = await readCredentialFile(file)
|
|
30
|
+
return entries.get(name)?.secret
|
|
31
|
+
},
|
|
32
|
+
put: (name, description, secret) => writeFileCredential(file, name, description, secret),
|
|
33
|
+
remove: name => deleteFileCredential(file, name),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write one account into the JSON source. Not a method on the read port.
|
|
39
|
+
* A present bad file is not overwritten.
|
|
40
|
+
* @param file - absolute path
|
|
41
|
+
* @param name - owner-chosen account name
|
|
42
|
+
* @param description - text the author can recognize
|
|
43
|
+
* @param secret - the one value
|
|
44
|
+
*/
|
|
45
|
+
export async function writeFileCredential(file: string, name: string, description: string, secret: string): Promise<void> {
|
|
46
|
+
admitCredentialName(name)
|
|
47
|
+
const entries = await readCredentialFile(file)
|
|
48
|
+
const next = new Map(entries)
|
|
49
|
+
next.set(name, { description, secret })
|
|
50
|
+
await writeEntries(file, next)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Remove one account from the JSON source. A missing name leaves the file unchanged.
|
|
55
|
+
* @param file - absolute path
|
|
56
|
+
* @param name - owner-chosen account name
|
|
57
|
+
*/
|
|
58
|
+
export async function deleteFileCredential(file: string, name: string): Promise<void> {
|
|
59
|
+
admitCredentialName(name)
|
|
60
|
+
const entries = await readCredentialFile(file)
|
|
61
|
+
if (!entries.has(name)) return
|
|
62
|
+
const next = new Map(entries)
|
|
63
|
+
next.delete(name)
|
|
64
|
+
await writeEntries(file, next)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeEntries(file: string, entries: ReadonlyMap<string, CredentialEntry>): Promise<void> {
|
|
68
|
+
await mkdir(path.dirname(file), { recursive: true })
|
|
69
|
+
const body: Record<string, CredentialEntry> = {}
|
|
70
|
+
for (const [name, entry] of entries) body[name] = entry
|
|
71
|
+
// The file holds secrets in the clear, so it is owner-only. `mode` covers a new file and the
|
|
72
|
+
// chmod tightens one that an earlier version wrote world-readable.
|
|
73
|
+
await writeFile(file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 })
|
|
74
|
+
await chmod(file, 0o600).catch(() => undefined)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function readCredentialFile(file: string): Promise<ReadonlyMap<string, CredentialEntry>> {
|
|
78
|
+
let text: string
|
|
79
|
+
try {
|
|
80
|
+
text = await readFile(file, 'utf8')
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (isNotFound(error)) return new Map()
|
|
83
|
+
throw new CredentialError('credential-unreadable', 'credential file cannot be read', { cause: error })
|
|
84
|
+
}
|
|
85
|
+
let raw: unknown
|
|
86
|
+
try {
|
|
87
|
+
raw = JSON.parse(text)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
throw new CredentialError('credential-unreadable', 'credential file is not JSON', { cause: error })
|
|
90
|
+
}
|
|
91
|
+
return resolveCredentialFile(raw)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function resolveCredentialFile(raw: unknown): ReadonlyMap<string, CredentialEntry> {
|
|
95
|
+
if (!isRecord(raw)) throw new CredentialError('credential-unreadable', 'credential file must be an object')
|
|
96
|
+
const entries = new Map<string, CredentialEntry>()
|
|
97
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
98
|
+
if (name === '') throw new CredentialError('credential-unreadable', 'credential file has an empty name')
|
|
99
|
+
if (!isRecord(value) || typeof value.description !== 'string' || typeof value.secret !== 'string') {
|
|
100
|
+
throw new CredentialError('credential-unreadable', `credential file entry is incomplete: ${name}`)
|
|
101
|
+
}
|
|
102
|
+
entries.set(name, { description: value.description, secret: value.secret })
|
|
103
|
+
}
|
|
104
|
+
return entries
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
108
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isNotFound(error: unknown): boolean {
|
|
112
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
|
|
113
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { CredentialError } from './codes.ts'
|
|
2
|
+
|
|
3
|
+
/** One account an author can recognize. No secret. */
|
|
4
|
+
export interface CredentialListing {
|
|
5
|
+
readonly name: string
|
|
6
|
+
readonly description: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One credential store. Read and write are one abstraction, so Shell swaps the implementation in
|
|
11
|
+
* one place: the built-in JSON file today, a system keychain later.
|
|
12
|
+
*
|
|
13
|
+
* App code does not see this interface. `ctx.credentials` projects `get` alone.
|
|
14
|
+
*/
|
|
15
|
+
export interface CredentialProvider {
|
|
16
|
+
/** Whether {@link put} and {@link remove} do anything. A read-only source is false. */
|
|
17
|
+
readonly writable: boolean
|
|
18
|
+
list(): Promise<readonly CredentialListing[]>
|
|
19
|
+
get(name: string): Promise<string | undefined>
|
|
20
|
+
/**
|
|
21
|
+
* Write one account. An existing name keeps its place and takes the new description and secret.
|
|
22
|
+
* @param name - owner-chosen account name
|
|
23
|
+
* @param description - text the owner recognizes
|
|
24
|
+
* @param secret - the one value
|
|
25
|
+
*/
|
|
26
|
+
put(name: string, description: string, secret: string): Promise<void>
|
|
27
|
+
/**
|
|
28
|
+
* Remove one account. A missing name changes nothing.
|
|
29
|
+
* @param name - owner-chosen account name
|
|
30
|
+
*/
|
|
31
|
+
remove(name: string): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* No source. `list` is empty, `get` is `undefined`, and the write side says why it cannot write.
|
|
36
|
+
* An empty name is still `credential-invalid`.
|
|
37
|
+
*/
|
|
38
|
+
export function emptyCredentials(): CredentialProvider {
|
|
39
|
+
return {
|
|
40
|
+
writable: false,
|
|
41
|
+
list: () => Promise.resolve([]),
|
|
42
|
+
get: name => Promise.resolve().then(() => {
|
|
43
|
+
admitCredentialName(name)
|
|
44
|
+
return undefined
|
|
45
|
+
}),
|
|
46
|
+
put: (name) => {
|
|
47
|
+
admitCredentialName(name)
|
|
48
|
+
return Promise.reject(cannotWrite())
|
|
49
|
+
},
|
|
50
|
+
remove: (name) => {
|
|
51
|
+
admitCredentialName(name)
|
|
52
|
+
return Promise.reject(cannotWrite())
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Reject an empty name before the source is read. */
|
|
58
|
+
export function admitCredentialName(name: string): string {
|
|
59
|
+
if (name === '') throw new CredentialError('credential-invalid', 'credential name is empty')
|
|
60
|
+
return name
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The failure of a write against a store that has no writable source. */
|
|
64
|
+
export function cannotWrite(): CredentialError {
|
|
65
|
+
return new CredentialError('credential-write-unavailable', 'no credential source can be written')
|
|
66
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/** Retained events per app when the caller omits a bound. Host policy, not a locked number. */
|
|
2
|
+
export const DEFAULT_APP_EVENT_TAIL = 50
|
|
3
|
+
|
|
4
|
+
/** Retry hint sent when a subscriber opens. Host policy, not a locked number. */
|
|
5
|
+
export const DEFAULT_APP_EVENT_RETRY_MS = 3000
|
|
6
|
+
|
|
7
|
+
/** Ping interval while a subscriber is attached. Host policy, not a locked number. */
|
|
8
|
+
export const DEFAULT_APP_EVENT_PING_MS = 15_000
|
|
9
|
+
|
|
10
|
+
/** One author event. Diagnostics never use this shape. */
|
|
11
|
+
export interface AuthorEvent {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly data: unknown
|
|
14
|
+
readonly seq: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The retained tail no longer contains `since`. The UI refetches. It does not invent events. */
|
|
18
|
+
export interface AppGap {
|
|
19
|
+
readonly type: 'app:gap'
|
|
20
|
+
readonly appId: string
|
|
21
|
+
readonly since: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Sent to the subscriber that just opened. Not an author channel. */
|
|
25
|
+
export interface AppRetry {
|
|
26
|
+
readonly type: 'retry'
|
|
27
|
+
readonly retryMs: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Sent while at least one subscriber is attached. Not an author channel. */
|
|
31
|
+
export interface AppPing {
|
|
32
|
+
readonly type: 'ping'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AppStreamItem = AuthorEvent | AppGap | AppRetry | AppPing
|
|
36
|
+
|
|
37
|
+
/** What `useApp().on` and `useApp().onAny` see. Ping and the retry hint stay on the stream. */
|
|
38
|
+
export interface AppHooks {
|
|
39
|
+
on(name: string, callback: (data: unknown) => void): () => void
|
|
40
|
+
onAny(callback: (event: { name: string; data: unknown }) => void): () => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The per-app author buffer. Diagnostics do not use this. */
|
|
44
|
+
export interface AppEvents {
|
|
45
|
+
push(appId: string, name: string, data?: unknown): void
|
|
46
|
+
subscribe(appId: string, lastSeq: number, listener: (item: AppStreamItem) => void): () => void
|
|
47
|
+
observe(listener: (appId: string, event: AuthorEvent) => void): () => void
|
|
48
|
+
retained(): ReadonlyArray<{ readonly appId: string; readonly gap?: { readonly since: number }; readonly events: readonly AuthorEvent[] }>
|
|
49
|
+
dispose(): void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface AppLog {
|
|
53
|
+
seq: number
|
|
54
|
+
events: AuthorEvent[]
|
|
55
|
+
listeners: Set<(item: AppStreamItem) => void>
|
|
56
|
+
ping: ReturnType<typeof setInterval> | undefined
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Per-app author stream. `tailLength` is host policy and is not locked.
|
|
61
|
+
* @param options - buffer bound and the warning log
|
|
62
|
+
*/
|
|
63
|
+
export function createAppEvents(options: {
|
|
64
|
+
readonly tailLength: number
|
|
65
|
+
readonly retryMs?: number
|
|
66
|
+
readonly pingMs?: number
|
|
67
|
+
readonly log?: (message: string) => void
|
|
68
|
+
}): AppEvents {
|
|
69
|
+
const retryMs = options.retryMs ?? DEFAULT_APP_EVENT_RETRY_MS
|
|
70
|
+
const pingMs = options.pingMs ?? DEFAULT_APP_EVENT_PING_MS
|
|
71
|
+
const apps = new Map<string, AppLog>()
|
|
72
|
+
const observers = new Set<(appId: string, event: AuthorEvent) => void>()
|
|
73
|
+
const log = options.log ?? ((message: string) => {
|
|
74
|
+
console.warn(message)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
function read(appId: string): AppLog {
|
|
78
|
+
const existing = apps.get(appId)
|
|
79
|
+
if (existing !== undefined) return existing
|
|
80
|
+
const created: AppLog = { seq: 0, events: [], listeners: new Set(), ping: undefined }
|
|
81
|
+
apps.set(appId, created)
|
|
82
|
+
return created
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
push(appId, name, data) {
|
|
87
|
+
if (data !== undefined && !isJson(data)) {
|
|
88
|
+
log(`ctx.push("${name}") dropped: data is not JSON`)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
const app = read(appId)
|
|
92
|
+
app.seq += 1
|
|
93
|
+
const event: AuthorEvent = { name, data, seq: app.seq }
|
|
94
|
+
app.events.push(event)
|
|
95
|
+
if (app.events.length > options.tailLength) {
|
|
96
|
+
app.events.splice(0, app.events.length - options.tailLength)
|
|
97
|
+
}
|
|
98
|
+
for (const listener of [...app.listeners]) listener(event)
|
|
99
|
+
for (const observer of [...observers]) observer(appId, event)
|
|
100
|
+
},
|
|
101
|
+
observe(listener) {
|
|
102
|
+
observers.add(listener)
|
|
103
|
+
return () => {
|
|
104
|
+
observers.delete(listener)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
retained() {
|
|
108
|
+
const rows = []
|
|
109
|
+
for (const [appId, app] of apps) {
|
|
110
|
+
const oldest = app.events[0]
|
|
111
|
+
if (oldest === undefined) continue
|
|
112
|
+
const gap = oldest.seq > 1 ? { since: 0 } : undefined
|
|
113
|
+
rows.push({
|
|
114
|
+
appId,
|
|
115
|
+
events: app.events,
|
|
116
|
+
...gap === undefined ? {} : { gap },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
return rows
|
|
120
|
+
},
|
|
121
|
+
subscribe(appId, lastSeq, listener) {
|
|
122
|
+
const app = read(appId)
|
|
123
|
+
listener({ type: 'retry', retryMs })
|
|
124
|
+
const oldest = app.events[0]
|
|
125
|
+
if (oldest !== undefined && oldest.seq > lastSeq + 1) {
|
|
126
|
+
listener({ type: 'app:gap', appId, since: lastSeq })
|
|
127
|
+
}
|
|
128
|
+
for (const event of app.events) {
|
|
129
|
+
if (event.seq > lastSeq) listener(event)
|
|
130
|
+
}
|
|
131
|
+
app.listeners.add(listener)
|
|
132
|
+
if (app.listeners.size === 1 && pingMs > 0 && app.ping === undefined) {
|
|
133
|
+
const timer = setInterval(() => {
|
|
134
|
+
for (const current of [...app.listeners]) current({ type: 'ping' })
|
|
135
|
+
}, pingMs)
|
|
136
|
+
timer.unref()
|
|
137
|
+
app.ping = timer
|
|
138
|
+
}
|
|
139
|
+
return () => {
|
|
140
|
+
app.listeners.delete(listener)
|
|
141
|
+
if (app.listeners.size === 0 && app.ping !== undefined) {
|
|
142
|
+
clearInterval(app.ping)
|
|
143
|
+
app.ping = undefined
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
dispose() {
|
|
148
|
+
observers.clear()
|
|
149
|
+
for (const app of apps.values()) {
|
|
150
|
+
if (app.ping !== undefined) clearInterval(app.ping)
|
|
151
|
+
app.ping = undefined
|
|
152
|
+
app.listeners.clear()
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Forward every app onto one listener. Live events are registered before the retained tail is copied.
|
|
160
|
+
* The panel uses this on the host stream so an open tab does not hold its own socket.
|
|
161
|
+
*/
|
|
162
|
+
export function forwardFrames(events: AppEvents, listener: (event: unknown) => void): () => void {
|
|
163
|
+
const stop = events.observe((appId, event) => {
|
|
164
|
+
listener({ type: 'app:event', appId, name: event.name, data: event.data, seq: event.seq })
|
|
165
|
+
})
|
|
166
|
+
for (const row of events.retained()) {
|
|
167
|
+
if (row.gap !== undefined) listener({ type: 'app:gap', appId: row.appId, since: row.gap.since })
|
|
168
|
+
for (const event of row.events) {
|
|
169
|
+
listener({ type: 'app:event', appId: row.appId, name: event.name, data: event.data, seq: event.seq })
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return stop
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Map the author stream onto `on` / `onAny`. A gap is `{ name: "*", data: { gap: true } }` for `onAny` only. */
|
|
176
|
+
export function bindAppHooks(events: AppEvents, appId: string, lastSeq = 0): AppHooks {
|
|
177
|
+
return {
|
|
178
|
+
on(name, callback) {
|
|
179
|
+
return events.subscribe(appId, lastSeq, (item) => {
|
|
180
|
+
if (!isAuthorEvent(item)) return
|
|
181
|
+
if (name !== '*' && item.name !== name) return
|
|
182
|
+
callback(item.data)
|
|
183
|
+
})
|
|
184
|
+
},
|
|
185
|
+
onAny(callback) {
|
|
186
|
+
return events.subscribe(appId, lastSeq, (item) => {
|
|
187
|
+
if (isGap(item)) {
|
|
188
|
+
callback({ name: '*', data: { gap: true } })
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
if (!isAuthorEvent(item)) return
|
|
192
|
+
callback({ name: item.name, data: item.data })
|
|
193
|
+
})
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isAuthorEvent(item: AppStreamItem): item is AuthorEvent {
|
|
199
|
+
return 'seq' in item
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isGap(item: AppStreamItem): item is AppGap {
|
|
203
|
+
return 'type' in item && item.type === 'app:gap'
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isJson(value: unknown): boolean {
|
|
207
|
+
if (typeof value === 'function' || typeof value === 'symbol' || value === undefined) return false
|
|
208
|
+
try {
|
|
209
|
+
JSON.stringify(value)
|
|
210
|
+
return true
|
|
211
|
+
} catch {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Kinds an iframe may report. A resource-load error with no message is dropped. */
|
|
2
|
+
export const errorKinds = ['render', 'module', 'uncaught', 'async'] as const
|
|
3
|
+
|
|
4
|
+
export type ErrorKind = (typeof errorKinds)[number]
|
|
5
|
+
|
|
6
|
+
export interface AppErrorRecord {
|
|
7
|
+
seq: number
|
|
8
|
+
kind: ErrorKind
|
|
9
|
+
message: string
|
|
10
|
+
componentStack?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Ring cap when the caller omits one. Host policy, not a locked number. */
|
|
14
|
+
const DEFAULT_CAP = 50
|
|
15
|
+
|
|
16
|
+
interface Ring {
|
|
17
|
+
seq: number
|
|
18
|
+
errors: AppErrorRecord[]
|
|
19
|
+
dropped: number
|
|
20
|
+
opened: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface AdmittedError {
|
|
24
|
+
kind: ErrorKind
|
|
25
|
+
message: string
|
|
26
|
+
componentStack?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Admit one iframe error report. A bad kind or an empty message is dropped.
|
|
31
|
+
* @param raw - untrusted JSON
|
|
32
|
+
*/
|
|
33
|
+
export function admitErrorReport(raw: unknown): AdmittedError | undefined {
|
|
34
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
|
|
35
|
+
const kind = 'kind' in raw ? raw.kind : undefined
|
|
36
|
+
const message = 'message' in raw ? raw.message : undefined
|
|
37
|
+
const componentStack = 'componentStack' in raw ? raw.componentStack : undefined
|
|
38
|
+
if (typeof kind !== 'string' || !isErrorKind(kind)) return undefined
|
|
39
|
+
if (typeof message !== 'string' || message.length === 0) return undefined
|
|
40
|
+
return {
|
|
41
|
+
kind,
|
|
42
|
+
message,
|
|
43
|
+
...typeof componentStack === 'string' ? { componentStack } : {},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Per-app error ring. Diagnostics never enter the author event buffer.
|
|
49
|
+
* @param cap - retained errors; older ones increment `dropped`
|
|
50
|
+
*/
|
|
51
|
+
export function createErrorRing(cap = DEFAULT_CAP) {
|
|
52
|
+
const apps = new Map<string, Ring>()
|
|
53
|
+
|
|
54
|
+
function ring(appId: string): Ring {
|
|
55
|
+
const existing = apps.get(appId)
|
|
56
|
+
if (existing !== undefined) return existing
|
|
57
|
+
const created: Ring = { seq: 0, errors: [], dropped: 0, opened: false }
|
|
58
|
+
apps.set(appId, created)
|
|
59
|
+
return created
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
record(appId: string, raw: unknown) {
|
|
64
|
+
const admitted = admitErrorReport(raw)
|
|
65
|
+
if (admitted === undefined) return
|
|
66
|
+
const current = ring(appId)
|
|
67
|
+
current.seq += 1
|
|
68
|
+
const entry: AppErrorRecord = {
|
|
69
|
+
seq: current.seq,
|
|
70
|
+
kind: admitted.kind,
|
|
71
|
+
message: admitted.message,
|
|
72
|
+
...admitted.componentStack === undefined ? {} : { componentStack: admitted.componentStack },
|
|
73
|
+
}
|
|
74
|
+
current.errors.push(entry)
|
|
75
|
+
while (current.errors.length > cap) {
|
|
76
|
+
current.errors.shift()
|
|
77
|
+
current.dropped += 1
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
markOpened(appId: string) {
|
|
81
|
+
ring(appId).opened = true
|
|
82
|
+
},
|
|
83
|
+
clearReload(appId: string) {
|
|
84
|
+
const current = ring(appId)
|
|
85
|
+
current.errors = []
|
|
86
|
+
current.dropped = 0
|
|
87
|
+
current.opened = false
|
|
88
|
+
},
|
|
89
|
+
read(appId: string, since?: number, clear = false) {
|
|
90
|
+
const current = ring(appId)
|
|
91
|
+
if (clear) current.errors = []
|
|
92
|
+
const errors = since === undefined ? [...current.errors] : current.errors.filter(item => item.seq > since)
|
|
93
|
+
const hint = current.errors.length === 0 && !current.opened
|
|
94
|
+
return {
|
|
95
|
+
errors,
|
|
96
|
+
lastSeq: current.seq,
|
|
97
|
+
dropped: current.dropped,
|
|
98
|
+
...hint ? { emptyHint: 'open the app before treating an empty ring as clean' } : {},
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
forget(appId: string) {
|
|
102
|
+
apps.delete(appId)
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isErrorKind(value: string): value is ErrorKind {
|
|
108
|
+
return (errorKinds as readonly string[]).includes(value)
|
|
109
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Host stream events. An author UI does not subscribe to this map. */
|
|
2
|
+
|
|
3
|
+
export type HostEvent =
|
|
4
|
+
| { type: 'app:open'; appId: string; title?: string }
|
|
5
|
+
| { type: 'workbench:default'; appId: string }
|
|
6
|
+
| { type: 'app:reload'; appId: string }
|
|
7
|
+
| { type: 'storage-size'; appId: string; table: string; keys: string[] }
|
|
8
|
+
| {
|
|
9
|
+
type: 'app:eval'
|
|
10
|
+
appId: string
|
|
11
|
+
requestId: string
|
|
12
|
+
code: string
|
|
13
|
+
budgetMs: number
|
|
14
|
+
maxBytes: number
|
|
15
|
+
maxNodes: number
|
|
16
|
+
maxDepth: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* In-process host stream. `GET /api/events` is the HTTP projection.
|
|
21
|
+
* `app:open` focuses a tab. `app:reload` refetches and does not create one.
|
|
22
|
+
*/
|
|
23
|
+
export function createHostEvents() {
|
|
24
|
+
const listeners = new Set<(event: HostEvent) => void>()
|
|
25
|
+
return {
|
|
26
|
+
subscribe(listener: (event: HostEvent) => void) {
|
|
27
|
+
listeners.add(listener)
|
|
28
|
+
return () => {
|
|
29
|
+
listeners.delete(listener)
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
publish(event: HostEvent) {
|
|
33
|
+
for (const listener of [...listeners]) listener(event)
|
|
34
|
+
},
|
|
35
|
+
get connected() {
|
|
36
|
+
return listeners.size > 0
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|