@lincyaw/host 0.0.0-stage → 1.0.0-rc.1
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/LICENSE +202 -0
- package/README.md +38 -2
- package/package.json +48 -3
- package/src/actions.ts +22 -0
- package/src/approvals.ts +12 -0
- package/src/blocks.ts +81 -0
- package/src/claim-first.ts +13 -0
- package/src/code-lines-box.ts +11 -0
- package/src/contracts.ts +157 -0
- package/src/control.ts +117 -0
- package/src/design/appearance.ts +16 -0
- package/src/design/caption.ts +31 -0
- package/src/design/components.ts +143 -0
- package/src/design/design.css +9 -0
- package/src/design/layout.ts +28 -0
- package/src/env.d.ts +7 -0
- package/src/error-boundary.ts +18 -0
- package/src/host-key.ts +4 -0
- package/src/host.ts +47 -0
- package/src/index.ts +114 -0
- package/src/locale.ts +20 -0
- package/src/messages.ts +66 -0
- package/src/operations.ts +60 -0
- package/src/panels.ts +28 -0
- package/src/playback-control.ts +67 -0
- package/src/registries.ts +86 -0
- package/src/resident-worker.ts +84 -0
- package/src/runtime.ts +187 -0
- package/src/server.ts +36 -0
- package/src/session.ts +10 -0
- package/src/shared-state.ts +30 -0
- package/src/site.ts +14 -0
- package/src/start-report-panel.ts +8 -0
- package/src/step-clock.ts +49 -0
- package/src/step-scope.ts +38 -0
- package/src/store.ts +33 -0
- package/src/styles.d.ts +1 -0
- package/src/windows.ts +30 -0
package/src/messages.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { PluginStatus } from '@lincyaw/kernel/contracts'
|
|
2
|
+
import type { Catalog } from './locale.ts'
|
|
3
|
+
|
|
4
|
+
export interface HostMessages {
|
|
5
|
+
panels: string
|
|
6
|
+
startReport: string
|
|
7
|
+
starting: string
|
|
8
|
+
noPlugins: string
|
|
9
|
+
status: Record<PluginStatus, string>
|
|
10
|
+
because: string
|
|
11
|
+
unnamedPlugin: string
|
|
12
|
+
componentError(component: string, message: string): string
|
|
13
|
+
problems(count: number): string
|
|
14
|
+
approvals(count: number): string
|
|
15
|
+
approvalsTitle: string
|
|
16
|
+
allow: string
|
|
17
|
+
deny: string
|
|
18
|
+
answerFailed: string
|
|
19
|
+
close: string
|
|
20
|
+
from(who: string): string
|
|
21
|
+
origins: Record<string, string>
|
|
22
|
+
otherOrigin: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const HOST_MESSAGES: Catalog<HostMessages> = {
|
|
26
|
+
'zh-CN': {
|
|
27
|
+
panels: '课堂与工具',
|
|
28
|
+
startReport: '启动报告',
|
|
29
|
+
starting: '正在启动…',
|
|
30
|
+
noPlugins: '没有注册插件。',
|
|
31
|
+
status: { started: '已启动', degraded: '部分可用', disabled: '已禁用', failed: '启动失败' },
|
|
32
|
+
because: ':',
|
|
33
|
+
unnamedPlugin: '其他插件',
|
|
34
|
+
componentError: (component, message) => `${component} 出错:${message}`,
|
|
35
|
+
problems: count => `${count} 个问题`,
|
|
36
|
+
approvals: count => `待确认 ${count}`,
|
|
37
|
+
approvalsTitle: '待确认的操作',
|
|
38
|
+
allow: '允许',
|
|
39
|
+
deny: '拒绝',
|
|
40
|
+
answerFailed: '无法发送答复。请重试。',
|
|
41
|
+
close: '关闭',
|
|
42
|
+
from: who => `来自${/^[A-Za-z0-9]/.test(who) ? ' ' : ''}${who}`,
|
|
43
|
+
origins: { 'presmium.mcp': '外部 AI 工具' },
|
|
44
|
+
otherOrigin: '其他程序',
|
|
45
|
+
},
|
|
46
|
+
'en': {
|
|
47
|
+
panels: 'Classroom and Tools',
|
|
48
|
+
startReport: 'Startup Report',
|
|
49
|
+
starting: 'Starting…',
|
|
50
|
+
noPlugins: 'No plugins registered.',
|
|
51
|
+
status: { started: 'Started', degraded: 'Partly available', disabled: 'Disabled', failed: 'Failed to start' },
|
|
52
|
+
because: ': ',
|
|
53
|
+
unnamedPlugin: 'Other plugin',
|
|
54
|
+
componentError: (component, message) => `${component} failed: ${message}`,
|
|
55
|
+
problems: count => `${count} ${count === 1 ? 'problem' : 'problems'}`,
|
|
56
|
+
approvals: count => `Pending Approvals (${count})`,
|
|
57
|
+
approvalsTitle: 'Pending Approvals',
|
|
58
|
+
allow: 'Allow',
|
|
59
|
+
deny: 'Deny',
|
|
60
|
+
answerFailed: 'Couldn’t send the answer. Please try again.',
|
|
61
|
+
close: 'Close',
|
|
62
|
+
from: who => `From ${who}`,
|
|
63
|
+
origins: { 'presmium.mcp': 'an external AI tool' },
|
|
64
|
+
otherOrigin: 'another program',
|
|
65
|
+
},
|
|
66
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Operation, Unsubscribe } from '@lincyaw/kernel/contracts'
|
|
2
|
+
import { createStore } from './store.ts'
|
|
3
|
+
import type { Operations, ReplayHandler } from './contracts.ts'
|
|
4
|
+
|
|
5
|
+
export function createOperations(): Operations {
|
|
6
|
+
const listeners = new Set<(operation: Operation) => void>()
|
|
7
|
+
const handlers = new Map<string, ReplayHandler>()
|
|
8
|
+
const replaying = createStore(false)
|
|
9
|
+
let unsettled = 0
|
|
10
|
+
return {
|
|
11
|
+
report(operation) {
|
|
12
|
+
if (replaying.getSnapshot()) return
|
|
13
|
+
for (const listener of [...listeners]) {
|
|
14
|
+
try {
|
|
15
|
+
listener(operation)
|
|
16
|
+
} catch (error) {
|
|
17
|
+
console.error('[presmium] an operations subscriber failed', error)
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
subscribe(listener) {
|
|
22
|
+
listeners.add(listener)
|
|
23
|
+
return () => {
|
|
24
|
+
listeners.delete(listener)
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
onReplay(type, handler): Unsubscribe {
|
|
28
|
+
handlers.set(type, handler)
|
|
29
|
+
return () => {
|
|
30
|
+
if (handlers.get(type) === handler) handlers.delete(type)
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
replay(operation, seeking = false) {
|
|
34
|
+
const handler = handlers.get(operation.type)
|
|
35
|
+
if (!handler) return false
|
|
36
|
+
unsettled++
|
|
37
|
+
replaying.set(true)
|
|
38
|
+
try {
|
|
39
|
+
handler.apply(operation.data, seeking)
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`[presmium] the replay of ${operation.type} failed`, error)
|
|
42
|
+
} finally {
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if (--unsettled === 0) replaying.set(false)
|
|
45
|
+
}, 0)
|
|
46
|
+
}
|
|
47
|
+
return true
|
|
48
|
+
},
|
|
49
|
+
clear() {
|
|
50
|
+
for (const [type, handler] of handlers) {
|
|
51
|
+
try {
|
|
52
|
+
handler.clear?.()
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error(`[presmium] clearing ${type} failed`, error)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
replaying,
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/panels.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createElement as h, useEffect, useState } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import type { ComponentType } from 'react'
|
|
4
|
+
import { PRESENTER_PANELS } from '@lincyaw/kernel'
|
|
5
|
+
import type { PresenterPanel } from '@lincyaw/kernel'
|
|
6
|
+
import { TOOLBAR } from './contracts.ts'
|
|
7
|
+
import type { ToolbarItem, ToolbarPlacement } from './contracts.ts'
|
|
8
|
+
import { useService, useStartReport } from './host.ts'
|
|
9
|
+
import { useMessages } from './locale.ts'
|
|
10
|
+
import { componentErrors, componentErrorRevision } from './error-boundary.ts'
|
|
11
|
+
import { useStore } from './store.ts'
|
|
12
|
+
import { HOST_MESSAGES } from './messages.ts'
|
|
13
|
+
import { ToolbarPlacementContext } from './design/components.ts'
|
|
14
|
+
export function PmToolbar({ filter = (item: ToolbarItem) => !item.slot || item.slot === 'tools' || item.slot === 'settings', placement = 'bar', className }: { className?: string, filter?: (item: ToolbarItem) => boolean, placement?: ToolbarPlacement }) {
|
|
15
|
+
const service = useService(TOOLBAR); const [items, setItems] = useState<ToolbarItem[]>([])
|
|
16
|
+
useEffect(() => { const update = () => setItems(service?.list() ?? []); update(); return service?.subscribe(update) }, [service])
|
|
17
|
+
return h(ToolbarPlacementContext.Provider, { value: placement }, h('span', { className: `pm-toolbar${className ? ` ${className}` : ''}`, 'data-placement': placement }, items.filter(filter).map(item => h(item.component as ComponentType, { key: item.id, 'data-toolbar-item': item.id } as any))))
|
|
18
|
+
}
|
|
19
|
+
export function PmPresenterPanels({ to = 'body', docked = false }: { to?: string, docked?: boolean }) {
|
|
20
|
+
const t = useMessages(HOST_MESSAGES); const service = useService(PRESENTER_PANELS); const [panels, setPanels] = useState<PresenterPanel[]>([])
|
|
21
|
+
useEffect(() => { const update = () => setPanels(service?.list() ?? []); update(); return service?.subscribe(update) }, [service])
|
|
22
|
+
const [target, setTarget] = useState<Element | null>(null)
|
|
23
|
+
useEffect(() => { setTarget(document.querySelector(to)) }, [to])
|
|
24
|
+
const report = useStartReport(); useStore(componentErrorRevision)
|
|
25
|
+
const problems = (report?.plugins.filter(plugin => plugin.status === 'failed' || plugin.status === 'disabled').length ?? 0) + componentErrors().length
|
|
26
|
+
const content = service ? h('details', { className: `pm-presenter-panels${docked ? ' pm-presenter-panels--docked' : ''}` }, h('summary', {}, t.panels, problems ? h('span', { className: 'pm-presenter-panels__badge', title: t.problems(problems) }, problems) : null), panels.map(panel => h('section', { key: panel.id, 'data-panel': panel.id }, h('h3', {}, panel.title), h(panel.component as ComponentType)))) : null
|
|
27
|
+
return target ? createPortal(content, target) : null
|
|
28
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { PlaybackControl, PlaybackLease } from './contracts.ts'
|
|
2
|
+
|
|
3
|
+
interface Participant {
|
|
4
|
+
pause(): void
|
|
5
|
+
cancel(): void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Coordinates starts and cancellation without owning time, media or a second player. */
|
|
9
|
+
export function createPlaybackControl(): PlaybackControl {
|
|
10
|
+
const participants = new Set<Participant>()
|
|
11
|
+
let gate: { authorize(signal: AbortSignal): Promise<void> } | undefined
|
|
12
|
+
const pause = () => {
|
|
13
|
+
for (const participant of [...participants]) {
|
|
14
|
+
participant.cancel()
|
|
15
|
+
participant.pause()
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
pause,
|
|
20
|
+
setGate(authorize) {
|
|
21
|
+
pause()
|
|
22
|
+
const installed = { authorize }
|
|
23
|
+
gate = installed
|
|
24
|
+
return () => {
|
|
25
|
+
if (gate !== installed) return
|
|
26
|
+
gate = undefined
|
|
27
|
+
pause()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
register(stop) {
|
|
31
|
+
let active: AbortController | undefined
|
|
32
|
+
let disposed = false
|
|
33
|
+
const cancel = () => { active?.abort(); active = undefined }
|
|
34
|
+
const participant = { pause: stop, cancel }
|
|
35
|
+
participants.add(participant)
|
|
36
|
+
const lease: PlaybackLease = {
|
|
37
|
+
async requestStart() {
|
|
38
|
+
cancel()
|
|
39
|
+
if (disposed) return false
|
|
40
|
+
for (const other of [...participants]) if (other !== participant) {
|
|
41
|
+
other.cancel()
|
|
42
|
+
other.pause()
|
|
43
|
+
}
|
|
44
|
+
const request = new AbortController()
|
|
45
|
+
active = request
|
|
46
|
+
const requestedGate = gate
|
|
47
|
+
try {
|
|
48
|
+
await requestedGate?.authorize(request.signal)
|
|
49
|
+
return !disposed && active === request && !request.signal.aborted && gate === requestedGate
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (request.signal.aborted || error instanceof Error && error.name === 'AbortError') return false
|
|
52
|
+
throw error
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
cancel,
|
|
56
|
+
dispose() {
|
|
57
|
+
if (disposed) return
|
|
58
|
+
disposed = true
|
|
59
|
+
cancel()
|
|
60
|
+
participants.delete(participant)
|
|
61
|
+
stop()
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
return lease
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { PresenterPanel, PresenterPanels, Unsubscribe } from '@lincyaw/kernel'
|
|
2
|
+
import { currentLocale } from './locale.ts'
|
|
3
|
+
import { HOST_MESSAGES } from './messages.ts'
|
|
4
|
+
import StartReportPanel from './start-report-panel.ts'
|
|
5
|
+
import type { StatusProvider, StatusRegistry, TimedCaptions, Toolbar, ToolbarItem } from './contracts.ts'
|
|
6
|
+
import { createStore } from './store.ts'
|
|
7
|
+
|
|
8
|
+
export function createTimedCaptions(): TimedCaptions {
|
|
9
|
+
const owners = new Set<symbol>(), active = createStore(false)
|
|
10
|
+
return { active, claim() {
|
|
11
|
+
const owner = Symbol()
|
|
12
|
+
owners.add(owner); active.set(true)
|
|
13
|
+
return () => { if (owners.delete(owner)) active.set(owners.size > 0) }
|
|
14
|
+
} }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Item {
|
|
18
|
+
id: string
|
|
19
|
+
order?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Registry<T extends Item> {
|
|
23
|
+
register(item: T): Unsubscribe
|
|
24
|
+
list(): T[]
|
|
25
|
+
subscribe(listener: () => void): Unsubscribe
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createRegistry<T extends Item>(kind: string): Registry<T> {
|
|
29
|
+
const items = new Map<string, T>()
|
|
30
|
+
const listeners = new Set<() => void>()
|
|
31
|
+
const notify = () => listeners.forEach(listener => listener())
|
|
32
|
+
return {
|
|
33
|
+
register(item) {
|
|
34
|
+
if (items.has(item.id)) throw new Error(`${kind} ${item.id} is already registered`)
|
|
35
|
+
items.set(item.id, item)
|
|
36
|
+
notify()
|
|
37
|
+
return () => {
|
|
38
|
+
if (items.get(item.id) !== item) return
|
|
39
|
+
items.delete(item.id)
|
|
40
|
+
notify()
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
list() {
|
|
44
|
+
return [...items.values()].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
|
|
45
|
+
},
|
|
46
|
+
subscribe(listener) {
|
|
47
|
+
listeners.add(listener)
|
|
48
|
+
return () => {
|
|
49
|
+
listeners.delete(listener)
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createPresenterPanels(): PresenterPanels {
|
|
56
|
+
const service = createRegistry<PresenterPanel>('presenter panel')
|
|
57
|
+
service.register({
|
|
58
|
+
id: 'presmium.core.start-report',
|
|
59
|
+
get title() {
|
|
60
|
+
return HOST_MESSAGES[currentLocale()].startReport
|
|
61
|
+
},
|
|
62
|
+
component: StartReportPanel,
|
|
63
|
+
order: -1,
|
|
64
|
+
})
|
|
65
|
+
return service
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createToolbar(): Toolbar {
|
|
69
|
+
return createRegistry<ToolbarItem>('toolbar item')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function createStatusRegistry(onChange?: () => void): StatusRegistry {
|
|
73
|
+
const providers = new Map<string, StatusProvider>()
|
|
74
|
+
return {
|
|
75
|
+
register(provider) {
|
|
76
|
+
providers.set(provider.id, provider)
|
|
77
|
+
onChange?.()
|
|
78
|
+
return () => {
|
|
79
|
+
if (providers.get(provider.id) !== provider) return
|
|
80
|
+
providers.delete(provider.id)
|
|
81
|
+
onChange?.()
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
list: () => [...providers.values()],
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface ResidentWorkerOptions {
|
|
2
|
+
start(): Worker
|
|
3
|
+
loadTimeoutMs: number
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type ResidentWorkerFailure = 'cancelled' | 'load-timeout' | 'run-timeout' | 'crashed'
|
|
7
|
+
|
|
8
|
+
export type ResidentWorkerOutcome<Reply> =
|
|
9
|
+
| { reply: Reply, elapsedMs: number }
|
|
10
|
+
| { failure: ResidentWorkerFailure, error?: string, elapsedMs: number }
|
|
11
|
+
|
|
12
|
+
export interface ResidentWorker<Request extends { id: number }, Reply extends { id: number, type: string }> {
|
|
13
|
+
send(request: Omit<Request, 'id'>, options: { timeoutMs: number, signal?: AbortSignal }): Promise<ResidentWorkerOutcome<Exclude<Reply, { type: 'started' }>>>
|
|
14
|
+
hasWorker(): boolean
|
|
15
|
+
dispose(): void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A dedicated Worker kept between requests, which it takes one at a time. Until the worker replies `started` the load
|
|
20
|
+
* timeout runs, then the request's own; a timeout, an abort or a crash ends the worker, and the next request starts another.
|
|
21
|
+
*/
|
|
22
|
+
export function createResidentWorker<Request extends { id: number }, Reply extends { id: number, type: string }>(options: ResidentWorkerOptions): ResidentWorker<Request, Reply> {
|
|
23
|
+
type Outcome = ResidentWorkerOutcome<Exclude<Reply, { type: 'started' }>>
|
|
24
|
+
let worker: Worker | undefined
|
|
25
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
26
|
+
let ids = 0
|
|
27
|
+
let cancel: (() => void) | undefined
|
|
28
|
+
let disposed = false
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
send(request, { timeoutMs, signal }) {
|
|
32
|
+
const job = () => new Promise<Outcome>((resolve) => {
|
|
33
|
+
if (disposed || signal?.aborted) return resolve({ failure: 'cancelled', elapsedMs: 0 })
|
|
34
|
+
const target = worker ??= options.start()
|
|
35
|
+
const id = ++ids
|
|
36
|
+
let started = 0
|
|
37
|
+
const elapsed = () => (started ? Math.round(performance.now() - started) : 0)
|
|
38
|
+
const finish = (outcome: Outcome) => {
|
|
39
|
+
clearTimeout(timer)
|
|
40
|
+
cancel = undefined
|
|
41
|
+
target.removeEventListener('message', onMessage)
|
|
42
|
+
target.removeEventListener('error', onError)
|
|
43
|
+
signal?.removeEventListener('abort', onAbort)
|
|
44
|
+
resolve(outcome)
|
|
45
|
+
}
|
|
46
|
+
const stop = (failure: ResidentWorkerFailure, error?: string) => {
|
|
47
|
+
target.terminate()
|
|
48
|
+
if (worker === target) worker = undefined
|
|
49
|
+
finish({ failure, ...error === undefined ? {} : { error }, elapsedMs: elapsed() })
|
|
50
|
+
}
|
|
51
|
+
const onMessage = ({ data }: MessageEvent<Reply>) => {
|
|
52
|
+
if (data.id !== id) return
|
|
53
|
+
if (data.type === 'started') {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
started = performance.now()
|
|
56
|
+
timer = setTimeout(() => stop('run-timeout'), timeoutMs)
|
|
57
|
+
} else {
|
|
58
|
+
finish({ reply: data as Exclude<Reply, { type: 'started' }>, elapsedMs: elapsed() })
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const onError = (event: ErrorEvent) => stop('crashed', event.message)
|
|
62
|
+
const onAbort = () => stop('cancelled')
|
|
63
|
+
cancel = onAbort
|
|
64
|
+
target.addEventListener('message', onMessage)
|
|
65
|
+
target.addEventListener('error', onError)
|
|
66
|
+
signal?.addEventListener('abort', onAbort)
|
|
67
|
+
let timer = setTimeout(() => stop('load-timeout'), options.loadTimeoutMs)
|
|
68
|
+
target.postMessage({ ...request, id })
|
|
69
|
+
})
|
|
70
|
+
const outcome = queue.then(job)
|
|
71
|
+
queue = outcome.catch(() => undefined)
|
|
72
|
+
return outcome
|
|
73
|
+
},
|
|
74
|
+
hasWorker: () => worker !== undefined,
|
|
75
|
+
dispose() {
|
|
76
|
+
if (disposed) return
|
|
77
|
+
disposed = true
|
|
78
|
+
cancel?.()
|
|
79
|
+
worker?.terminate()
|
|
80
|
+
worker = undefined
|
|
81
|
+
queue = Promise.resolve()
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Kernel, PRESENTER_PANELS } from '@lincyaw/kernel'
|
|
2
|
+
import type { EnvironmentSpec, Logger, PluginDefinition, PluginReport, PresentationMode, ServiceToken, StartReport } from '@lincyaw/kernel'
|
|
3
|
+
import { createStore } from './store.ts'
|
|
4
|
+
import { connectActions } from './actions.ts'
|
|
5
|
+
import { PmApprovals } from './approvals.ts'
|
|
6
|
+
import { NAVIGATION, OPERATIONS, PLAYBACK_CONTROL, STATUS, STEP_CLOCK, SYNC_TRANSPORTS, TIMED_CAPTIONS, TOOLBAR } from './contracts.ts'
|
|
7
|
+
import type { Navigation, ServerClient, SyncTransports } from './contracts.ts'
|
|
8
|
+
import { connectControl } from './control.ts'
|
|
9
|
+
import { componentErrors } from './error-boundary.ts'
|
|
10
|
+
import { currentLocale } from './locale.ts'
|
|
11
|
+
import { HOST_MESSAGES } from './messages.ts'
|
|
12
|
+
import { createOperations } from './operations.ts'
|
|
13
|
+
import { createPlaybackControl } from './playback-control.ts'
|
|
14
|
+
import { createPresenterPanels, createStatusRegistry, createTimedCaptions, createToolbar } from './registries.ts'
|
|
15
|
+
import { provideServer } from './server.ts'
|
|
16
|
+
import { createStepClock } from './step-clock.ts'
|
|
17
|
+
|
|
18
|
+
export const runtimeRevision = createStore(0)
|
|
19
|
+
|
|
20
|
+
const registered: PluginDefinition[] = []
|
|
21
|
+
const running = createStore<Kernel | undefined>(undefined)
|
|
22
|
+
let starting: Promise<StartReport> | undefined
|
|
23
|
+
interface RuntimeOwner { kernel: Kernel, releases: (() => void)[], started?: Promise<StartReport> }
|
|
24
|
+
let runtime: RuntimeOwner | undefined
|
|
25
|
+
let lifecycle: Promise<unknown> = Promise.resolve()
|
|
26
|
+
const failures = createStore<PluginReport[]>([])
|
|
27
|
+
let resumeHost: (() => Promise<StartReport>) | undefined
|
|
28
|
+
let hostActive = true
|
|
29
|
+
let resumePage: (() => void) | undefined
|
|
30
|
+
let watchingPage = false
|
|
31
|
+
|
|
32
|
+
export function registerPresmiumPlugin(plugin: PluginDefinition): void {
|
|
33
|
+
const { id } = plugin.manifest
|
|
34
|
+
if (starting) console.error(`[presmium] ${id} was registered after the kernel started; ignored`)
|
|
35
|
+
else if (registered.some(p => p.manifest.id === id)) console.error(`[presmium] plugin ${id} is already registered; ignored`)
|
|
36
|
+
else registered.push(plugin)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isPluginDefinition(value: unknown): value is PluginDefinition {
|
|
40
|
+
const plugin = value as Partial<PluginDefinition> | null
|
|
41
|
+
return typeof plugin === 'object' && plugin !== null && typeof plugin.setup === 'function' && typeof plugin.manifest?.id === 'string'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pluginsOf(module: unknown): PluginDefinition[] {
|
|
45
|
+
const values = Object.values((module ?? {}) as Record<string, unknown>).flatMap(v => (Array.isArray(v) ? v : [v]))
|
|
46
|
+
return [...new Set(values.filter(isPluginDefinition))]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface StartOptions {
|
|
50
|
+
mode: PresentationMode
|
|
51
|
+
config?: Record<string, unknown>
|
|
52
|
+
beforeStart?(kernel: Kernel, release: (stop: () => void) => void): void
|
|
53
|
+
log?: Logger
|
|
54
|
+
environments?: Record<string, EnvironmentSpec>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function transition<T>(change: () => Promise<T>): Promise<T> {
|
|
58
|
+
const next = lifecycle.then(change)
|
|
59
|
+
lifecycle = next.catch(() => undefined)
|
|
60
|
+
return next
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function startKernel(options: StartOptions): Promise<StartReport> {
|
|
64
|
+
starting = transition(async () => {
|
|
65
|
+
if (runtime) return runtime.started!
|
|
66
|
+
const owner: RuntimeOwner = { kernel: new Kernel({ mode: options.mode, config: options.config, log: options.log, environments: options.environments }), releases: [] }
|
|
67
|
+
runtime = owner
|
|
68
|
+
owner.started = (async () => {
|
|
69
|
+
options.beforeStart?.(owner.kernel, stop => owner.releases.push(stop))
|
|
70
|
+
for (const plugin of registered) owner.kernel.register(plugin)
|
|
71
|
+
const report = await owner.kernel.start()
|
|
72
|
+
running.set(owner.kernel); runtimeRevision.set(n => n + 1)
|
|
73
|
+
return report
|
|
74
|
+
})()
|
|
75
|
+
return owner.started
|
|
76
|
+
})
|
|
77
|
+
return starting
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Stops the kernel and disposes the plugins; `startHost` or `setHostActive(true)` starts it again. */
|
|
81
|
+
export function stopHost(): Promise<void> {
|
|
82
|
+
const requested = starting
|
|
83
|
+
return transition(async () => {
|
|
84
|
+
const owner = runtime
|
|
85
|
+
try {
|
|
86
|
+
if (!owner) return
|
|
87
|
+
if (running.getSnapshot() === owner.kernel) {
|
|
88
|
+
running.set(undefined); runtimeRevision.set(n => n + 1)
|
|
89
|
+
}
|
|
90
|
+
const results = await Promise.allSettled([
|
|
91
|
+
...owner.releases.splice(0).reverse().map(async release => release()),
|
|
92
|
+
owner.kernel.dispose(),
|
|
93
|
+
])
|
|
94
|
+
const failed = results.find(result => result.status === 'rejected')
|
|
95
|
+
if (failed?.status === 'rejected') throw failed.reason
|
|
96
|
+
} finally {
|
|
97
|
+
if (runtime === owner) runtime = undefined
|
|
98
|
+
if (starting === requested) starting = undefined
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stopOnPageHide(start: () => void): void {
|
|
104
|
+
resumePage = start
|
|
105
|
+
if (watchingPage) return
|
|
106
|
+
watchingPage = true
|
|
107
|
+
window.addEventListener('pagehide', () => void stopHost())
|
|
108
|
+
window.addEventListener('pageshow', (event) => {
|
|
109
|
+
if (event.persisted && hostActive) resumePage?.()
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function setHostActive(active: boolean): Promise<void> {
|
|
114
|
+
hostActive = active
|
|
115
|
+
if (active) await resumeHost?.()
|
|
116
|
+
else await stopHost()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function presmiumService<T>(token: ServiceToken<T>): T | undefined {
|
|
120
|
+
return running.getSnapshot()?.getService(token)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function reportPluginFailure(id: string, reason: string): void {
|
|
124
|
+
failures.set([...failures.getSnapshot().filter(report => report.id !== id), { id, status: 'failed', reason }]); runtimeRevision.set(n => n + 1)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function presmiumReport(): StartReport | undefined {
|
|
128
|
+
const report = running.getSnapshot()?.report()
|
|
129
|
+
return report && failures.getSnapshot().length ? { ...report, plugins: [...report.plugins, ...failures.getSnapshot()] } : report
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export interface HostStart {
|
|
133
|
+
mode: PresentationMode
|
|
134
|
+
config: Record<string, unknown>
|
|
135
|
+
navigation: Navigation
|
|
136
|
+
transports: SyncTransports
|
|
137
|
+
environments?: Record<string, EnvironmentSpec>
|
|
138
|
+
server?: ServerClient
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function startHost(options: HostStart): Promise<StartReport> {
|
|
142
|
+
if (options.server) provideServer(options.server)
|
|
143
|
+
const start = () => startKernel({
|
|
144
|
+
mode: options.mode,
|
|
145
|
+
config: options.config,
|
|
146
|
+
environments: options.environments,
|
|
147
|
+
beforeStart(kernel, release) {
|
|
148
|
+
const toolbar = createToolbar()
|
|
149
|
+
const status = createStatusRegistry()
|
|
150
|
+
kernel.provide(PRESENTER_PANELS, createPresenterPanels())
|
|
151
|
+
kernel.provide(SYNC_TRANSPORTS, options.transports)
|
|
152
|
+
kernel.provide(NAVIGATION, options.navigation)
|
|
153
|
+
kernel.provide(TOOLBAR, toolbar)
|
|
154
|
+
const operations = createOperations()
|
|
155
|
+
connectActions(operations)
|
|
156
|
+
kernel.provide(OPERATIONS, operations)
|
|
157
|
+
kernel.provide(PLAYBACK_CONTROL, createPlaybackControl())
|
|
158
|
+
kernel.provide(TIMED_CAPTIONS, createTimedCaptions())
|
|
159
|
+
const stepClock = createStepClock(options.navigation)
|
|
160
|
+
release(stepClock.stop)
|
|
161
|
+
kernel.provide(STEP_CLOCK, stepClock.clock)
|
|
162
|
+
kernel.provide(STATUS, status)
|
|
163
|
+
status.register({
|
|
164
|
+
id: 'presmium.runtime',
|
|
165
|
+
read: () => ({
|
|
166
|
+
plugins: (presmiumReport()?.plugins ?? []).slice(-40).map(plugin => ({ id: plugin.id, status: plugin.status, ...plugin.reason ? { reason: plugin.reason.slice(0, 2000) } : {} })),
|
|
167
|
+
errors: componentErrors().slice(-20).map(error => ({ component: error.component, message: error.message.slice(0, 2000) })),
|
|
168
|
+
}),
|
|
169
|
+
})
|
|
170
|
+
const { server } = options
|
|
171
|
+
if (!server?.available) return
|
|
172
|
+
toolbar.register({
|
|
173
|
+
id: 'presmium.approvals',
|
|
174
|
+
get title() {
|
|
175
|
+
return HOST_MESSAGES[currentLocale()].approvalsTitle
|
|
176
|
+
},
|
|
177
|
+
component: PmApprovals,
|
|
178
|
+
order: 1000,
|
|
179
|
+
})
|
|
180
|
+
release(connectControl(server, { navigation: options.navigation, status, service: token => kernel.getService(token) }))
|
|
181
|
+
},
|
|
182
|
+
})
|
|
183
|
+
resumeHost = start
|
|
184
|
+
hostActive = true
|
|
185
|
+
stopOnPageHide(() => void setHostActive(true))
|
|
186
|
+
return start()
|
|
187
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { PreviewEventSubscription } from '@lincyaw/kernel/contracts'
|
|
2
|
+
import type { Member } from '@lincyaw/kernel/contracts'
|
|
3
|
+
import { currentLocale } from './locale.ts'
|
|
4
|
+
import type { ServerClient } from './contracts.ts'
|
|
5
|
+
import { createStore, useStore } from './store.ts'
|
|
6
|
+
export const SERVER_PATH = '/__presmium/presmium.server'
|
|
7
|
+
const UNAVAILABLE: ServerClient = { available: false, member: createStore<Member | undefined>(undefined), fetch: () => Promise.reject(new Error('no Presmium server serves this page')), events() { throw new Error('no Presmium server serves this page') } }
|
|
8
|
+
const current = createStore<ServerClient>(UNAVAILABLE)
|
|
9
|
+
const documents = new WeakMap<ServerClient, string>()
|
|
10
|
+
const subscriptions = new WeakMap<ServerClient, PreviewEventSubscription>()
|
|
11
|
+
const roots = new WeakMap<ServerClient, string>()
|
|
12
|
+
/** The path a plugin's own server routes are below on this page: `<root>__presmium/<plugin>/`. */
|
|
13
|
+
export function pluginRoutePath(client: ServerClient, plugin: string): string { return `${roots.get(client) ?? '/'}__presmium/${plugin}/` }
|
|
14
|
+
export function clientEventSubscription(client: ServerClient): PreviewEventSubscription | undefined { return subscriptions.get(client) }
|
|
15
|
+
export function clientDocument(client: ServerClient): string { return documents.get(client) ?? '' }
|
|
16
|
+
export function serverClient(): ServerClient { return current.getSnapshot() }
|
|
17
|
+
export function useServer(): ServerClient { return useStore(current) }
|
|
18
|
+
export function provideServer(client: ServerClient | undefined): void { current.set(client ?? UNAVAILABLE) }
|
|
19
|
+
export function documentHeader(document: string): string { return encodeURIComponent(document) }
|
|
20
|
+
/** `root`: the path the server's routes are below (`/user/a/preview/` behind a proxy that forwards a path); `/` by default. */
|
|
21
|
+
export function createServerClient(options: { document?: string, root?: string, subscribeEvents?: PreviewEventSubscription } = {}): ServerClient {
|
|
22
|
+
const document = options.document ?? ''
|
|
23
|
+
const root = (options.root ?? '/').replace(/\/?$/, '/')
|
|
24
|
+
const member = createStore<Member | undefined>(undefined)
|
|
25
|
+
const url = (plugin: string, path: string) => `${root}__presmium/${plugin}/${path.replace(/^\//, '')}`
|
|
26
|
+
const client: ServerClient = {
|
|
27
|
+
available: true, member,
|
|
28
|
+
fetch(plugin, path, init = {}) { const headers = new Headers(init.headers); headers.set('Presmium-Document', documentHeader(document)); if (!headers.has('Accept-Language')) headers.set('Accept-Language', currentLocale()); return fetch(url(plugin, path), { ...init, headers }) },
|
|
29
|
+
events(plugin, path) { const target = url(plugin, path); return new EventSource(`${target}${target.includes('?') ? '&' : '?'}document=${documentHeader(document)}`) },
|
|
30
|
+
}
|
|
31
|
+
void fetch(`${root}${SERVER_PATH.slice(1)}/hello`, { headers: { 'Presmium-Document': documentHeader(document) } }).then(response => response.ok ? response.json() : undefined).then((hello: { member?: Member } | undefined) => member.set(hello?.member)).catch(() => undefined)
|
|
32
|
+
documents.set(client, document)
|
|
33
|
+
roots.set(client, root)
|
|
34
|
+
if (options.subscribeEvents) subscriptions.set(client, options.subscribeEvents)
|
|
35
|
+
return client
|
|
36
|
+
}
|
package/src/session.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
import { SESSION } from '@lincyaw/kernel'
|
|
3
|
+
import type { SessionSnapshot } from '@lincyaw/kernel'
|
|
4
|
+
import { useService } from './host.ts'
|
|
5
|
+
export function useSessionSnapshot() {
|
|
6
|
+
const session = useService(SESSION)
|
|
7
|
+
const [snapshot, setSnapshot] = useState<SessionSnapshot>()
|
|
8
|
+
useEffect(() => { setSnapshot(session?.snapshot()); return session?.subscribe(setSnapshot) }, [session])
|
|
9
|
+
return { session, snapshot }
|
|
10
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SharedStateScope, SyncPublish, SyncTransport, SyncTransports } from './contracts.ts'
|
|
2
|
+
import { createStore } from './store.ts'
|
|
3
|
+
import type { Store } from './store.ts'
|
|
4
|
+
import { joinWindows } from './windows.ts'
|
|
5
|
+
interface Channel { name: string, receive(patch: Record<string, unknown>): void, value(): unknown, publishers: Map<SyncTransport, SyncPublish> }
|
|
6
|
+
export interface SharedStates { sharedState<T>(key: string, initial: T, scope: SharedStateScope): Store<T>, transports: SyncTransports }
|
|
7
|
+
const plain = (value: unknown): unknown => value === undefined ? undefined : JSON.parse(JSON.stringify(value))
|
|
8
|
+
export function createSharedStates(deck: string): SharedStates {
|
|
9
|
+
const states = new Map<string, Store<unknown>>()
|
|
10
|
+
const channels = new Map<string, Channel>()
|
|
11
|
+
const transports = new Set<SyncTransport>()
|
|
12
|
+
const connect = (transport: SyncTransport, channel: Channel) => { const publish = transport.connect(channel.name, channel.receive, { value: channel.value() }, false); if (publish) channel.publishers.set(transport, publish) }
|
|
13
|
+
function make(id: string, initial: unknown, scope: SharedStateScope): Store<unknown> {
|
|
14
|
+
const state = createStore(plain(initial))
|
|
15
|
+
if (scope === 'view') return state
|
|
16
|
+
const name = `presmium.shared:${deck}:${id}`
|
|
17
|
+
let from: 'window' | 'remote' | undefined
|
|
18
|
+
let changed = false
|
|
19
|
+
const apply = (value: unknown, source: 'window' | 'remote') => { from = source; state.set(value); from = undefined }
|
|
20
|
+
const windows = joinWindows(name, value => apply(value, 'window'), () => changed ? plain(state.getSnapshot()) : undefined)
|
|
21
|
+
const channel: Channel = { name, receive(patch) { if ('value' in patch) apply(patch.value, 'remote') }, value: () => plain(state.getSnapshot()), publishers: new Map() }
|
|
22
|
+
state.subscribe(() => { changed = true; const value = plain(state.getSnapshot()); if (!from) windows?.send(value); channel.publishers.forEach(publish => publish({ value }, from !== undefined)) })
|
|
23
|
+
if (scope === 'remote') { channels.set(id, channel); transports.forEach(transport => connect(transport, channel)) }
|
|
24
|
+
return state
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
sharedState<T>(key: string, initial: T, scope: SharedStateScope) { const id = `${scope}:${key}`; let state = states.get(id); if (!state) { state = make(id, initial, scope); states.set(id, state) } return state as Store<T> },
|
|
28
|
+
transports: { add(transport) { transports.add(transport); channels.forEach(channel => connect(transport, channel)); return () => { transports.delete(transport); channels.forEach(channel => channel.publishers.delete(transport)) } } },
|
|
29
|
+
}
|
|
30
|
+
}
|