@lincyaw/server 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/src/focus.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { resolveFocus, toFocusReport } from '@lincyaw/mcp'
2
+ import type { McpFocus } from '@lincyaw/mcp'
3
+ import type { FocusReport, IRDocument } from '@lincyaw/kernel'
4
+
5
+ const MAX_FOCUS = 50
6
+
7
+ export class FocusStore {
8
+ readonly #latest = new Map<string, McpFocus>()
9
+ readonly #watchers = new Set<() => void>()
10
+
11
+ find(document?: string): McpFocus | undefined {
12
+ if (document !== undefined) return this.#latest.get(document)
13
+ let newest: McpFocus | undefined
14
+ for (const found of this.#latest.values()) if (!newest || found.at >= newest.at) newest = found
15
+ return newest
16
+ }
17
+
18
+ report(document: string, doc: IRDocument | undefined, reports: FocusReport[], readWorkspaceFile?: (file: string) => string): McpFocus {
19
+ const record = { document, focus: reports.map(report => resolveFocus(doc, document, report, undefined, readWorkspaceFile)), at: Date.now() }
20
+ this.#latest.set(document, record)
21
+ for (const watcher of this.#watchers) watcher()
22
+ return record
23
+ }
24
+
25
+ forget(document: string): void {
26
+ if (this.#latest.delete(document)) for (const watcher of this.#watchers) watcher()
27
+ }
28
+
29
+ watch(listener: () => void): () => void {
30
+ this.#watchers.add(listener)
31
+ return () => this.#watchers.delete(listener)
32
+ }
33
+ }
34
+
35
+ export function toFocusRequest(body: unknown): { document: string, focus: FocusReport[] } | string {
36
+ if (!body || typeof body !== 'object') return 'a JSON object { document, focus: [...] }'
37
+ const { document, focus } = body as { document?: unknown, focus?: unknown }
38
+ if (typeof document !== 'string') return 'the document directory (relative to the workspace root) as `document`'
39
+ if (!Array.isArray(focus) || focus.length > MAX_FOCUS) return `focus is a list of at most ${MAX_FOCUS}`
40
+ const reports: FocusReport[] = []
41
+ for (const item of focus) {
42
+ const report = toFocusReport(item)
43
+ if (typeof report === 'string') return report
44
+ reports.push(report)
45
+ }
46
+ return { document, focus: reports }
47
+ }
package/src/http.ts ADDED
@@ -0,0 +1,188 @@
1
+ import { timingSafeEqual } from 'node:crypto'
2
+ import { Agent, createServer } from 'node:http'
3
+ import { Agent as HttpsAgent } from 'node:https'
4
+ import type { IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from 'node:http'
5
+ import type { AddressInfo } from 'node:net'
6
+ import type { Duplex } from 'node:stream'
7
+ import { getRequestListener } from '@hono/node-server'
8
+ import { jsonResponse } from '@lincyaw/kernel'
9
+ import { bracketIPv6 } from '@lincyaw/kernel/node'
10
+ import { createProxyServer } from 'http-proxy-3'
11
+
12
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
13
+
14
+ export function normalizeHostName(host: string | null | undefined): string {
15
+ if (!host) return ''
16
+ const value = host.trim().toLowerCase()
17
+ if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1)
18
+ if (value.indexOf(':') !== value.lastIndexOf(':')) return `[${value}]`
19
+ return value.replace(/:\d+$/, '')
20
+ }
21
+
22
+ export function isLoopback(host: string | null | undefined): boolean {
23
+ return LOOPBACK.has(normalizeHostName(host))
24
+ }
25
+
26
+ export function originHost(origin: string | null | undefined): string | undefined {
27
+ if (origin === null || origin === undefined) return undefined
28
+ try {
29
+ return normalizeHostName(new URL(origin).host)
30
+ } catch {
31
+ return ''
32
+ }
33
+ }
34
+
35
+ export function bearer(value: string | null | undefined): string | undefined {
36
+ const match = /^Bearer\s+(\S+)$/i.exec(value?.trim() ?? '')
37
+ return match?.[1]
38
+ }
39
+
40
+ export function hasBearerToken(request: Request, accessToken: string): boolean {
41
+ const given = Buffer.from(bearer(request.headers.get('authorization')) ?? '')
42
+ const own = Buffer.from(accessToken)
43
+ return given.length === own.length && timingSafeEqual(given, own)
44
+ }
45
+
46
+ export async function limitBody(request: Request, limit: number): Promise<Request | Response> {
47
+ const tooLarge = () => jsonResponse({ error: `the request body is larger than this route takes (${limit} bytes)` }, 413)
48
+ const declared = Number(request.headers.get('content-length') ?? Number.NaN)
49
+ if (declared > limit) return tooLarge()
50
+ if (!request.body) return request
51
+ const chunks: Uint8Array[] = []
52
+ let size = 0
53
+ const reader = request.body.getReader()
54
+ for (;;) {
55
+ const { done, value } = await reader.read()
56
+ if (done) break
57
+ size += value.byteLength
58
+ if (size > limit) {
59
+ void drop(reader)
60
+ return tooLarge()
61
+ }
62
+ chunks.push(value)
63
+ }
64
+ const body = new Uint8Array(size)
65
+ let at = 0
66
+ for (const chunk of chunks) {
67
+ body.set(chunk, at)
68
+ at += chunk.byteLength
69
+ }
70
+ return new Request(request.url, { method: request.method, headers: request.headers, body, signal: request.signal })
71
+ }
72
+
73
+ async function drop(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<void> {
74
+ try {
75
+ while (!(await reader.read()).done) {}
76
+ } catch {}
77
+ }
78
+
79
+ export function header(headers: IncomingHttpHeaders, name: string): string | undefined {
80
+ const value = headers[name.toLowerCase()]
81
+ return Array.isArray(value) ? value[0] : value
82
+ }
83
+
84
+ export async function send(res: ServerResponse, response: Response): Promise<void> {
85
+ res.writeHead(response.status, Object.fromEntries(response.headers))
86
+ res.end(Buffer.from(await response.arrayBuffer()))
87
+ }
88
+
89
+ export function refuseUpgrade(socket: Duplex, status: number): void {
90
+ socket.end(`HTTP/1.1 ${status} ${status === 401 ? 'Unauthorized' : status === 403 ? 'Forbidden' : 'Not Found'}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
91
+ }
92
+
93
+ export interface ProxyTarget {
94
+ target: string
95
+ path: string
96
+ headers?: Record<string, string>
97
+ }
98
+
99
+ export function createProxy() {
100
+ // A page loads hundreds of modules: the connections to the targets are kept and reused, not opened for each.
101
+ const agents = { http: new Agent({ keepAlive: true, maxSockets: 64 }), https: new HttpsAgent({ keepAlive: true, maxSockets: 64 }) }
102
+ const agentFor = (target: string) => (target.startsWith('https:') || target.startsWith('wss:') ? agents.https : agents.http)
103
+ const proxy = createProxyServer({ ws: true, xfwd: false })
104
+ proxy.on('error', (error: Error, _req: unknown, res: unknown) => {
105
+ const response = res as ServerResponse | Duplex | undefined
106
+ if (response && 'writeHead' in response && !response.headersSent) {
107
+ response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' })
108
+ response.end(`proxy error: ${error.message}`)
109
+ } else {
110
+ response?.destroy?.()
111
+ }
112
+ })
113
+ return {
114
+ web(req: IncomingMessage, res: ServerResponse, to: ProxyTarget): void {
115
+ req.url = to.path
116
+ proxy.web(req, res, { target: to.target, headers: to.headers, agent: agentFor(to.target) })
117
+ },
118
+ ws(req: IncomingMessage, socket: Duplex, head: Buffer, to: ProxyTarget): void {
119
+ req.url = to.path
120
+ proxy.ws(req, socket, head, { target: to.target, headers: to.headers, agent: agentFor(to.target) })
121
+ },
122
+ close: () => {
123
+ proxy.close()
124
+ agents.http.destroy()
125
+ agents.https.destroy()
126
+ },
127
+ }
128
+ }
129
+
130
+ export type Proxy = ReturnType<typeof createProxy>
131
+
132
+ export type Decision =
133
+ | { kind: 'fetch' }
134
+ | { kind: 'proxy', to: ProxyTarget }
135
+ | { kind: 'answer', response: Response }
136
+
137
+ export function nodeServer(options: {
138
+ decide(req: IncomingMessage): Promise<Decision>
139
+ decideUpgrade(req: IncomingMessage): Promise<{ to?: ProxyTarget, status?: number }>
140
+ fetch(request: Request, req: IncomingMessage): Promise<Response>
141
+ proxy: Proxy
142
+ }): { server: Server, close(): Promise<void> } {
143
+ const listener = getRequestListener((request, env) => options.fetch(request, (env as { incoming: IncomingMessage }).incoming))
144
+ const upgrades = new Set<Duplex>()
145
+ const server = createServer((req, res) => {
146
+ void options.decide(req).then((decision) => {
147
+ if (res.destroyed) return
148
+ if (decision.kind === 'fetch') return listener(req, res)
149
+ if (decision.kind === 'proxy') return options.proxy.web(req, res, decision.to)
150
+ return send(res, decision.response)
151
+ }).catch((error: unknown) => {
152
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
153
+ res.end(error instanceof Error ? error.message : String(error))
154
+ })
155
+ })
156
+ server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
157
+ upgrades.add(socket)
158
+ socket.once('close', () => upgrades.delete(socket))
159
+ void options.decideUpgrade(req).then(({ to, status }) => {
160
+ if (socket.destroyed) return
161
+ if (to) options.proxy.ws(req, socket, head, to)
162
+ else refuseUpgrade(socket, status ?? 404)
163
+ }, () => refuseUpgrade(socket, 500))
164
+ })
165
+ return {
166
+ server,
167
+ close: () => new Promise((resolve) => {
168
+ server.close(() => resolve())
169
+ server.closeAllConnections()
170
+ for (const socket of upgrades) socket.destroy()
171
+ }),
172
+ }
173
+ }
174
+
175
+ export function listen(server: Server, port: number, host: string): Promise<number> {
176
+ return new Promise((resolve, reject) => {
177
+ server.once('error', reject)
178
+ server.listen(port, host, () => {
179
+ server.off('error', reject)
180
+ resolve((server.address() as AddressInfo).port)
181
+ })
182
+ })
183
+ }
184
+
185
+ export function urlHost(host: string): string {
186
+ if (host === '0.0.0.0' || host === '::') return '127.0.0.1'
187
+ return bracketIPv6(host)
188
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export type { ApprovalPolicy } from './control.ts'
2
+ export { DiagnosticStore } from './diagnostics.ts'
3
+ export { serverConfigKey, servedDocument } from './server-host.ts'
4
+ export type { ServedDocument, ServedPlugin, ServerHost } from './server-host.ts'
5
+ export { isAnyAddress } from './auth.ts'
6
+ export { hasBearerToken, isLoopback } from './http.ts'
7
+ export { startServer } from './server.ts'
8
+ export type { PresmiumServer, ServerOptions } from './server.ts'
@@ -0,0 +1,76 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { LOCAL_AUTHOR } from '@lincyaw/kernel'
3
+ import type { McpEndpoint, McpGrant } from '@lincyaw/kernel'
4
+ import type { McpCaller } from '@lincyaw/mcp'
5
+
6
+ function endedBy(response: Response, signal: AbortSignal, done: () => void): Response {
7
+ if (!response.body) {
8
+ done()
9
+ return response
10
+ }
11
+ const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
12
+ void response.body.pipeTo(writable, { signal, preventAbort: true })
13
+ .catch(() => writable.close().catch(() => undefined))
14
+ .finally(done)
15
+ return new Response(readable, { status: response.status, statusText: response.statusText, headers: response.headers })
16
+ }
17
+
18
+ /**
19
+ * The tokens that open /mcp: this process's own (written to its state file), the grants MCP_ENDPOINT issues,
20
+ * and the grants the launcher asks for its agent sessions. Revoking a grant ends the event streams it opened.
21
+ */
22
+ export class McpTokens {
23
+ readonly processMcpToken = randomBytes(24).toString('base64url')
24
+ readonly endpoint: McpEndpoint
25
+ readonly #callers = new Map<string, McpCaller>()
26
+ readonly #streams = new Map<string, Set<AbortController>>()
27
+ readonly #agentSessionGrants = new Map<string, McpGrant>()
28
+
29
+ constructor(serverUrl: () => string) {
30
+ this.endpoint = {
31
+ issue: (member, mode, issued = {}) => {
32
+ const mcpToken = randomBytes(24).toString('base64url')
33
+ this.#callers.set(mcpToken, { member, mode, ...issued.confirmed ? { confirmed: true } : {}, ...issued.document !== undefined ? { document: issued.document } : {} })
34
+ return { url: `${serverUrl()}/mcp`, token: mcpToken, revoke: () => this.#revoke(mcpToken) }
35
+ },
36
+ }
37
+ }
38
+
39
+ callerOf(mcpToken: string | undefined): McpCaller | undefined {
40
+ if (mcpToken === undefined) return undefined
41
+ return mcpToken === this.processMcpToken ? { member: LOCAL_AUTHOR, mode: 'author' } : this.#callers.get(mcpToken)
42
+ }
43
+
44
+ /** Ties an event stream to the grant that opened it, so that revoking the grant ends the stream. */
45
+ tieToGrant(mcpToken: string, response: Response): Response {
46
+ if (mcpToken === this.processMcpToken || !response.headers.get('content-type')?.startsWith('text/event-stream')) return response
47
+ if (!this.#callers.has(mcpToken)) return endedBy(response, AbortSignal.abort(), () => {})
48
+ const stream = new AbortController()
49
+ const open = this.#streams.get(mcpToken) ?? new Set()
50
+ this.#streams.set(mcpToken, open.add(stream))
51
+ return endedBy(response, stream.signal, () => open.delete(stream))
52
+ }
53
+
54
+ grantAgentSession(session: string, document: string): McpGrant {
55
+ this.#agentSessionGrants.get(session)?.revoke()
56
+ const grant = this.endpoint.issue(LOCAL_AUTHOR, 'author', { confirmed: true, document })
57
+ this.#agentSessionGrants.set(session, grant)
58
+ return grant
59
+ }
60
+
61
+ /** Revokes the grant of one agent session, or of every one; returns how many it revoked. */
62
+ revokeAgentSessions(session?: string): number {
63
+ const revoked = session === undefined ? [...this.#agentSessionGrants.keys()] : this.#agentSessionGrants.has(session) ? [session] : []
64
+ for (const id of revoked) {
65
+ this.#agentSessionGrants.get(id)!.revoke()
66
+ this.#agentSessionGrants.delete(id)
67
+ }
68
+ return revoked.length
69
+ }
70
+
71
+ #revoke(mcpToken: string): void {
72
+ this.#callers.delete(mcpToken)
73
+ for (const stream of this.#streams.get(mcpToken) ?? []) stream.abort()
74
+ this.#streams.delete(mcpToken)
75
+ }
76
+ }
@@ -0,0 +1,96 @@
1
+ import { dirname } from 'node:path'
2
+ import { isPresentingRole } from '@lincyaw/kernel'
3
+ import type { AgentTool, Member, ServiceToken } from '@lincyaw/kernel'
4
+ import type { McpHost, McpPluginTools, McpPosition } from '@lincyaw/mcp'
5
+ import type { ControlChannel } from './control.ts'
6
+ import type { FocusStore } from './focus.ts'
7
+ import type { DiagnosticStore } from './diagnostics.ts'
8
+ import type { ServedDocument, ServerHost } from './server-host.ts'
9
+
10
+ export interface McpHostOptions {
11
+ serverHost?: ServerHost
12
+ control: ControlChannel
13
+ command: 'dev' | 'serve'
14
+ mount(document: string): Promise<ServedDocument | undefined>
15
+ mounted(): Promise<ServedDocument[]>
16
+ service<T>(token: ServiceToken<T>): T | undefined
17
+ focus: FocusStore
18
+ diagnostics: DiagnosticStore
19
+ }
20
+
21
+ function toolsOf(documents: ServedDocument[], own: McpHostOptions['service']): McpPluginTools {
22
+ const tools = new Map<string, AgentTool>()
23
+ for (const doc of documents) {
24
+ for (const { plugin } of doc.plugins) for (const tool of plugin.tools ?? []) if (!tools.has(tool.name)) tools.set(tool.name, tool)
25
+ }
26
+ const doc = documents[0]
27
+ return { tools: [...tools.values()], service: <T>(token: ServiceToken<T>) => own(token) ?? doc?.service(token) }
28
+ }
29
+
30
+ export function serverMcpHost(options: McpHostOptions): McpHost {
31
+ const { control } = options
32
+ const host = (): ServerHost => {
33
+ if (!options.serverHost) throw new Error('this server has no host operations')
34
+ return options.serverHost
35
+ }
36
+ const position = (entry: ReturnType<ControlChannel['positions']>[number]): McpPosition => {
37
+ const { report } = entry
38
+ const { slide, step, view, section, trail } = report.position!
39
+ return { document: entry.document, member: entry.member, slide, step, view, ...section === undefined ? {} : { section }, ...trail ? { trail } : {}, role: report.role, at: report.at }
40
+ }
41
+ return {
42
+ process: { command: options.command },
43
+ get projectAuthoring() { return options.serverHost?.projectAuthoring },
44
+ get authoring() { return options.serverHost?.authoring },
45
+ get documentHistory() { return options.serverHost?.documentHistory },
46
+ get authoringClients() { return options.serverHost?.authoringClients },
47
+ get audioAuthoring() { return options.serverHost?.audioAuthoring },
48
+ get imageAuthoring() { return options.serverHost?.imageAuthoring },
49
+ get voiceAuthoring() { return options.serverHost?.voiceAuthoring },
50
+ get transcriptions() { return options.serverHost?.transcriptions },
51
+ get transcriptionAdoption() { return options.serverHost?.transcriptionAdoption },
52
+ readyTools: document => options.serverHost?.agentTools?.(document) ?? { tools: [], service: options.service },
53
+ watchTools: listener => options.serverHost?.watchAgentTools?.(listener) ?? (() => {}),
54
+ documents: () => host().documents(),
55
+ compile: document => host().compile(document),
56
+ async check(document) {
57
+ const { doc } = await host().compile(document)
58
+ const irRevision = options.diagnostics.irRevision(document)
59
+ const findings = await host().check(document)
60
+ if (irRevision === options.diagnostics.irRevision(document)) {
61
+ const compiled = new Set(doc.diagnostics.map(finding => JSON.stringify(finding)))
62
+ options.diagnostics.replace({ document, origin: 'check' }, findings.filter(finding => !compiled.has(JSON.stringify(finding))), { root: host().workspaceRoot ?? dirname(doc.meta.entry), irRevision })
63
+ }
64
+ return findings
65
+ },
66
+ build: (document, build) => host().build(document, build),
67
+ get jobs() {
68
+ return options.serverHost?.jobs
69
+ },
70
+ run: (document, target, signal) => host().run(document, target, signal),
71
+ approve: (request, signal) => control.approvals.request(request, signal),
72
+ offers: kind => control.policy(kind) !== 'off',
73
+ approval: kind => control.policy(kind),
74
+ async tools(document) {
75
+ if (document === undefined) return toolsOf(await options.mounted(), options.service)
76
+ const doc = await options.mount(document)
77
+ return toolsOf(doc ? [doc] : [], options.service)
78
+ },
79
+ positions(document: string | undefined, member: Member) {
80
+ const all = control.positions({ document, viewer: member })
81
+ const presenting = (w: typeof all[number]) => w.member === member.id && isPresentingRole(w.report.role)
82
+ return [...all.filter(presenting), ...all.filter(w => !presenting(w))].map(position)
83
+ },
84
+ watchPositions: listener => control.watch(listener),
85
+ focus: document => options.focus.find(document),
86
+ watchFocus: listener => options.focus.watch(listener),
87
+ async diagnostics(document) {
88
+ return options.diagnostics.snapshot(document)
89
+ },
90
+ watchDiagnostics: listener => options.diagnostics.watch(listener),
91
+ classroomStatus: document => control.status(document),
92
+ runtime: document => control.runtime(document).map(({ window, report }) => ({ window, role: report.role, position: report.position, reported: new Date(report.at).toISOString(), status: report.status ?? {} })),
93
+ navigate: (document, to, member) => control.go(to, { member, document }),
94
+ setClassroom: (document, action, name, member) => control.setClassroom(action, name, { document, member }),
95
+ }
96
+ }
package/src/mounts.ts ADDED
@@ -0,0 +1,118 @@
1
+ import { errorMessage, prefixedLogger } from '@lincyaw/kernel'
2
+ import type { Logger, ServerContext, ServerContribution, ServiceToken } from '@lincyaw/kernel'
3
+ import { operatorSpeechSecrets } from '@lincyaw/kernel/node'
4
+ import type { DiagnosticStore } from './diagnostics.ts'
5
+ import type { ServedDocument } from './server-host.ts'
6
+
7
+ export interface Mounted {
8
+ doc: ServedDocument
9
+ contributions: Map<string, ServerContribution>
10
+ }
11
+
12
+ export interface MountsOptions {
13
+ document(dir: string): Promise<ServedDocument | undefined>
14
+ ownService<T>(token: ServiceToken<T>): T | undefined
15
+ settings?: Record<string, Record<string, unknown>>
16
+ operatorDir?: string
17
+ env: NodeJS.ProcessEnv
18
+ diagnostics: DiagnosticStore
19
+ log: Logger
20
+ }
21
+
22
+ /** The plugins' server contributions of each document, made when a request first names the document. */
23
+ export class Mounts {
24
+ readonly #options: MountsOptions
25
+ readonly #mounted = new Map<string, Promise<Mounted | undefined>>()
26
+ readonly #tearingDown = new Map<string, Promise<void>>()
27
+ #stopped = false
28
+
29
+ constructor(options: MountsOptions) {
30
+ this.#options = options
31
+ }
32
+
33
+ mount(dir: string): Promise<Mounted | undefined> {
34
+ if (this.#stopped) return Promise.resolve(undefined)
35
+ const known = this.#mounted.get(dir)
36
+ if (known) return known
37
+ const loading = this.#load(dir, this.#tearingDown.get(dir))
38
+ loading.then((found) => {
39
+ if (!found && this.#mounted.get(dir) === loading) this.#mounted.delete(dir)
40
+ }, () => {
41
+ if (this.#mounted.get(dir) === loading) this.#mounted.delete(dir)
42
+ })
43
+ this.#mounted.set(dir, loading)
44
+ return loading
45
+ }
46
+
47
+ async documents(): Promise<ServedDocument[]> {
48
+ return (await Promise.all([...this.#mounted.values()].map(known => known.catch(() => undefined)))).flatMap(m => (m ? [m.doc] : []))
49
+ }
50
+
51
+ async unmount(dir: string): Promise<void> {
52
+ const known = this.#mounted.get(dir)
53
+ this.#mounted.delete(dir)
54
+ if (!known) return this.#tearingDown.get(dir)
55
+ this.#options.log.debug(`unmounting the server contributions of ${dir || 'the deck'}`)
56
+ const stopped = this.#takeDown(known).finally(() => this.#options.diagnostics.clear(dir, 'server'))
57
+ this.#tearingDown.set(dir, stopped)
58
+ try {
59
+ await stopped
60
+ } finally {
61
+ if (this.#tearingDown.get(dir) === stopped) this.#tearingDown.delete(dir)
62
+ }
63
+ }
64
+
65
+ /** Mounts nothing more: every later request for a document finds none. */
66
+ stop(): void {
67
+ this.#stopped = true
68
+ }
69
+
70
+ async close(): Promise<void> {
71
+ this.stop()
72
+ await Promise.all([...this.#mounted.keys()].map(dir => this.unmount(dir)))
73
+ await Promise.all(this.#tearingDown.values())
74
+ }
75
+
76
+ async #load(dir: string, previous: Promise<void> | undefined): Promise<Mounted | undefined> {
77
+ const { diagnostics, env, log } = this.#options
78
+ await previous
79
+ const doc = await this.#options.document(dir)
80
+ if (!doc) return undefined
81
+ log.debug(`mounting the server contributions of ${dir || 'the deck'}: ${doc.plugins.filter(p => p.plugin.server).map(p => p.id).join(', ') || 'none'}`)
82
+ const contributions = new Map<string, ServerContribution>()
83
+ for (const { id, plugin, config } of doc.plugins) {
84
+ if (!plugin.server) continue
85
+ const operatorDir = this.#options.operatorDir ?? doc.operatorDir
86
+ const ctx: ServerContext = {
87
+ deckDir: doc.deckDir,
88
+ workspaceRoot: doc.workspaceRoot,
89
+ config,
90
+ settings: (this.#options.settings ?? doc.settings)?.[id] ?? {},
91
+ lang: doc.lang,
92
+ service: <T>(token: ServiceToken<T>) => this.#options.ownService(token) ?? doc.service(token),
93
+ secret: operatorSpeechSecrets(operatorDir, name => env[name]),
94
+ ...operatorDir ? { operatorDir } : {},
95
+ ...doc.home ? { home: true } : {},
96
+ log: prefixedLogger(log, id),
97
+ }
98
+ try {
99
+ contributions.set(id, await plugin.server.call(plugin, ctx))
100
+ diagnostics.replace({ document: dir, origin: 'server', instance: id }, [])
101
+ } catch (error) {
102
+ log.error(`plugin ${id} failed to make its server contribution: ${errorMessage(error)}`)
103
+ diagnostics.replace({ document: dir, origin: 'server', instance: id }, [{ checkId: id, severity: 'error', message: `plugin ${id} failed to make its server contribution: ${errorMessage(error)}` }])
104
+ }
105
+ }
106
+ return { doc, contributions }
107
+ }
108
+
109
+ async #takeDown(known: Promise<Mounted | undefined>): Promise<void> {
110
+ for (const [id, contribution] of (await known.catch(() => undefined))?.contributions ?? []) {
111
+ try {
112
+ await contribution.close?.()
113
+ } catch (error) {
114
+ this.#options.log.error(`the contribution of ${id} failed to close: ${errorMessage(error)}`)
115
+ }
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,75 @@
1
+ import type { ProjectAuthoring, DocumentHistory } from '@lincyaw/kernel'
2
+ import type { Compilation } from '@lincyaw/compiler'
3
+ import { pluginId } from '@lincyaw/compiler'
4
+ import type { DocumentSummary, Finding, IRDocument, Member, OutputJobs, PresmiumNodePlugin, RunResult, ServiceToken } from '@lincyaw/kernel'
5
+ import type { AgentTool, DocumentAuthoring, DocumentAudioAuthoring, DocumentImageAuthoring, DocumentVoiceAuthoring, DocumentTranscriptionAdoption, DocumentTranscriptions, Route } from '@lincyaw/kernel'
6
+
7
+ export interface ServedPlugin {
8
+ id: string
9
+ plugin: PresmiumNodePlugin
10
+ config: Record<string, unknown>
11
+ }
12
+
13
+ export interface ServedDocument {
14
+ deckDir: string
15
+ workspaceRoot: string
16
+ lang?: string
17
+ plugins: ServedPlugin[]
18
+ service<T>(token: ServiceToken<T>): T | undefined
19
+ settings?: Record<string, Record<string, unknown>>
20
+ operatorDir?: string
21
+ home?: boolean
22
+ }
23
+
24
+ /** What the server contributions of a compilation depend on, as a key that changes when they must be made again. */
25
+ export function serverConfigKey(compilation: Pick<Compilation, 'doc' | 'plugins'>): string {
26
+ return JSON.stringify([
27
+ compilation.doc.meta.lang,
28
+ compilation.plugins.filter(loaded => loaded.plugin).map(loaded => [pluginId(loaded), loaded.dir, loaded.entry]),
29
+ compilation.doc.config.presmium,
30
+ ])
31
+ }
32
+
33
+ export function servedDocument(compilation: Compilation): ServedDocument {
34
+ const { presmium } = compilation.doc.config
35
+ return {
36
+ deckDir: compilation.deckDir,
37
+ workspaceRoot: compilation.workspaceRoot,
38
+ lang: compilation.doc.meta.lang,
39
+ plugins: compilation.plugins.flatMap((loaded) => {
40
+ if (!loaded.plugin) return []
41
+ const config = presmium[pluginId(loaded)]
42
+ return [{ id: pluginId(loaded), plugin: loaded.plugin, config: config !== null && typeof config === 'object' && !Array.isArray(config) ? config as Record<string, unknown> : {} }]
43
+ }),
44
+ service: compilation.service,
45
+ settings: compilation.settings,
46
+ ...compilation.operatorDir ? { operatorDir: compilation.operatorDir } : {},
47
+ }
48
+ }
49
+
50
+ export interface ServerHost {
51
+ projectAuthoring?: ProjectAuthoring
52
+ authoring?: DocumentAuthoring
53
+ documentHistory?: DocumentHistory
54
+ authoringClients?: AuthoringClients
55
+ audioAuthoring?: DocumentAudioAuthoring
56
+ imageAuthoring?: DocumentImageAuthoring
57
+ voiceAuthoring?: DocumentVoiceAuthoring
58
+ transcriptions?: DocumentTranscriptions
59
+ transcriptionAdoption?: DocumentTranscriptionAdoption
60
+ /** Host-owned routes are available before a document's compiled plugin contributions. */
61
+ routes?: Record<string, Record<string, Route>>
62
+ /** Only already compiled plugin tools. This snapshot must never start or wait for compilation. */
63
+ agentTools?(document?: string): { tools: AgentTool[], service<T>(token: ServiceToken<T>): T | undefined }
64
+ watchAgentTools?(listener: (document: string) => void): () => void
65
+ readSource?(path: string, member: Member): Promise<{ content: string }>
66
+ workspaceRoot?: string
67
+ documents(): Promise<DocumentSummary[]>
68
+ compile(document: string): Promise<{ doc: IRDocument, diagnostics: Finding[] }>
69
+ check(document: string): Promise<Finding[]>
70
+ build(document: string, options?: { to?: string[] }): Promise<{ outputs: { renderer: string, outDir: string }[], findings: Finding[] }>
71
+ run(document: string, target: { section: string, block: string }, signal?: AbortSignal): Promise<RunResult>
72
+ /** The documents' builds and exports as jobs, where the host runs them so (the workspace service). */
73
+ jobs?: OutputJobs
74
+ }
75
+ import type { AuthoringClients } from '@lincyaw/kernel'