@lincyaw/addon-session 0.0.0-stage → 1.0.0-rc.2

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.
@@ -0,0 +1,72 @@
1
+ import type { Participant, SessionStatus } from '@lincyaw/kernel'
2
+
3
+ export interface Position {
4
+ slide: number
5
+ step: number
6
+ view?: string
7
+ section?: string
8
+ trail?: string[]
9
+ back?: string
10
+ }
11
+
12
+ export type FollowMode = 'follow' | 'free'
13
+
14
+ export interface SessionRecord {
15
+ status?: 'active' | 'ended'
16
+ presenter?: string
17
+ presenterName?: string
18
+ cursor?: Position
19
+ startedAt?: number
20
+ endedAt?: number
21
+ archive?: string
22
+ archivedAt?: number
23
+ }
24
+
25
+ export function sessionStatus(record: SessionRecord): Exclude<SessionStatus, 'idle'> {
26
+ return record.status === 'ended' ? 'ended' : 'active'
27
+ }
28
+
29
+ export function startRecord(presenter: Participant, now: number): SessionRecord {
30
+ return { status: 'active', presenter: presenter.id, presenterName: presenter.name, startedAt: now }
31
+ }
32
+
33
+ export function endRecord(now: number): SessionRecord {
34
+ return { status: 'ended', endedAt: now }
35
+ }
36
+
37
+ export function samePosition(a: Position | undefined, b: Position | undefined): boolean {
38
+ return a?.slide === b?.slide && a?.step === b?.step && a?.view === b?.view && a?.section === b?.section
39
+ && a?.trail?.join('\n') === b?.trail?.join('\n') && a?.back === b?.back
40
+ }
41
+
42
+ export function clonePosition(position: Position): Position {
43
+ const { slide, step, view, section, trail, back } = position
44
+ return { slide, step, ...view ? { view } : {}, ...section ? { section } : {}, ...trail ? { trail: [...trail] } : {}, ...back ? { back } : {} }
45
+ }
46
+
47
+ export function followTarget(me: Participant | undefined, follow: FollowMode, record: SessionRecord): Position | undefined {
48
+ if (me?.role !== 'learner' || follow !== 'follow' || sessionStatus(record) !== 'active') return undefined
49
+ return record.cursor
50
+ }
51
+
52
+ export function jsonClone<T>(value: T): T {
53
+ return value === undefined ? value : JSON.parse(JSON.stringify(value)) as T
54
+ }
55
+
56
+ export function changedEntries(current: (key: string) => unknown, next: Readonly<Record<string, unknown>>): [string, unknown][] {
57
+ return Object.entries(jsonClone(next)).filter(([key, value]) => value !== undefined && JSON.stringify(current(key)) !== JSON.stringify(value))
58
+ }
59
+
60
+ function isParticipant(value: unknown): value is Participant {
61
+ const p = value as Partial<Participant> | null | undefined
62
+ return typeof p?.id === 'string' && typeof p.name === 'string' && (p.role === 'presenter' || p.role === 'learner')
63
+ }
64
+
65
+ export function participantsOf(states: Iterable<unknown>): Participant[] {
66
+ const byId = new Map<string, Participant>()
67
+ for (const state of states) {
68
+ const participant = (state as { participant?: unknown } | null)?.participant
69
+ if (isParticipant(participant)) byId.set(participant.id, { id: participant.id, name: participant.name, role: participant.role })
70
+ }
71
+ return [...byId.values()].sort((a, b) => (a.role === b.role ? a.name.localeCompare(b.name, 'zh') : a.role === 'presenter' ? -1 : 1))
72
+ }
@@ -0,0 +1,49 @@
1
+ import type { ClassroomStore, SessionService, SessionSnapshot, Unsubscribe } from '@lincyaw/kernel'
2
+ import type { SessionMessages } from './messages.ts'
3
+ import type { FollowMode } from './session-record.ts'
4
+
5
+ export interface ClassroomSnapshot extends SessionSnapshot {
6
+ server?: string
7
+ connection: 'offline' | 'connecting' | 'connected'
8
+ follow: FollowMode
9
+ joinUrl?: string
10
+ invitation?: string
11
+ rejoin?: string
12
+ error?: string
13
+ refused?: boolean
14
+ archive?: string
15
+ }
16
+
17
+ export interface ClassroomSession extends SessionService {
18
+ snapshot(): ClassroomSnapshot
19
+ subscribe(listener: (snapshot: ClassroomSnapshot) => void): Unsubscribe
20
+ setFollow(mode: FollowMode): void
21
+ rejoin(): Promise<void>
22
+ archive(): Promise<string>
23
+ readonly personal: ClassroomStore
24
+ }
25
+
26
+ export function isClassroomSession(service: SessionService | undefined): service is ClassroomSession {
27
+ return typeof (service as Partial<ClassroomSession> | undefined)?.setFollow === 'function'
28
+ }
29
+
30
+ export function statusText(s: ClassroomSnapshot, t: SessionMessages): string {
31
+ if (!s.server) return t.status.noServer
32
+ if (s.status === 'idle') {
33
+ if (s.error) return t.status.failed(s.error)
34
+ return s.invitation ? t.status.invited(s.invitation) : t.status.idle
35
+ }
36
+ if (s.status === 'ended') return s.archive ? t.status.archived(s.archive) : t.status.ended
37
+ if (s.connection !== 'connected') return t.status.connecting
38
+ if (s.me?.role === 'presenter') return t.status.presenting
39
+ return s.follow === 'follow' ? t.status.following : t.status.browsing
40
+ }
41
+
42
+ export function shortStatus(s: ClassroomSnapshot, t: SessionMessages): string {
43
+ if (!s.server) return t.short.noServer
44
+ if (s.status === 'idle') return t.short.idle
45
+ if (s.status === 'ended') return s.archive ? t.short.archived : t.short.ended
46
+ if (s.connection !== 'connected') return t.short.connecting
47
+ if (s.me?.role === 'presenter') return t.short.presenter
48
+ return s.follow === 'follow' ? t.short.follow : t.short.free
49
+ }
@@ -0,0 +1,45 @@
1
+ import { createRequire } from 'node:module'
2
+ import { startClassroomServer } from '@lincyaw/classroom-server'
3
+ import { isRecord, jsonResponse } from '@lincyaw/kernel'
4
+ import type { Check, PresmiumNodePlugin } from '@lincyaw/kernel'
5
+ import { SESSION_ID, SYNC_PATH, toSessionConfig } from '../domain/index.ts'
6
+
7
+ const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
8
+
9
+ const serverCheck: Check = {
10
+ id: SESSION_ID,
11
+ async run(ctx) {
12
+ const config = ctx.ir.config.presmium[SESSION_ID]
13
+ if (toSessionConfig(isRecord(config) ? config : {}).server) return []
14
+ return [{
15
+ checkId: SESSION_ID,
16
+ severity: 'info',
17
+ message: 'no classroom server: presmium dev and presmium serve sync the classroom on their own port; a build shows no classroom',
18
+ hint: 'for a published deck, start presmium-classroom-server and set presmium: { presmium.session: { server: ws://<host>:<port> } } in the frontmatter',
19
+ }]
20
+ },
21
+ }
22
+
23
+ const plugin: PresmiumNodePlugin = {
24
+ id: SESSION_ID,
25
+ version,
26
+ kernel: '^1.0.0',
27
+ checks: [serverCheck],
28
+ async server(ctx) {
29
+ const classroom = await startClassroomServer({
30
+ port: 0,
31
+ listenAddress: '127.0.0.1',
32
+ deck: ctx.deckDir,
33
+ onDropped: (room, writer, reason) => ctx.log.warn(`classroom ${room}: dropped ${reason} from ${writer.presenter ? 'the presenter' : writer.participant ? `participant ${writer.participant}` : 'an unidentified participant'}`),
34
+ })
35
+ return {
36
+ routes: {
37
+ classroom: { role: 'reader', handle: (_request, { document }) => jsonResponse({ path: `/__presmium/${SESSION_ID}/${SYNC_PATH}`, document }) },
38
+ },
39
+ proxies: [{ path: SYNC_PATH, target: `http://127.0.0.1:${classroom.port}`, role: 'reader' }],
40
+ close: () => classroom.close(),
41
+ }
42
+ },
43
+ }
44
+
45
+ export default plugin