@lincyaw/host 1.0.0-rc.0 → 1.0.0-rc.10

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 (46) hide show
  1. package/README.md +14 -15
  2. package/package.json +16 -7
  3. package/src/actions.ts +22 -0
  4. package/src/approvals.ts +9 -55
  5. package/src/blocks.ts +9 -10
  6. package/src/claim-first.ts +13 -0
  7. package/src/code-lines-box.ts +11 -0
  8. package/src/{types.ts → contracts.ts} +62 -12
  9. package/src/control.ts +117 -0
  10. package/src/design/appearance.ts +16 -0
  11. package/src/design/caption.ts +31 -0
  12. package/src/design/components.ts +132 -316
  13. package/src/design/design.css +8 -0
  14. package/src/design/layout.ts +28 -0
  15. package/src/error-boundary.ts +18 -0
  16. package/src/host-key.ts +4 -0
  17. package/src/host.ts +42 -77
  18. package/src/index.ts +55 -25
  19. package/src/locale.ts +15 -37
  20. package/src/messages.ts +25 -10
  21. package/src/operations.ts +6 -22
  22. package/src/panels.ts +25 -57
  23. package/src/playback-control.ts +67 -0
  24. package/src/registries.ts +86 -0
  25. package/src/resident-worker.ts +84 -0
  26. package/src/runtime.ts +187 -0
  27. package/src/server.ts +27 -164
  28. package/src/session.ts +5 -29
  29. package/src/shared-state.ts +30 -0
  30. package/src/site.ts +14 -0
  31. package/src/start-report-panel.ts +8 -0
  32. package/src/step-clock.ts +49 -0
  33. package/src/step-scope.ts +38 -0
  34. package/src/store.ts +33 -0
  35. package/src/styles.d.ts +1 -0
  36. package/src/windows.ts +4 -2
  37. package/src/PresenterPanels.vue +0 -91
  38. package/src/StartReportPanel.vue +0 -45
  39. package/src/Toolbar.vue +0 -49
  40. package/src/boundary.ts +0 -56
  41. package/src/design/theme.ts +0 -32
  42. package/src/kernel.ts +0 -131
  43. package/src/key.ts +0 -6
  44. package/src/scope.ts +0 -29
  45. package/src/shared.ts +0 -85
  46. package/src/vue-shim.d.ts +0 -6
@@ -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 CHANGED
@@ -1,173 +1,36 @@
1
- import { SESSION } from '@lincyaw/kernel/contracts'
2
- import type { ApprovalRequest, Member, ServiceToken, Unsubscribe } from '@lincyaw/kernel/contracts'
3
- import type { Ref } from 'vue'
4
- import { computed, shallowRef, watch } from 'vue'
1
+ import type { PreviewEventSubscription } from '@lincyaw/kernel/contracts'
2
+ import type { Member } from '@lincyaw/kernel/contracts'
5
3
  import { currentLocale } from './locale.ts'
6
- import type { Navigation, NavigationPosition, ServerClient, StatusRegistry, ViewRole } from './types.ts'
7
-
4
+ import type { ServerClient } from './contracts.ts'
5
+ import { createStore, useStore } from './store.ts'
8
6
  export const SERVER_PATH = '/__presmium/presmium.server'
9
-
10
- const UNAVAILABLE: ServerClient = {
11
- available: false,
12
- member: computed(() => undefined),
13
- fetch: () => Promise.reject(new Error('no Presmium server serves this page')),
14
- events() {
15
- throw new Error('no Presmium server serves this page')
16
- },
17
- }
18
-
19
- const current = shallowRef<ServerClient>(UNAVAILABLE)
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)
20
9
  const documents = new WeakMap<ServerClient, string>()
21
-
22
- const facade: ServerClient = {
23
- get available() {
24
- return current.value.available
25
- },
26
- get member() {
27
- return current.value.member
28
- },
29
- fetch: (plugin, path, init) => current.value.fetch(plugin, path, init),
30
- events: (plugin, path) => current.value.events(plugin, path),
31
- }
32
-
33
- export function useServer(): ServerClient {
34
- return facade
35
- }
36
-
37
- export function provideServer(client: ServerClient | undefined): void {
38
- current.value = client ?? UNAVAILABLE
39
- }
40
-
41
- function documentHeader(document: string): string {
42
- return encodeURIComponent(document)
43
- }
44
-
45
- export function createServerClient(options: { document?: string } = {}): ServerClient {
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 {
46
22
  const document = options.document ?? ''
47
- const member = shallowRef<Member>()
48
- const url = (plugin: string, path: string) => `/__presmium/${plugin}/${path.replace(/^\//, '')}`
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(/^\//, '')}`
49
26
  const client: ServerClient = {
50
- available: true,
51
- member: computed(() => member.value),
52
- fetch(plugin, path, init = {}) {
53
- const headers = new Headers(init.headers)
54
- headers.set('Presmium-Document', documentHeader(document))
55
- if (!headers.has('Accept-Language')) headers.set('Accept-Language', currentLocale())
56
- return fetch(url(plugin, path), { ...init, headers })
57
- },
58
- events(plugin, path) {
59
- const target = url(plugin, path)
60
- return new EventSource(`${target}${target.includes('?') ? '&' : '?'}document=${documentHeader(document)}`)
61
- },
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)}`) },
62
30
  }
63
- void fetch(`${SERVER_PATH}/hello`, { headers: { 'Presmium-Document': documentHeader(document) } })
64
- .then(response => (response.ok ? response.json() : response.text().then(() => undefined)))
65
- .then((hello: { member?: Member } | undefined) => {
66
- member.value = hello?.member
67
- })
68
- .catch(() => undefined)
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)
69
32
  documents.set(client, document)
33
+ roots.set(client, root)
34
+ if (options.subscribeEvents) subscriptions.set(client, options.subscribeEvents)
70
35
  return client
71
36
  }
72
-
73
- export interface PendingApproval {
74
- id: string
75
- request: ApprovalRequest
76
- }
77
-
78
- export interface ApprovalState {
79
- pending: Ref<PendingApproval[]>
80
- answer(id: string, allow: boolean): Promise<void>
81
- }
82
-
83
- const pending = shallowRef<PendingApproval[]>([])
84
- let answering: (id: string, allow: boolean) => Promise<void> = async () => {}
85
-
86
- export const approvals: ApprovalState = {
87
- pending,
88
- answer: (id, allow) => answering(id, allow),
89
- }
90
-
91
- function roleOf(position: NavigationPosition): ViewRole {
92
- if (position.view === 'presenter') return 'presenter'
93
- if (position.view === 'present') return 'audience'
94
- if (position.view !== undefined) return 'reader'
95
- return typeof location !== 'undefined' && /\/presenter(?:\/|$)/.test(location.pathname) ? 'presenter' : 'audience'
96
- }
97
-
98
- export function connectControl(client: ServerClient, host: {
99
- navigation: Navigation
100
- status: StatusRegistry
101
- service<T>(token: ServiceToken<T>): T | undefined
102
- }): Unsubscribe {
103
- const { navigation, status } = host
104
- const document = documents.get(client) ?? ''
105
- const id = Math.random().toString(36).slice(2, 12)
106
- const post = (body: Record<string, unknown>) => fetch(`${SERVER_PATH}/control`, {
107
- method: 'POST',
108
- headers: { 'Content-Type': 'application/json', 'Presmium-Document': documentHeader(document) },
109
- body: JSON.stringify({ window: id, ...body }),
110
- }).then(async response => ({ ok: response.ok, status: response.status, text: await response.text() }))
111
- answering = async (approval, allow) => {
112
- const response = await post({ type: 'answer', id: approval, allow })
113
- if (!response.ok) throw new Error(`the server did not take the answer (${response.status})`)
114
- pending.value = pending.value.filter(p => p.id !== approval)
115
- }
116
- const source = new EventSource(`${SERVER_PATH}/control?window=${id}&document=${documentHeader(document)}`)
117
- const on = <T>(event: string, handle: (data: T) => void) => source.addEventListener(event, (message) => {
118
- try {
119
- handle(JSON.parse((message as MessageEvent<string>).data) as T)
120
- } catch (error) {
121
- console.error(`[presmium] control channel: bad ${event} message`, error)
122
- }
123
- })
124
- on<PendingApproval>('approval', (approval) => {
125
- if (!pending.value.some(p => p.id === approval.id)) pending.value = [...pending.value, approval]
126
- })
127
- on<{ id: string }>('settled', (settled) => {
128
- pending.value = pending.value.filter(p => p.id !== settled.id)
129
- })
130
- on<NavigationPosition>('go', position => navigation.go(position))
131
- on<{ action: 'start' | 'end', name?: string }>('session', ({ action, name }) => {
132
- const session = host.service(SESSION)
133
- if (!session) return
134
- void (action === 'start' ? session.create(name ?? '') : session.end()).catch((error: unknown) => {
135
- console.error('[presmium] the classroom command failed', error)
136
- })
137
- })
138
-
139
- let reported = ''
140
- const report = () => {
141
- const position = navigation.position.value
142
- const summaries = Object.fromEntries(status.list().map((provider) => {
143
- try {
144
- return [provider.id, provider.read()]
145
- } catch {
146
- return [provider.id, null]
147
- }
148
- }))
149
- const body = JSON.stringify({ position, role: roleOf(position), status: summaries })
150
- if (body === reported) return
151
- reported = body
152
- void post({ type: 'report', ...JSON.parse(body) as Record<string, unknown> }).catch(() => undefined)
153
- }
154
- source.addEventListener('open', () => {
155
- reported = ''
156
- report()
157
- })
158
- let soon: ReturnType<typeof setTimeout> | undefined
159
- const stopWatch = watch(() => navigation.position.value, () => {
160
- soon ??= setTimeout(() => {
161
- soon = undefined
162
- report()
163
- }, 200)
164
- }, { deep: true })
165
- const timer = setInterval(report, 1000)
166
- return () => {
167
- stopWatch()
168
- clearTimeout(soon)
169
- clearInterval(timer)
170
- source.close()
171
- pending.value = []
172
- }
173
- }
package/src/session.ts CHANGED
@@ -1,34 +1,10 @@
1
+ import { useEffect, useState } from 'react'
1
2
  import { SESSION } from '@lincyaw/kernel'
2
- import type { SessionService, SessionSnapshot } from '@lincyaw/kernel'
3
- import type { ComputedRef, Ref } from 'vue'
4
- import { computed, getCurrentScope, onScopeDispose, shallowRef, watchEffect } from 'vue'
3
+ import type { SessionSnapshot } from '@lincyaw/kernel'
5
4
  import { useService } from './host.ts'
6
-
7
- export function useSessionSnapshot(): { session: ComputedRef<SessionService | undefined>, snapshot: Readonly<Ref<SessionSnapshot | undefined>> } {
5
+ export function useSessionSnapshot() {
8
6
  const session = useService(SESSION)
9
- const snapshot = shallowRef<SessionSnapshot>()
10
- watchEffect((onCleanup) => {
11
- const current = session.value
12
- snapshot.value = current?.snapshot()
13
- if (current) onCleanup(current.subscribe(next => snapshot.value = next))
14
- })
7
+ const [snapshot, setSnapshot] = useState<SessionSnapshot>()
8
+ useEffect(() => { setSnapshot(session?.snapshot()); return session?.subscribe(setSnapshot) }, [session])
15
9
  return { session, snapshot }
16
10
  }
17
-
18
- const groups = new Map<string, Ref<symbol[]>>()
19
-
20
- export function claimFirst(group: string): { owns: Readonly<Ref<boolean>>, release(): void } {
21
- let owners = groups.get(group)
22
- if (!owners) {
23
- owners = shallowRef<symbol[]>([])
24
- groups.set(group, owners)
25
- }
26
- const list = owners
27
- const token = Symbol(group)
28
- list.value = [...list.value, token]
29
- const release = () => {
30
- list.value = list.value.filter(t => t !== token)
31
- }
32
- if (getCurrentScope()) onScopeDispose(release)
33
- return { owns: computed(() => list.value[0] === token), release }
34
- }
@@ -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
+ }
package/src/site.ts ADDED
@@ -0,0 +1,14 @@
1
+ // The document's site path: where its published files and its plugins' run-time files are. The prebuilt viewer that
2
+ // shows every document is built at no site path of its own, so the page's shell tells it; the slides form's build is
3
+ // at the document's path, which Vite gives.
4
+ let base: string | undefined
5
+
6
+ /** Sets the document's site path (ending with `/`), as the page's shell knows it. */
7
+ export function setSiteBase(value: string): void {
8
+ base = value.endsWith('/') ? value : `${value}/`
9
+ }
10
+
11
+ /** The document's site path, ending with `/`: below it are `media/` and the plugins' run-time files (`pyodide/`). */
12
+ export function siteBase(): string {
13
+ return base ?? import.meta.env?.BASE_URL ?? '/'
14
+ }
@@ -0,0 +1,8 @@
1
+ import { createElement as h } from 'react'
2
+ import { useStartReport } from './host.ts'
3
+ import { useLocale, useMessages } from './locale.ts'
4
+ import { HOST_MESSAGES } from './messages.ts'
5
+ import { useStore } from './store.ts'
6
+ import { componentErrors, componentErrorRevision } from './error-boundary.ts'
7
+ // Plugins by the names they declare; a reason the kernel gives (service ids, error text) stays in the console, where it logs it.
8
+ export default function StartReportPanel() { const t = useMessages(HOST_MESSAGES); const locale = useLocale(); const report = useStartReport(); useStore(componentErrorRevision); return h('div', { className: 'pm-start-report' }, report === undefined ? h('p', {}, t.starting) : report.plugins.length ? h('ul', {}, report.plugins.map(plugin => h('li', { key: plugin.id, 'data-plugin': plugin.id, 'data-status': plugin.status }, `${plugin.name?.[locale] ?? t.unnamedPlugin}${t.because}${t.status[plugin.status]}`))) : h('p', {}, t.noPlugins), componentErrors().map((error, key) => h('p', { key, role: 'alert' }, t.componentError(error.component, error.message)))) }