@lincyaw/server 1.0.0-rc.0 → 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/README.md +2 -2
- package/package.json +4 -4
- package/src/auth.ts +11 -16
- package/src/control.ts +42 -53
- package/src/diagnostics.ts +139 -0
- package/src/focus.ts +6 -6
- package/src/http.ts +39 -20
- package/src/index.ts +7 -7
- package/src/mcp-access.ts +76 -0
- package/src/mcp-host.ts +96 -0
- package/src/mounts.ts +118 -0
- package/src/server-host.ts +75 -0
- package/src/server-routes.ts +203 -0
- package/src/server.ts +123 -285
- package/src/host.ts +0 -52
- package/src/mcp.ts +0 -64
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,
|
|
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,
|
|
9
|
+
import { authenticate, machineHosts } from './auth.ts'
|
|
10
|
+
import type { MachineHosts } from './auth.ts'
|
|
21
11
|
import type { Refusal } from './auth.ts'
|
|
22
|
-
import {
|
|
23
|
-
import type { ApprovalPolicy
|
|
24
|
-
import {
|
|
25
|
-
import
|
|
26
|
-
import { bearer,
|
|
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 {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
58
|
+
mcpToken: string
|
|
71
59
|
services: [ServiceToken<unknown>, unknown][]
|
|
72
|
-
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
|
-
|
|
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
|
|
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
|
|
129
|
-
if (mode === 'serve' || isLoopback(
|
|
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 ${
|
|
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
|
|
138
|
-
const refused =
|
|
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
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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:
|
|
224
|
-
service:
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
279
|
-
if (!
|
|
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
|
|
372
|
-
|
|
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
|
-
|
|
385
|
-
if (!
|
|
386
|
-
|
|
387
|
-
|
|
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
|
|
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
|
|
232
|
+
return problemResponse(500, errorMessage(error))
|
|
398
233
|
}
|
|
399
234
|
}
|
|
400
235
|
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
283
|
+
mcpToken: mcpTokens.processMcpToken,
|
|
422
284
|
services,
|
|
423
285
|
control,
|
|
424
286
|
serverHost: options.serverHost,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
}
|