@lincyaw/server 1.0.0-rc.0 → 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.
package/src/server.ts CHANGED
@@ -1,86 +1,70 @@
1
- import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
2
1
  import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
3
2
  import type { IncomingMessage } from 'node:http'
4
3
  import { dirname } from 'node:path'
5
- import { APPROVALS, eventStream, isRecord, MAX_BODY, MCP_ENDPOINT, errorMessage, prefixed } from '@lincyaw/kernel'
6
- import type {
7
- ApprovalKind,
8
- Logger,
9
- McpEndpoint,
10
- Member,
11
- ProxySpec,
12
- Route,
13
- ServerContext,
14
- ServerContribution,
15
- ServiceToken,
16
- } from '@lincyaw/kernel'
4
+ import { APPROVALS, errorMessage, hasRole, MAX_BODY, MCP_ENDPOINT, problemResponse } from '@lincyaw/kernel'
5
+ import type { ApprovalKind, Logger, Member, ProxySpec, Route, ServiceToken } from '@lincyaw/kernel'
17
6
  import { pluginLogger } from '@lincyaw/compiler'
18
7
  import { createPresmiumMcp } from '@lincyaw/mcp'
19
8
  import type { McpCaller } from '@lincyaw/mcp'
20
- import { authenticate, LOCAL_AUTHOR, machineHosts } from './auth.ts'
9
+ import { authenticate, machineHosts } from './auth.ts'
10
+ import type { MachineHosts } from './auth.ts'
21
11
  import type { Refusal } from './auth.ts'
22
- import { Control, hasRole } from './control.ts'
23
- import type { ApprovalPolicy, WindowPosition } from './control.ts'
24
- import { focusBody, FocusStore } from './focus.ts'
25
- import type { ServedDocument, ServerHost } from './host.ts'
26
- import { bearer, closeServer, createProxy, header, isLoopback, json, limitBody, listen, nodeServer, problem, urlHost } from './http.ts'
12
+ import { ControlChannel } from './control.ts'
13
+ import type { ApprovalPolicy } from './control.ts'
14
+ import { FocusStore } from './focus.ts'
15
+ import { DiagnosticStore } from './diagnostics.ts'
16
+ import { bearer, createProxy, header, isLoopback, limitBody, listen, nodeServer, normalizeHostName, urlHost } from './http.ts'
27
17
  import type { Decision, ProxyTarget } from './http.ts'
28
- import { serverMcpHost } from './mcp.ts'
29
-
30
- export { LOCAL_AUTHOR }
31
-
32
- export const SERVER_PATH = '/__presmium/presmium.server'
18
+ import { McpTokens } from './mcp-access.ts'
19
+ import { serverMcpHost } from './mcp-host.ts'
20
+ import { Mounts } from './mounts.ts'
21
+ import { createServerRouteHandler, SERVER_PATH } from './server-routes.ts'
22
+ import type { ServedDocument, ServerHost } from './server-host.ts'
33
23
 
34
24
  function isMcp(path: string): boolean {
35
25
  return path === '/mcp' || path.startsWith('/mcp/')
36
26
  }
37
27
 
38
- function endedBy(response: Response, signal: AbortSignal, done: () => void): Response {
39
- if (!response.body) {
40
- done()
41
- return response
42
- }
43
- const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
44
- void response.body.pipeTo(writable, { signal, preventAbort: true })
45
- .catch(() => writable.close().catch(() => undefined))
46
- .finally(done)
47
- return new Response(readable, { status: response.status, statusText: response.statusText, headers: response.headers })
48
- }
49
-
50
28
  export interface ServerOptions {
51
29
  mode: 'dev' | 'serve'
52
- host?: string
30
+ listenAddress?: string
53
31
  port?: number
54
32
  document(dir: string): Promise<ServedDocument | undefined>
55
33
  serverHost?: ServerHost
56
34
  settings?: Record<string, Record<string, unknown>>
57
35
  approvals?: Partial<Record<ApprovalKind, ApprovalPolicy>>
58
- forward?(): string | undefined
36
+ /** The origin to forward a path of the site to (in `serve`): the dev server that serves it. */
37
+ forward?(path: string): string | undefined | Promise<string | undefined>
59
38
  operatorDir?: string
39
+ /**
40
+ * The address browsers use when a reverse proxy fronts the server: its host is trusted as this machine's, and pages
41
+ * are advertised under it. A path (`https://host/user/a/preview/`) is the base below which the proxy forwards the
42
+ * unchanged path; the server's own routes are found below it, and still at the root for calls on its own address.
43
+ */
44
+ publicUrl?: string
60
45
  stateFile?: string
61
46
  mcp?(request: Request, access: McpCaller): Promise<Response>
62
47
  env?: NodeJS.ProcessEnv
63
48
  approvalTimeoutMs?: number
64
49
  log?: Logger
50
+ diagnostics?: DiagnosticStore
65
51
  }
66
52
 
67
53
  export interface PresmiumServer {
68
54
  url: string
55
+ /** The address browsers use: `publicUrl` when given, else `url`. */
56
+ publicUrl: string
69
57
  port: number
70
- token: string
58
+ mcpToken: string
71
59
  services: [ServiceToken<unknown>, unknown][]
72
- control: Control
60
+ control: ControlChannel
61
+ diagnostics: DiagnosticStore
73
62
  serverHost?: ServerHost
74
- unmount(dir: string): Promise<void>
63
+ unmount(dir: string, options?: { preserveFocus?: boolean }): Promise<void>
75
64
  close(): Promise<void>
76
65
  }
77
66
 
78
- interface Mounted {
79
- doc: ServedDocument
80
- contributions: Map<string, ServerContribution>
81
- }
82
-
83
- function pluginPath(pathname: string): { plugin: string, path: string, raw: string, plain: boolean } | undefined {
67
+ function parsePluginPath(pathname: string): { plugin: string, path: string, raw: string, plain: boolean } | undefined {
84
68
  const match = /^\/__presmium\/([^/]+)\/?(.*)$/.exec(pathname)
85
69
  if (!match) return undefined
86
70
  const raw = match[2]!
@@ -116,7 +100,7 @@ function findProxy(proxies: ProxySpec[] | undefined, path: string): { spec: Prox
116
100
  return undefined
117
101
  }
118
102
 
119
- function documentOf(headers: IncomingMessage['headers'], url: URL): string {
103
+ function requestedDocument(headers: IncomingMessage['headers'], url: URL): string {
120
104
  const value = header(headers, 'presmium-document') ?? url.searchParams.get('document') ?? ''
121
105
  try {
122
106
  return decodeURIComponent(value).replace(/^\/+|\/+$/g, '')
@@ -125,105 +109,49 @@ function documentOf(headers: IncomingMessage['headers'], url: URL): string {
125
109
  }
126
110
  }
127
111
 
128
- function remoteHost(mode: ServerOptions['mode'], host: string, port: number | undefined): string | undefined {
129
- if (mode === 'serve' || isLoopback(host)) return undefined
112
+ function remoteListenRefusal(mode: ServerOptions['mode'], listenAddress: string, port: number | undefined): string | undefined {
113
+ if (mode === 'serve' || isLoopback(listenAddress)) return undefined
130
114
  const at = port || 'its port'
131
- return `presmium ${mode} listens on this machine only, not on ${host}: it serves the one user who runs it. To use it from another machine, forward its port over SSH (ssh -L ${at}:127.0.0.1:${at} <this machine>), or use presmium serve --host`
115
+ return `presmium ${mode} listens on this machine only, not on ${listenAddress}: it serves the one user who runs it. To use it from another machine, forward its port over SSH (ssh -L ${at}:127.0.0.1:${at} <this machine>), or use presmium serve --host`
132
116
  }
133
117
 
134
118
  export async function startServer(options: ServerOptions): Promise<PresmiumServer> {
135
119
  const env = options.env ?? process.env
136
120
  const log = options.log ?? pluginLogger('presmium server')
137
- const listenHost = options.host ?? '127.0.0.1'
138
- const refused = remoteHost(options.mode, listenHost, options.port)
121
+ const listenAddress = options.listenAddress ?? '127.0.0.1'
122
+ const refused = remoteListenRefusal(options.mode, listenAddress, options.port)
139
123
  if (refused) throw new Error(refused)
140
- const local = machineHosts(listenHost)
124
+ const basePath = options.publicUrl ? `${new URL(options.publicUrl).pathname.replace(/\/+$/, '')}/` : '/'
125
+ const inner = (path: string) => basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length - 1) : path
126
+ const machine = machineHosts(listenAddress)
127
+ const publicHost = options.publicUrl ? normalizeHostName(new URL(options.publicUrl).host) : undefined
128
+ const local: MachineHosts = publicHost ? host => host === publicHost || machine(host) : machine
141
129
 
142
- const control = new Control({
130
+ const control = new ControlChannel({
143
131
  policy: options.approvals ?? {},
144
132
  timeoutMs: options.approvalTimeoutMs,
145
133
  })
146
- const token = randomBytes(24).toString('base64url')
147
- const mcpTokens = new Map<string, McpCaller>()
148
- const mcpStreams = new Map<string, Set<AbortController>>()
149
134
  let url = ''
150
- const mcpEndpoint: McpEndpoint = {
151
- issue(member, mode, issued = {}) {
152
- const value = randomBytes(24).toString('base64url')
153
- mcpTokens.set(value, { member, mode, ...issued.confirmed ? { confirmed: true } : {}, ...issued.document !== undefined ? { document: issued.document } : {} })
154
- return {
155
- url: `${url}/mcp`,
156
- token: value,
157
- revoke: () => {
158
- mcpTokens.delete(value)
159
- for (const stream of mcpStreams.get(value) ?? []) stream.abort()
160
- mcpStreams.delete(value)
161
- },
162
- }
163
- },
164
- }
165
- const services: [ServiceToken<unknown>, unknown][] = [[APPROVALS, control.approvals], [MCP_ENDPOINT, mcpEndpoint]]
166
-
167
- const serviceToken = env.PRESMIUM_SERVICE_TOKEN || undefined
168
- const fromHost = (request: Request) => {
169
- const given = bearer(request.headers.get('authorization'))
170
- if (!serviceToken || given === undefined || given.length !== serviceToken.length) return false
171
- return timingSafeEqual(Buffer.from(given), Buffer.from(serviceToken))
172
- }
173
- const sessions = new Map<string, ReturnType<McpEndpoint['issue']>>()
135
+ const mcpTokens = new McpTokens(() => url)
136
+ const services: [ServiceToken<unknown>, unknown][] = [[APPROVALS, control.approvals], [MCP_ENDPOINT, mcpTokens.endpoint]]
137
+ const ownServices = new Map(services.map(([token, implementation]) => [token.id, implementation]))
138
+ const ownService = <T>(token: ServiceToken<T>) => ownServices.get(token.id) as T | undefined
174
139
  const focus = new FocusStore()
175
- const own = new Map(services.map(([token, impl]) => [token.id, impl]))
176
-
177
- const mounted = new Map<string, Promise<Mounted | undefined>>()
178
- const mount = (dir: string) => {
179
- let known = mounted.get(dir)
180
- if (!known) {
181
- known = (async () => {
182
- const doc = await options.document(dir)
183
- if (!doc) return undefined
184
- log.debug(`mounting the server contributions of ${dir || 'the deck'}: ${doc.plugins.filter(p => p.plugin.server).map(p => p.id).join(', ') || 'none'}`)
185
- const contributions = new Map<string, ServerContribution>()
186
- for (const { id, plugin, config } of doc.plugins) {
187
- if (!plugin.server) continue
188
- const operatorDir = options.operatorDir ?? doc.operatorDir
189
- const ctx: ServerContext = {
190
- deckDir: doc.deckDir,
191
- workspaceRoot: doc.workspaceRoot,
192
- config,
193
- settings: (options.settings ?? doc.settings)?.[id] ?? {},
194
- lang: doc.lang,
195
- service: <T>(token: ServiceToken<T>) => (own.get(token.id) as T | undefined) ?? doc.service(token),
196
- secret: name => env[name],
197
- ...operatorDir ? { operatorDir } : {},
198
- ...doc.home ? { home: true } : {},
199
- log: prefixed(log, id),
200
- }
201
- try {
202
- contributions.set(id, await plugin.server.call(plugin, ctx))
203
- } catch (error) {
204
- log.error(`plugin ${id} failed to make its server contribution: ${errorMessage(error)}`)
205
- }
206
- }
207
- return { doc, contributions }
208
- })()
209
- const loading = known
210
- loading.then((found) => {
211
- if (!found && mounted.get(dir) === loading) mounted.delete(dir)
212
- }, () => mounted.delete(dir))
213
- mounted.set(dir, known)
214
- }
215
- return known
216
- }
140
+ const diagnostics = options.diagnostics ?? new DiagnosticStore()
141
+ const mounts = new Mounts({ document: options.document, ownService, settings: options.settings, operatorDir: options.operatorDir, env, diagnostics, log })
217
142
 
218
- const mcp = options.mcp ? undefined : createPresmiumMcp(serverMcpHost({
143
+ const mcpHost = serverMcpHost({
219
144
  serverHost: options.serverHost,
220
145
  control,
221
146
  command: options.mode,
222
- mount: async dir => (await mount(dir))?.doc,
223
- mounted: async () => (await Promise.all([...mounted.values()].map(known => known.catch(() => undefined)))).flatMap(m => (m ? [m.doc] : [])),
224
- service: <T>(token: ServiceToken<T>) => own.get(token.id) as T | undefined,
147
+ mount: async dir => (await mounts.mount(dir))?.doc,
148
+ mounted: () => mounts.documents(),
149
+ service: ownService,
225
150
  focus,
226
- }))
151
+ diagnostics,
152
+ })
153
+ const mcp = options.mcp ? undefined : createPresmiumMcp(mcpHost)
154
+ const serverRoute = createServerRouteHandler({ control, mcpTokens, focus, diagnostics, mcpHost, serverHost: options.serverHost, launcherToken: env.PRESMIUM_SERVICE_TOKEN || undefined })
227
155
 
228
156
  const members = new WeakMap<IncomingMessage, Member | Refusal>()
229
157
  const memberOf = (req: IncomingMessage): Member | Refusal => {
@@ -232,23 +160,24 @@ export async function startServer(options: ServerOptions): Promise<PresmiumServe
232
160
  return found
233
161
  }
234
162
 
235
- const refuse = (refusal: Refusal): Decision => ({ kind: 'answer', response: problem(refusal.status, refusal.message) })
163
+ const refuse = (refusal: Refusal): Decision => ({ kind: 'answer', response: problemResponse(refusal.status, refusal.message) })
236
164
 
237
165
  const decide = async (req: IncomingMessage): Promise<Decision> => {
238
166
  const url = new URL(req.url ?? '/', 'http://presmium')
239
- const path = url.pathname
167
+ const path = inner(url.pathname)
240
168
  const member = memberOf(req)
241
169
  if ('status' in member) return refuse(member)
242
- const target = pluginPath(path)
170
+ const target = parsePluginPath(path)
243
171
  if (target && !target.plain) return refuse({ status: 400, message: 'a path with a . or .. part or an encoded separator' })
244
172
  if (isMcp(path)) return { kind: 'fetch' }
245
173
  if (target) {
246
174
  if (target.plugin === 'presmium.server') return { kind: 'fetch' }
247
- const proxy = await proxyOf(target, documentOf(req.headers, url), member, url.search)
175
+ if (findRoute(options.serverHost?.routes?.[target.plugin], target.path)) return { kind: 'fetch' }
176
+ const proxy = await proxyOf(target, requestedDocument(req.headers, url), member, url.search)
248
177
  return proxy ?? { kind: 'fetch' }
249
178
  }
250
- const forward = options.mode === 'serve' ? options.forward?.() : undefined
251
- if (!forward) return { kind: 'answer', response: problem(404, 'not found') }
179
+ const forward = options.mode === 'serve' ? await options.forward?.(url.pathname) : undefined
180
+ if (!forward) return { kind: 'answer', response: problemResponse(404, 'not found') }
252
181
  const site = new URL(forward)
253
182
  const lan = !isLoopback(header(req.headers, 'host'))
254
183
  const headers = lan ? { host: site.host, ...header(req.headers, 'origin') ? { origin: site.origin } : {} } : undefined
@@ -256,7 +185,7 @@ export async function startServer(options: ServerOptions): Promise<PresmiumServe
256
185
  }
257
186
 
258
187
  const proxyOf = async (target: { plugin: string, path: string, raw: string }, dir: string, member: Member, search: string): Promise<Decision | undefined> => {
259
- const contribution = (await mount(dir))?.contributions.get(target.plugin)
188
+ const contribution = (await mounts.mount(dir))?.contributions.get(target.plugin)
260
189
  const found = findProxy(contribution?.proxies, target.path)
261
190
  if (!found) return undefined
262
191
  const role = found.spec.role ?? 'editor'
@@ -275,118 +204,24 @@ export async function startServer(options: ServerOptions): Promise<PresmiumServe
275
204
 
276
205
  const serveMcp = async (request: Request): Promise<Response> => {
277
206
  const given = bearer(request.headers.get('authorization'))
278
- const access: McpCaller | undefined = given === undefined ? undefined : given === token ? { member: LOCAL_AUTHOR, mode: 'author' as const } : mcpTokens.get(given)
279
- if (!access) return problem(401, 'MCP needs an Authorization: Bearer token')
280
- const limited = await limitBody(request, MAX_BODY)
281
- if (limited instanceof Response) return limited
282
- request = limited
283
- const response = await (options.mcp ? options.mcp(request, access) : mcp!.fetch(request, access))
284
- if (given === token || !response.headers.get('content-type')?.startsWith('text/event-stream')) return response
285
- if (!mcpTokens.has(given!)) return endedBy(response, AbortSignal.abort(), () => {})
286
- const stream = new AbortController()
287
- const open = mcpStreams.get(given!) ?? new Set()
288
- mcpStreams.set(given!, open.add(stream))
289
- return endedBy(response, stream.signal, () => open.delete(stream))
290
- }
291
-
292
- const handle = async (request: Request, req: IncomingMessage): Promise<Response> => {
293
- const url = new URL(request.url)
294
- const path = url.pathname
295
- const member = memberOf(req)
296
- if ('status' in member) return problem(member.status, member.message)
297
- const document = documentOf(req.headers, url)
298
- if (isMcp(path)) return serveMcp(request)
299
- if (path.startsWith(`${SERVER_PATH}/`)) return await serverRoute(request, path.slice(SERVER_PATH.length + 1), member, document, req)
300
- const target = pluginPath(path)
301
- if (!target) return problem(404, 'not found')
302
- return await pluginRoute(request, target, member, document)
303
- }
304
-
305
- const serverRoute = async (request: Request, route: string, member: Member, document: string, req: IncomingMessage): Promise<Response> => {
306
- if (route === 'hello') return json({ member, document })
307
- if (route === 'control') {
308
- if (request.method === 'GET') {
309
- const window = new URL(request.url).searchParams.get('window') || randomUUID()
310
- const stream = control.open(window, member, document)
311
- req.once('close', () => stream.close())
312
- return eventStream(stream, request.signal)
313
- }
314
- if (request.method !== 'POST') return problem(405, 'GET or POST')
315
- const limited = await limitBody(request, MAX_BODY)
316
- if (limited instanceof Response) return limited
317
- const body: unknown = await limited.json().catch(() => undefined)
318
- if (!isRecord(body)) return problem(400, 'a JSON object')
319
- if (body.type === 'answer' && typeof body.id === 'string') {
320
- const status = control.answer(body.id, body.allow === true, member)
321
- return status === 200 ? json({ ok: true }) : problem(status, status === 404 ? 'no such approval' : 'this member may not answer this approval')
322
- }
323
- if (body.type === 'report' && typeof body.window === 'string') {
324
- const position = windowPosition(body.position)
325
- const ok = control.report(body.window, member, { position, role: typeof body.role === 'string' ? body.role : undefined, status: isRecord(body.status) ? body.status : undefined })
326
- return ok ? json({ ok: true }) : problem(404, 'unknown window')
327
- }
328
- return problem(400, 'unknown message')
329
- }
330
- if (route === 'tokens') return await tokensRoute(request)
331
- if (route === 'focus') return await focusRoute(request)
332
- return problem(404, 'not found')
333
- }
334
-
335
- const tokensRoute = async (request: Request): Promise<Response> => {
336
- if (!serviceToken) return problem(403, 'session tokens are issued only to the host that started this service (PRESMIUM_SERVICE_TOKEN)')
337
- if (!fromHost(request)) return problem(401, 'session tokens need Authorization: Bearer <the service token>')
338
- const limited = await limitBody(request, MAX_BODY)
339
- if (limited instanceof Response) return limited
340
- const body: unknown = await limited.json().catch(() => ({}))
341
- const session = isRecord(body) && typeof body.session === 'string' && body.session !== '' ? body.session : undefined
342
- if (request.method === 'DELETE') {
343
- const revoked = session === undefined ? [...sessions.keys()] : sessions.has(session) ? [session] : []
344
- for (const id of revoked) {
345
- sessions.get(id)!.revoke()
346
- sessions.delete(id)
347
- }
348
- return json({ revoked: revoked.length })
349
- }
350
- if (request.method !== 'POST') return problem(405, 'POST or DELETE')
351
- if (!isRecord(body) || typeof body.document !== 'string' || session === undefined) return problem(400, '{ document, session }')
352
- const document = body.document
353
- if (options.serverHost && !(await options.serverHost.documents()).some(doc => doc.dir === document)) return problem(404, `no document ${JSON.stringify(document)}`)
354
- sessions.get(session)?.revoke()
355
- const grant = mcpEndpoint.issue(LOCAL_AUTHOR, 'author', { confirmed: true, document })
356
- sessions.set(session, grant)
357
- return json({ url: grant.url, token: grant.token, document }, 201)
358
- }
359
-
360
- const focusRoute = async (request: Request): Promise<Response> => {
361
- if (request.method === 'GET') {
362
- const params = new URL(request.url).searchParams
363
- const found = focus.get(params.has('document') ? params.get('document')! : undefined)
364
- return json(found ? { document: found.document, focus: found.focus, reported: new Date(found.at).toISOString() } : { focus: null })
365
- }
366
- if (request.method !== 'POST') return problem(405, 'GET or POST')
367
- if (serviceToken && !fromHost(request)) return problem(401, 'a focus report needs Authorization: Bearer <the service token>')
368
- if (!options.serverHost) return problem(409, 'this server compiles no document')
207
+ const caller = mcpTokens.callerOf(given)
208
+ if (!caller) return problemResponse(401, 'MCP needs an Authorization: Bearer token')
369
209
  const limited = await limitBody(request, MAX_BODY)
370
210
  if (limited instanceof Response) return limited
371
- const body = focusBody(await limited.json().catch(() => undefined))
372
- if (typeof body === 'string') return problem(400, body)
373
- if (!(await options.serverHost.documents()).some(doc => doc.dir === body.document)) return problem(404, `no document ${JSON.stringify(body.document)}`)
374
- try {
375
- const { doc } = await options.serverHost.compile(body.document)
376
- const record = focus.report(body.document, doc, body.focus)
377
- return json({ document: record.document, focus: record.focus, reported: new Date(record.at).toISOString() })
378
- } catch (error) {
379
- return problem(400, errorMessage(error))
380
- }
211
+ const response = await (options.mcp ? options.mcp(limited, caller) : mcp!.fetch(limited, caller))
212
+ return mcpTokens.tieToGrant(given!, response)
381
213
  }
382
214
 
383
215
  const pluginRoute = async (request: Request, target: { plugin: string, path: string }, member: Member, document: string): Promise<Response> => {
384
- const known = await mount(document)
385
- if (!known) return problem(404, `no document ${JSON.stringify(document)}`)
386
- const found = findRoute(known.contributions.get(target.plugin)?.routes, target.path)
387
- if (!found) return problem(404, 'not found')
216
+ let found = findRoute(options.serverHost?.routes?.[target.plugin], target.path)
217
+ if (!found) {
218
+ const known = await mounts.mount(document)
219
+ if (!known) return problemResponse(404, `no document ${JSON.stringify(document)}`)
220
+ found = findRoute(known.contributions.get(target.plugin)?.routes, target.path)
221
+ }
222
+ if (!found) return problemResponse(404, 'not found')
388
223
  const role = found.route.role ?? 'editor'
389
- if (!hasRole(member, role)) return problem(403, `this route needs the role ${role}`)
224
+ if (!hasRole(member, role)) return problemResponse(403, `this route needs the role ${role}`)
390
225
  const limited = await limitBody(request, found.route.maxBody ?? MAX_BODY)
391
226
  if (limited instanceof Response) return limited
392
227
  try {
@@ -394,52 +229,67 @@ export async function startServer(options: ServerOptions): Promise<PresmiumServe
394
229
  return response.headers.has('cache-control') ? response : withHeaders(response, { 'Cache-Control': 'no-store' })
395
230
  } catch (error) {
396
231
  log.error(`route ${target.plugin}/${target.path} failed: ${errorMessage(error)}`)
397
- return problem(500, errorMessage(error))
232
+ return problemResponse(500, errorMessage(error))
398
233
  }
399
234
  }
400
235
 
401
- const proxy = createProxy()
402
- const server = nodeServer({ decide, decideUpgrade, fetch: handle, proxy })
403
- const port = await listen(server, options.port ?? (options.mode === 'dev' ? 0 : 4310), listenHost)
404
- url = `http://${urlHost(listenHost)}:${port}`
405
- log.debug(`listening at ${url}`)
406
- if (options.stateFile) {
407
- mkdirSync(dirname(options.stateFile), { recursive: true })
408
- writeFileSync(options.stateFile, `${JSON.stringify({ url, mcp: `${url}/mcp`, token, pid: process.pid }, null, 2)}\n`, { mode: 0o600 })
409
- chmodSync(options.stateFile, 0o600)
236
+ const handle = async (request: Request, req: IncomingMessage): Promise<Response> => {
237
+ const url = new URL(request.url)
238
+ const path = inner(url.pathname)
239
+ const member = memberOf(req)
240
+ if ('status' in member) return problemResponse(member.status, member.message)
241
+ const document = requestedDocument(req.headers, url)
242
+ if (isMcp(path)) return serveMcp(request)
243
+ if (path.startsWith(`${SERVER_PATH}/`)) return await serverRoute(request, path.slice(SERVER_PATH.length + 1), { member, document, req })
244
+ const target = parsePluginPath(path)
245
+ if (!target) return problemResponse(404, 'not found')
246
+ return await pluginRoute(request, target, member, document)
410
247
  }
411
248
 
412
- const takeDown = async (known: Promise<Mounted | undefined>) => {
413
- for (const [id, contribution] of (await known.catch(() => undefined))?.contributions ?? []) {
414
- await Promise.resolve(contribution.close?.()).catch((error: unknown) => log.error(`the contribution of ${id} failed to close: ${errorMessage(error)}`))
249
+ const proxy = createProxy()
250
+ const http = nodeServer({ decide, decideUpgrade, fetch: handle, proxy })
251
+ let closing: Promise<void> | undefined
252
+ const close = (): Promise<void> => closing ??= Promise.resolve().then(async () => {
253
+ mounts.stop()
254
+ mcpTokens.revokeAgentSessions()
255
+ control.close()
256
+ await mcp?.close().catch((error: unknown) => log.error(`MCP failed to close: ${errorMessage(error)}`))
257
+ try {
258
+ await mounts.close()
259
+ } finally {
260
+ try { proxy.close() }
261
+ finally { await http.close() }
262
+ }
263
+ })
264
+ let port: number
265
+ try {
266
+ port = await listen(http.server, options.port ?? (options.mode === 'dev' ? 0 : 4310), listenAddress)
267
+ url = `http://${urlHost(listenAddress)}:${port}`
268
+ log.debug(`listening at ${url}`)
269
+ if (options.stateFile) {
270
+ mkdirSync(dirname(options.stateFile), { recursive: true })
271
+ writeFileSync(options.stateFile, `${JSON.stringify({ url, mcp: `${url}/mcp`, token: mcpTokens.processMcpToken, pid: process.pid }, null, 2)}\n`, { mode: 0o600 })
272
+ chmodSync(options.stateFile, 0o600)
415
273
  }
274
+ } catch (error) {
275
+ await close().catch((cleanup: unknown) => log.error(`the server failed to close after startup failed: ${errorMessage(cleanup)}`))
276
+ throw error
416
277
  }
417
278
 
418
279
  return {
419
280
  url,
281
+ publicUrl: options.publicUrl?.replace(/\/+$/, '') ?? url,
420
282
  port,
421
- token,
283
+ mcpToken: mcpTokens.processMcpToken,
422
284
  services,
423
285
  control,
424
286
  serverHost: options.serverHost,
425
- async unmount(dir) {
426
- focus.forget(dir)
427
- const known = mounted.get(dir)
428
- mounted.delete(dir)
429
- if (known) {
430
- log.debug(`unmounting the server contributions of ${dir || 'the deck'}`)
431
- await takeDown(known)
432
- }
433
- },
434
- async close() {
435
- for (const grant of sessions.values()) grant.revoke()
436
- sessions.clear()
437
- control.close()
438
- await mcp?.close().catch((error: unknown) => log.error(`MCP failed to close: ${errorMessage(error)}`))
439
- for (const known of mounted.values()) await takeDown(known)
440
- proxy.close()
441
- await closeServer(server)
287
+ diagnostics,
288
+ async unmount(dir, unmountOptions = {}) {
289
+ if (!unmountOptions.preserveFocus) focus.forget(dir)
290
+ await mounts.unmount(dir)
442
291
  },
292
+ close,
443
293
  }
444
294
  }
445
295
 
@@ -455,15 +305,3 @@ function withHeaders(response: Response, headers: Record<string, string>): Respo
455
305
  }
456
306
  return response
457
307
  }
458
-
459
- function windowPosition(value: unknown): WindowPosition | undefined {
460
- if (!isRecord(value) || typeof value.slide !== 'number' || typeof value.step !== 'number') return undefined
461
- const { trail } = value
462
- return {
463
- slide: value.slide,
464
- step: value.step,
465
- ...typeof value.view === 'string' ? { view: value.view } : {},
466
- ...typeof value.section === 'string' ? { section: value.section } : {},
467
- ...Array.isArray(trail) && trail.length <= 10_000 && trail.every(key => typeof key === 'string') ? { trail: trail as string[] } : {},
468
- }
469
- }
package/src/host.ts DELETED
@@ -1,52 +0,0 @@
1
- import type { Compilation } from '@lincyaw/compiler'
2
- import { pluginId } from '@lincyaw/compiler'
3
- import type { Finding, IRDocument, PresmiumNodePlugin, RunResult, ServiceToken } from '@lincyaw/kernel'
4
-
5
- export interface ServedPlugin {
6
- id: string
7
- plugin: PresmiumNodePlugin
8
- config: Record<string, unknown>
9
- }
10
-
11
- export interface ServedDocument {
12
- deckDir: string
13
- workspaceRoot: string
14
- lang?: string
15
- plugins: ServedPlugin[]
16
- service<T>(token: ServiceToken<T>): T | undefined
17
- settings?: Record<string, Record<string, unknown>>
18
- operatorDir?: string
19
- home?: boolean
20
- }
21
-
22
- export function servedDocument(compilation: Compilation): ServedDocument {
23
- const { presmium } = compilation.doc.config
24
- return {
25
- deckDir: compilation.deckDir,
26
- workspaceRoot: compilation.workspaceRoot,
27
- lang: compilation.doc.meta.lang,
28
- plugins: compilation.plugins.flatMap((loaded) => {
29
- if (!loaded.plugin) return []
30
- const config = presmium[pluginId(loaded)]
31
- return [{ id: pluginId(loaded), plugin: loaded.plugin, config: config !== null && typeof config === 'object' && !Array.isArray(config) ? config as Record<string, unknown> : {} }]
32
- }),
33
- service: compilation.service,
34
- settings: compilation.settings,
35
- ...compilation.operatorDir ? { operatorDir: compilation.operatorDir } : {},
36
- }
37
- }
38
-
39
- export interface HostDocument {
40
- dir: string
41
- title: string
42
- lang?: string
43
- tags?: string[]
44
- }
45
-
46
- export interface ServerHost {
47
- documents(): Promise<HostDocument[]>
48
- compile(document: string): Promise<{ doc: IRDocument, diagnostics: Finding[] }>
49
- check(document: string): Promise<Finding[]>
50
- build(document: string, options?: { to?: string[] }): Promise<{ outputs: { renderer: string, outDir: string }[], findings: Finding[] }>
51
- run(document: string, target: { section: string, block: string }, signal?: AbortSignal): Promise<RunResult>
52
- }
package/src/mcp.ts DELETED
@@ -1,64 +0,0 @@
1
- import type { AgentTool, Member, ServiceToken } from '@lincyaw/kernel'
2
- import type { McpHost, McpPluginTools, McpPosition } from '@lincyaw/mcp'
3
- import type { Control } from './control.ts'
4
- import type { FocusStore } from './focus.ts'
5
- import type { ServedDocument, ServerHost } from './host.ts'
6
-
7
- export interface McpHostOptions {
8
- serverHost?: ServerHost
9
- control: Control
10
- command: 'dev' | 'serve'
11
- mount(document: string): Promise<ServedDocument | undefined>
12
- mounted(): Promise<ServedDocument[]>
13
- service<T>(token: ServiceToken<T>): T | undefined
14
- focus: FocusStore
15
- }
16
-
17
- function toolsOf(documents: ServedDocument[], own: McpHostOptions['service']): McpPluginTools {
18
- const tools = new Map<string, AgentTool>()
19
- for (const doc of documents) {
20
- for (const { plugin } of doc.plugins) for (const tool of plugin.tools ?? []) if (!tools.has(tool.name)) tools.set(tool.name, tool)
21
- }
22
- const doc = documents[0]
23
- return { tools: [...tools.values()], service: <T>(token: ServiceToken<T>) => own(token) ?? doc?.service(token) }
24
- }
25
-
26
- export function serverMcpHost(options: McpHostOptions): McpHost {
27
- const { control } = options
28
- const host = (): ServerHost => {
29
- if (!options.serverHost) throw new Error('this server has no host operations')
30
- return options.serverHost
31
- }
32
- const position = (entry: ReturnType<Control['positions']>[number]): McpPosition => {
33
- const { report } = entry
34
- const { slide, step, view, section, trail } = report.position!
35
- return { document: entry.document, member: entry.member, slide, step, view, ...section === undefined ? {} : { section }, ...trail ? { trail } : {}, role: report.role, at: report.at }
36
- }
37
- return {
38
- process: { command: options.command },
39
- documents: () => host().documents(),
40
- compile: document => host().compile(document),
41
- check: document => host().check(document),
42
- build: (document, build) => host().build(document, build),
43
- run: (document, target, signal) => host().run(document, target, signal),
44
- approve: (request, signal) => control.approvals.request(request, signal),
45
- offers: kind => control.policy(kind) !== 'off',
46
- approval: kind => control.policy(kind),
47
- async tools(document) {
48
- if (document === undefined) return toolsOf(await options.mounted(), options.service)
49
- const doc = await options.mount(document)
50
- return toolsOf(doc ? [doc] : [], options.service)
51
- },
52
- positions(document: string | undefined, member: Member) {
53
- const all = control.positions(undefined, document, member)
54
- const presenting = (w: typeof all[number]) => w.member === member.id && (w.report.role === 'presenter' || w.report.role === 'audience')
55
- return [...all.filter(presenting), ...all.filter(w => !presenting(w))].map(position)
56
- },
57
- watchPositions: listener => control.watch(listener),
58
- focus: document => options.focus.get(document),
59
- watchFocus: listener => options.focus.watch(listener),
60
- classroom: document => control.status(document),
61
- navigate: (document, to, member) => control.go(to, member, document),
62
- session: (document, action, name, member) => control.session(action, name, document, member),
63
- }
64
- }