@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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The server of `presmium dev` and `presmium serve`: plugin routes and proxies under `/__presmium/`, approvals, the control channel and the MCP endpoint. It listens on this machine only unless `presmium serve --host` is given.
|
|
4
4
|
|
|
5
|
-
Part of [Presmium](https://github.com/Lincyaw/presmium), which
|
|
5
|
+
Part of [Presmium](https://github.com/Lincyaw/presmium), which compiles canonical lecture.json teaching documents into web pages, slides, Jupyter notebooks and narrated playback.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ It comes with `@lincyaw/cli`; you do not install it yourself. Its programming in
|
|
|
10
10
|
|
|
11
11
|
## Documentation
|
|
12
12
|
|
|
13
|
-
- [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/lecture.
|
|
13
|
+
- [Plugin guide](https://github.com/Lincyaw/presmium/blob/main/site/plugins/locales/en/lecture.json): server routes
|
|
14
14
|
- [Security and the trust model](https://github.com/Lincyaw/presmium/blob/main/SECURITY.md)
|
|
15
15
|
|
|
16
16
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/server",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
4
|
"description": "The server of presmium dev and presmium serve: plugin routes and proxies, approvals, the control channel and MCP",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"@hono/node-server": "2.1.3",
|
|
32
32
|
"hono": "4.13.13",
|
|
33
33
|
"http-proxy-3": "2.0.1",
|
|
34
|
-
"@lincyaw/compiler": "1.0.0-rc.
|
|
35
|
-
"@lincyaw/
|
|
36
|
-
"@lincyaw/
|
|
34
|
+
"@lincyaw/compiler": "1.0.0-rc.1",
|
|
35
|
+
"@lincyaw/mcp": "1.0.0-rc.1",
|
|
36
|
+
"@lincyaw/kernel": "1.0.0-rc.1"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@modelcontextprotocol/client": "2.3.1"
|
package/src/auth.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http'
|
|
2
|
-
import { hostname
|
|
2
|
+
import { hostname, networkInterfaces } from 'node:os'
|
|
3
|
+
import { LOCAL_AUTHOR } from '@lincyaw/kernel'
|
|
3
4
|
import type { Member } from '@lincyaw/kernel'
|
|
4
|
-
import { header,
|
|
5
|
-
|
|
6
|
-
export const LOCAL_AUTHOR: Member = { id: 'local', name: 'local author', roles: ['owner'] }
|
|
5
|
+
import { header, isLoopback, normalizeHostName, originHost } from './http.ts'
|
|
7
6
|
|
|
8
7
|
export interface Refusal {
|
|
9
8
|
status: number
|
|
@@ -12,21 +11,17 @@ export interface Refusal {
|
|
|
12
11
|
|
|
13
12
|
export type MachineHosts = (host: string) => boolean
|
|
14
13
|
|
|
15
|
-
export function
|
|
16
|
-
return ['0.0.0.0', '[::]'].includes(
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function networkAddresses(): string[] {
|
|
20
|
-
return Object.values(networkInterfaces()).flatMap(entries => entries ?? []).filter(entry => entry.family === 'IPv4' && !entry.internal).map(entry => entry.address)
|
|
14
|
+
export function isAnyAddress(address: string): boolean {
|
|
15
|
+
return ['0.0.0.0', '[::]'].includes(normalizeHostName(address))
|
|
21
16
|
}
|
|
22
17
|
|
|
23
|
-
export function machineHosts(
|
|
24
|
-
const own =
|
|
18
|
+
export function machineHosts(listenAddress: string): MachineHosts {
|
|
19
|
+
const own = normalizeHostName(listenAddress)
|
|
25
20
|
if (isLoopback(own)) return isLoopback
|
|
26
|
-
const addresses =
|
|
27
|
-
? Object.values(networkInterfaces()).flatMap(entries => entries ?? []).map(entry =>
|
|
21
|
+
const addresses = isAnyAddress(own)
|
|
22
|
+
? Object.values(networkInterfaces()).flatMap(entries => entries ?? []).map(entry => normalizeHostName(entry.address.replace(/%.*$/, '')))
|
|
28
23
|
: [own]
|
|
29
|
-
const name =
|
|
24
|
+
const name = hostname().toLowerCase()
|
|
30
25
|
const names = new Set([...addresses, name, `${name}.local`])
|
|
31
26
|
return host => isLoopback(host) || names.has(host)
|
|
32
27
|
}
|
|
@@ -34,7 +29,7 @@ export function machineHosts(listen: string): MachineHosts {
|
|
|
34
29
|
export function authenticate(req: IncomingMessage, local: MachineHosts = isLoopback): Member | Refusal {
|
|
35
30
|
const host = header(req.headers, 'host')
|
|
36
31
|
const origin = originHost(header(req.headers, 'origin'))
|
|
37
|
-
if (!local(
|
|
32
|
+
if (!local(normalizeHostName(host))) return { status: 403, message: `Host ${host ?? '(none)'} is not this machine` }
|
|
38
33
|
if (origin !== undefined && !local(origin)) return { status: 403, message: `Origin ${header(req.headers, 'origin')} is not this machine` }
|
|
39
34
|
return LOCAL_AUTHOR
|
|
40
35
|
}
|
package/src/control.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import
|
|
2
|
+
import { hasRole, isPresentingRole, pushStream } from '@lincyaw/kernel'
|
|
3
|
+
import type { ApprovalKind, ApprovalRequest, Approvals, Member, MemberRole, PushStream } from '@lincyaw/kernel'
|
|
3
4
|
|
|
4
5
|
export type ApprovalPolicy = 'off' | 'ask' | 'allow'
|
|
5
6
|
|
|
@@ -20,10 +21,17 @@ export interface WindowReport {
|
|
|
20
21
|
at: number
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export interface WindowFilter {
|
|
25
|
+
member?: Member
|
|
26
|
+
document?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
23
29
|
interface Window {
|
|
24
30
|
id: string
|
|
25
31
|
member: Member
|
|
26
32
|
document: string
|
|
33
|
+
/** Hears approvals only: not a viewer, so it is never navigated, counted or asked to report. */
|
|
34
|
+
observer: boolean
|
|
27
35
|
report?: WindowReport
|
|
28
36
|
send(event: string, data: unknown): void
|
|
29
37
|
close(): void
|
|
@@ -36,13 +44,8 @@ interface Pending {
|
|
|
36
44
|
settle(allow: boolean): void
|
|
37
45
|
}
|
|
38
46
|
|
|
39
|
-
function
|
|
40
|
-
return window.report?.role
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function hasRole(member: Member, role: MemberRole): boolean {
|
|
44
|
-
const rank: Record<MemberRole, number> = { reader: 0, editor: 1, owner: 2 }
|
|
45
|
-
return member.roles.some(own => rank[own] >= rank[role])
|
|
47
|
+
function isPresenting(window: Window): boolean {
|
|
48
|
+
return isPresentingRole(window.report?.role)
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export interface ControlOptions {
|
|
@@ -50,7 +53,7 @@ export interface ControlOptions {
|
|
|
50
53
|
timeoutMs?: number
|
|
51
54
|
}
|
|
52
55
|
|
|
53
|
-
export class
|
|
56
|
+
export class ControlChannel {
|
|
54
57
|
readonly #windows = new Map<string, Window>()
|
|
55
58
|
readonly #pending = new Map<string, Pending>()
|
|
56
59
|
readonly #watchers = new Set<() => void>()
|
|
@@ -64,52 +67,26 @@ export class Control {
|
|
|
64
67
|
request: (request, signal) => this.#request(request, signal),
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
open(id: string, member: Member, document: string):
|
|
70
|
+
open(id: string, member: Member, document: string, options: { observer?: boolean } = {}): PushStream<{ event: string, data: unknown }> {
|
|
68
71
|
this.#windows.get(id)?.close()
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
member,
|
|
75
|
-
document,
|
|
76
|
-
send(event, data) {
|
|
77
|
-
if (closed) return
|
|
78
|
-
queue.push({ event, data })
|
|
79
|
-
wake?.()
|
|
80
|
-
},
|
|
81
|
-
close: () => {
|
|
82
|
-
closed = true
|
|
72
|
+
let window!: Window
|
|
73
|
+
const stream = pushStream<{ event: string, data: unknown }>((push) => {
|
|
74
|
+
window = { id, member, document, observer: options.observer === true, send: (event, data) => push({ event, data }), close: () => stream.end() }
|
|
75
|
+
this.#windows.set(id, window)
|
|
76
|
+
return () => {
|
|
83
77
|
if (this.#windows.get(id) === window) this.#windows.delete(id)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
this.#windows.set(id, window)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
88
80
|
window.send('ready', { window: id })
|
|
89
81
|
for (const pending of this.#pending.values()) {
|
|
90
82
|
if (this.#eligible(member, pending.request)) window.send('approval', { id: pending.id, request: pending.request })
|
|
91
83
|
}
|
|
92
|
-
const stream = {
|
|
93
|
-
close: window.close,
|
|
94
|
-
async* [Symbol.asyncIterator]() {
|
|
95
|
-
try {
|
|
96
|
-
while (!closed) {
|
|
97
|
-
while (queue.length > 0) yield queue.shift()!
|
|
98
|
-
if (closed) break
|
|
99
|
-
await new Promise<void>(resolve => wake = resolve)
|
|
100
|
-
wake = undefined
|
|
101
|
-
}
|
|
102
|
-
} finally {
|
|
103
|
-
window.close()
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
}
|
|
107
84
|
return stream
|
|
108
85
|
}
|
|
109
86
|
|
|
110
87
|
report(id: string, member: Member, report: Omit<WindowReport, 'at'>): boolean {
|
|
111
88
|
const window = this.#windows.get(id)
|
|
112
|
-
if (!window || window.member.id !== member.id) return false
|
|
89
|
+
if (!window || window.observer || window.member.id !== member.id) return false
|
|
113
90
|
const moved = JSON.stringify(report.position) !== JSON.stringify(window.report?.position)
|
|
114
91
|
window.report = { ...report, at: Date.now() }
|
|
115
92
|
if (moved) for (const listener of [...this.#watchers]) listener()
|
|
@@ -133,24 +110,24 @@ export class Control {
|
|
|
133
110
|
return 200
|
|
134
111
|
}
|
|
135
112
|
|
|
136
|
-
positions(
|
|
137
|
-
const all =
|
|
138
|
-
const trusted = (w: Window) => hasRole(w.member, 'editor') || w.member.id === viewer?.id
|
|
139
|
-
const rank = (w: Window) => (!trusted(w) ? 2 :
|
|
113
|
+
positions(filter: WindowFilter & { viewer?: Member } = {}): { window: string, member: string, roles: MemberRole[], document: string, report: WindowReport }[] {
|
|
114
|
+
const all = this.#windowsOf(filter).filter(w => w.report?.position)
|
|
115
|
+
const trusted = (w: Window) => hasRole(w.member, 'editor') || w.member.id === filter.viewer?.id
|
|
116
|
+
const rank = (w: Window) => (!trusted(w) ? 2 : isPresenting(w) ? 0 : 1)
|
|
140
117
|
return all.sort((a, b) => rank(a) - rank(b) || b.report!.at - a.report!.at)
|
|
141
118
|
.map(w => ({ window: w.id, member: w.member.id, roles: w.member.roles, document: w.document, report: w.report! }))
|
|
142
119
|
}
|
|
143
120
|
|
|
144
|
-
go(position: WindowPosition,
|
|
145
|
-
const windows =
|
|
146
|
-
const presenting = windows.filter(
|
|
121
|
+
go(position: WindowPosition, filter: WindowFilter = {}): number {
|
|
122
|
+
const windows = this.#windowsOf(filter)
|
|
123
|
+
const presenting = windows.filter(isPresenting)
|
|
147
124
|
const targets = presenting.length > 0 ? presenting : windows
|
|
148
125
|
for (const window of targets) window.send('go', position)
|
|
149
126
|
return targets.length
|
|
150
127
|
}
|
|
151
128
|
|
|
152
|
-
|
|
153
|
-
const windows =
|
|
129
|
+
setClassroom(action: 'start' | 'end', name: string | undefined, filter: WindowFilter = {}): number {
|
|
130
|
+
const windows = this.#windowsOf(filter).filter(isPresenting)
|
|
154
131
|
for (const window of windows) window.send('session', { action, name })
|
|
155
132
|
return windows.length
|
|
156
133
|
}
|
|
@@ -165,6 +142,14 @@ export class Control {
|
|
|
165
142
|
return out
|
|
166
143
|
}
|
|
167
144
|
|
|
145
|
+
runtime(document: string): { window: string, report: WindowReport }[] {
|
|
146
|
+
return [...this.#windows.values()]
|
|
147
|
+
.filter(window => window.document === document && window.report?.status && hasRole(window.member, 'editor'))
|
|
148
|
+
.sort((a, b) => b.report!.at - a.report!.at)
|
|
149
|
+
.slice(0, 8)
|
|
150
|
+
.map(window => ({ window: window.id, report: window.report! }))
|
|
151
|
+
}
|
|
152
|
+
|
|
168
153
|
close(): void {
|
|
169
154
|
for (const pending of [...this.#pending.values()]) pending.settle(false)
|
|
170
155
|
for (const window of [...this.#windows.values()]) window.close()
|
|
@@ -198,6 +183,10 @@ export class Control {
|
|
|
198
183
|
})
|
|
199
184
|
}
|
|
200
185
|
|
|
186
|
+
#windowsOf(filter: WindowFilter): Window[] {
|
|
187
|
+
return [...this.#windows.values()].filter(w => !w.observer && (!filter.member || w.member.id === filter.member.id) && (filter.document === undefined || w.document === filter.document))
|
|
188
|
+
}
|
|
189
|
+
|
|
201
190
|
#eligible(member: Member, request: ApprovalRequest): boolean {
|
|
202
191
|
if ((request.scope ?? 'workspace') === 'workspace') return hasRole(member, 'owner')
|
|
203
192
|
return request.member ? member.id === request.member : hasRole(member, 'editor')
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { isAbsolute, relative } from 'node:path'
|
|
2
|
+
import { diagnosticOf, pushStream } from '@lincyaw/kernel'
|
|
3
|
+
import type { AuthoringDiagnostics, Diagnostic, DiagnosticOrigin, DiagnosticSnapshot, Finding, IRDocument, PreviewDiagnostics } from '@lincyaw/kernel'
|
|
4
|
+
|
|
5
|
+
export interface DiagnosticSource {
|
|
6
|
+
document: string
|
|
7
|
+
origin: DiagnosticOrigin
|
|
8
|
+
renderer?: 'web' | 'slides'
|
|
9
|
+
instance?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface Contribution {
|
|
13
|
+
source: DiagnosticSource
|
|
14
|
+
diagnostics: Diagnostic[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function workspaceFindings(findings: Finding[], root: string): Finding[] {
|
|
18
|
+
return findings.map(finding => ({
|
|
19
|
+
...finding,
|
|
20
|
+
...finding.source ? { source: { ...finding.source, file: (isAbsolute(finding.source.file) ? relative(root, finding.source.file) : finding.source.file).replace(/\\/g, '/') } } : {},
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Each producer replaces its current contribution. No diagnostic event history is kept. */
|
|
25
|
+
export class DiagnosticStore {
|
|
26
|
+
readonly #contributions = new Map<string, Contribution>()
|
|
27
|
+
readonly #irRevisions = new Map<string, string | undefined>()
|
|
28
|
+
readonly #watchers = new Set<(document: string) => void>()
|
|
29
|
+
#version = 0
|
|
30
|
+
|
|
31
|
+
isObserved(document: string): boolean {
|
|
32
|
+
return this.#irRevisions.has(document)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
irRevision(document: string): string | undefined {
|
|
36
|
+
return this.#irRevisions.get(document)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
snapshot(document?: string): DiagnosticSnapshot {
|
|
40
|
+
const diagnostics = new Map<string, Diagnostic>()
|
|
41
|
+
for (const contribution of this.#contributions.values()) {
|
|
42
|
+
if (document !== undefined && contribution.source.document !== document) continue
|
|
43
|
+
for (const diagnostic of contribution.diagnostics) diagnostics.set(diagnostic.id, diagnostic)
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
version: this.#version,
|
|
47
|
+
diagnostics: [...diagnostics.values()].map(diagnostic => ({ ...diagnostic, ...diagnostic.source ? { source: { ...diagnostic.source } } : {}, ...diagnostic.authoringSource ? { authoringSource: { ...diagnostic.authoringSource } } : {} })),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
replace(source: DiagnosticSource, findings: Finding[], options: { irRevision?: string, root?: string } = {}): void {
|
|
52
|
+
const key = JSON.stringify([source.document, source.origin, source.renderer, source.instance])
|
|
53
|
+
const normalized = options.root ? workspaceFindings(findings, options.root) : findings
|
|
54
|
+
const diagnostics = normalized.map(finding => diagnosticOf(source.document, source.origin, finding, source.renderer, options.irRevision))
|
|
55
|
+
const previous = this.#contributions.get(key)
|
|
56
|
+
if (JSON.stringify(previous?.diagnostics ?? []) === JSON.stringify(diagnostics)) return
|
|
57
|
+
if (diagnostics.length) this.#contributions.set(key, { source, diagnostics })
|
|
58
|
+
else this.#contributions.delete(key)
|
|
59
|
+
this.#changed(source.document)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
compiled(document: string, doc: IRDocument, root: string, irRevision?: string): void {
|
|
63
|
+
if (!this.#irRevisions.has(document) || this.#irRevisions.get(document) !== irRevision) {
|
|
64
|
+
this.#remove(document, source => source.origin === 'check' || source.origin === 'runtime')
|
|
65
|
+
}
|
|
66
|
+
this.#irRevisions.set(document, irRevision)
|
|
67
|
+
this.replace({ document, origin: 'compile' }, doc.diagnostics, { root, irRevision })
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
failed(document: string, message: string, source?: Finding['source']): void {
|
|
71
|
+
this.#irRevisions.set(document, undefined)
|
|
72
|
+
this.#remove(document, contribution => contribution.origin === 'check' || contribution.origin === 'runtime')
|
|
73
|
+
this.replace({ document, origin: 'compile' }, [{ checkId: 'presmium.compile', severity: 'error', message, ...source ? { source } : {} }])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
report(report: PreviewDiagnostics): boolean {
|
|
77
|
+
const source: DiagnosticSource = { document: report.document, origin: 'runtime', renderer: report.renderer, instance: report.instance }
|
|
78
|
+
// A closing frame can always remove its own contribution, including after a new compile.
|
|
79
|
+
if (!report.findings.length) {
|
|
80
|
+
this.replace(source, [])
|
|
81
|
+
return true
|
|
82
|
+
}
|
|
83
|
+
if (!this.#irRevisions.has(report.document) || this.#irRevisions.get(report.document) !== report.revision) return false
|
|
84
|
+
this.replace(source, report.findings, { irRevision: report.revision })
|
|
85
|
+
return true
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** This producer describes authoring-window failures, independently of runtime compilation. */
|
|
89
|
+
reportAuthoring(report: AuthoringDiagnostics, member: string): void {
|
|
90
|
+
this.replace({ document: report.document, origin: 'authoring', instance: JSON.stringify([member, report.instance]) }, report.findings)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
forget(document: string): void {
|
|
94
|
+
this.#irRevisions.delete(document)
|
|
95
|
+
this.#remove(document, () => true)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clear(document: string, origin: DiagnosticOrigin, renderer?: 'web' | 'slides'): void {
|
|
99
|
+
this.#remove(document, source => source.origin === origin && (renderer === undefined || source.renderer === renderer))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
watch(listener: (document: string) => void): () => void {
|
|
103
|
+
this.#watchers.add(listener)
|
|
104
|
+
return () => { this.#watchers.delete(listener) }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
events(document: string | undefined, signal: AbortSignal): AsyncIterable<{ event: string, data: DiagnosticSnapshot }> {
|
|
108
|
+
const store = this
|
|
109
|
+
return {
|
|
110
|
+
async* [Symbol.asyncIterator]() {
|
|
111
|
+
if (signal.aborted) return
|
|
112
|
+
const changes = pushStream<void>(push => store.watch((changed) => {
|
|
113
|
+
if (document === undefined || document === changed) push()
|
|
114
|
+
}), { signal, latestOnly: true })
|
|
115
|
+
try {
|
|
116
|
+
yield { event: 'diagnostics', data: store.snapshot(document) }
|
|
117
|
+
for await (const _change of changes) yield { event: 'diagnostics', data: store.snapshot(document) }
|
|
118
|
+
} finally {
|
|
119
|
+
changes.end()
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#remove(document: string, matches: (source: DiagnosticSource) => boolean): void {
|
|
126
|
+
let changed = false
|
|
127
|
+
for (const [key, contribution] of this.#contributions) {
|
|
128
|
+
if (contribution.source.document !== document || !matches(contribution.source)) continue
|
|
129
|
+
this.#contributions.delete(key)
|
|
130
|
+
changed = true
|
|
131
|
+
}
|
|
132
|
+
if (changed) this.#changed(document)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#changed(document: string): void {
|
|
136
|
+
this.#version++
|
|
137
|
+
for (const watcher of this.#watchers) watcher(document)
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/focus.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveFocus, toFocusReport } from '@lincyaw/mcp'
|
|
2
2
|
import type { McpFocus } from '@lincyaw/mcp'
|
|
3
3
|
import type { FocusReport, IRDocument } from '@lincyaw/kernel'
|
|
4
4
|
|
|
@@ -8,15 +8,15 @@ export class FocusStore {
|
|
|
8
8
|
readonly #latest = new Map<string, McpFocus>()
|
|
9
9
|
readonly #watchers = new Set<() => void>()
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
find(document?: string): McpFocus | undefined {
|
|
12
12
|
if (document !== undefined) return this.#latest.get(document)
|
|
13
13
|
let newest: McpFocus | undefined
|
|
14
14
|
for (const found of this.#latest.values()) if (!newest || found.at >= newest.at) newest = found
|
|
15
15
|
return newest
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
report(document: string, doc: IRDocument, reports: FocusReport[]): McpFocus {
|
|
19
|
-
const record = { document, focus: reports.map(report => resolveFocus(doc, document, report)), at: Date.now() }
|
|
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
20
|
this.#latest.set(document, record)
|
|
21
21
|
for (const watcher of this.#watchers) watcher()
|
|
22
22
|
return record
|
|
@@ -32,14 +32,14 @@ export class FocusStore {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
export function
|
|
35
|
+
export function toFocusRequest(body: unknown): { document: string, focus: FocusReport[] } | string {
|
|
36
36
|
if (!body || typeof body !== 'object') return 'a JSON object { document, focus: [...] }'
|
|
37
37
|
const { document, focus } = body as { document?: unknown, focus?: unknown }
|
|
38
38
|
if (typeof document !== 'string') return 'the document directory (relative to the workspace root) as `document`'
|
|
39
39
|
if (!Array.isArray(focus) || focus.length > MAX_FOCUS) return `focus is a list of at most ${MAX_FOCUS}`
|
|
40
40
|
const reports: FocusReport[] = []
|
|
41
41
|
for (const item of focus) {
|
|
42
|
-
const report =
|
|
42
|
+
const report = toFocusReport(item)
|
|
43
43
|
if (typeof report === 'string') return report
|
|
44
44
|
reports.push(report)
|
|
45
45
|
}
|
package/src/http.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
2
|
+
import { Agent, createServer } from 'node:http'
|
|
3
|
+
import { Agent as HttpsAgent } from 'node:https'
|
|
2
4
|
import type { IncomingHttpHeaders, IncomingMessage, Server, ServerResponse } from 'node:http'
|
|
3
5
|
import type { AddressInfo } from 'node:net'
|
|
4
6
|
import type { Duplex } from 'node:stream'
|
|
5
7
|
import { getRequestListener } from '@hono/node-server'
|
|
6
|
-
import {
|
|
8
|
+
import { jsonResponse } from '@lincyaw/kernel'
|
|
9
|
+
import { bracketIPv6 } from '@lincyaw/kernel/node'
|
|
7
10
|
import { createProxyServer } from 'http-proxy-3'
|
|
8
11
|
|
|
9
12
|
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
10
13
|
|
|
11
|
-
export function
|
|
14
|
+
export function normalizeHostName(host: string | null | undefined): string {
|
|
12
15
|
if (!host) return ''
|
|
13
16
|
const value = host.trim().toLowerCase()
|
|
14
17
|
if (value.startsWith('[')) return value.slice(0, value.indexOf(']') + 1)
|
|
@@ -17,13 +20,13 @@ export function hostname(host: string | null | undefined): string {
|
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
export function isLoopback(host: string | null | undefined): boolean {
|
|
20
|
-
return LOOPBACK.has(
|
|
23
|
+
return LOOPBACK.has(normalizeHostName(host))
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export function originHost(origin: string | null | undefined): string | undefined {
|
|
24
27
|
if (origin === null || origin === undefined) return undefined
|
|
25
28
|
try {
|
|
26
|
-
return
|
|
29
|
+
return normalizeHostName(new URL(origin).host)
|
|
27
30
|
} catch {
|
|
28
31
|
return ''
|
|
29
32
|
}
|
|
@@ -34,10 +37,14 @@ export function bearer(value: string | null | undefined): string | undefined {
|
|
|
34
37
|
return match?.[1]
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
export
|
|
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
|
+
}
|
|
38
45
|
|
|
39
46
|
export async function limitBody(request: Request, limit: number): Promise<Request | Response> {
|
|
40
|
-
const tooLarge = () =>
|
|
47
|
+
const tooLarge = () => jsonResponse({ error: `the request body is larger than this route takes (${limit} bytes)` }, 413)
|
|
41
48
|
const declared = Number(request.headers.get('content-length') ?? Number.NaN)
|
|
42
49
|
if (declared > limit) return tooLarge()
|
|
43
50
|
if (!request.body) return request
|
|
@@ -90,6 +97,9 @@ export interface ProxyTarget {
|
|
|
90
97
|
}
|
|
91
98
|
|
|
92
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)
|
|
93
103
|
const proxy = createProxyServer({ ws: true, xfwd: false })
|
|
94
104
|
proxy.on('error', (error: Error, _req: unknown, res: unknown) => {
|
|
95
105
|
const response = res as ServerResponse | Duplex | undefined
|
|
@@ -103,13 +113,17 @@ export function createProxy() {
|
|
|
103
113
|
return {
|
|
104
114
|
web(req: IncomingMessage, res: ServerResponse, to: ProxyTarget): void {
|
|
105
115
|
req.url = to.path
|
|
106
|
-
proxy.web(req, res, { target: to.target, headers: to.headers })
|
|
116
|
+
proxy.web(req, res, { target: to.target, headers: to.headers, agent: agentFor(to.target) })
|
|
107
117
|
},
|
|
108
118
|
ws(req: IncomingMessage, socket: Duplex, head: Buffer, to: ProxyTarget): void {
|
|
109
119
|
req.url = to.path
|
|
110
|
-
proxy.ws(req, socket, head, { target: to.target, headers: to.headers })
|
|
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()
|
|
111
126
|
},
|
|
112
|
-
close: () => proxy.close(),
|
|
113
127
|
}
|
|
114
128
|
}
|
|
115
129
|
|
|
@@ -125,10 +139,12 @@ export function nodeServer(options: {
|
|
|
125
139
|
decideUpgrade(req: IncomingMessage): Promise<{ to?: ProxyTarget, status?: number }>
|
|
126
140
|
fetch(request: Request, req: IncomingMessage): Promise<Response>
|
|
127
141
|
proxy: Proxy
|
|
128
|
-
}): Server {
|
|
142
|
+
}): { server: Server, close(): Promise<void> } {
|
|
129
143
|
const listener = getRequestListener((request, env) => options.fetch(request, (env as { incoming: IncomingMessage }).incoming))
|
|
144
|
+
const upgrades = new Set<Duplex>()
|
|
130
145
|
const server = createServer((req, res) => {
|
|
131
146
|
void options.decide(req).then((decision) => {
|
|
147
|
+
if (res.destroyed) return
|
|
132
148
|
if (decision.kind === 'fetch') return listener(req, res)
|
|
133
149
|
if (decision.kind === 'proxy') return options.proxy.web(req, res, decision.to)
|
|
134
150
|
return send(res, decision.response)
|
|
@@ -138,12 +154,22 @@ export function nodeServer(options: {
|
|
|
138
154
|
})
|
|
139
155
|
})
|
|
140
156
|
server.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
157
|
+
upgrades.add(socket)
|
|
158
|
+
socket.once('close', () => upgrades.delete(socket))
|
|
141
159
|
void options.decideUpgrade(req).then(({ to, status }) => {
|
|
160
|
+
if (socket.destroyed) return
|
|
142
161
|
if (to) options.proxy.ws(req, socket, head, to)
|
|
143
162
|
else refuseUpgrade(socket, status ?? 404)
|
|
144
163
|
}, () => refuseUpgrade(socket, 500))
|
|
145
164
|
})
|
|
146
|
-
return
|
|
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
|
+
}
|
|
147
173
|
}
|
|
148
174
|
|
|
149
175
|
export function listen(server: Server, port: number, host: string): Promise<number> {
|
|
@@ -156,14 +182,7 @@ export function listen(server: Server, port: number, host: string): Promise<numb
|
|
|
156
182
|
})
|
|
157
183
|
}
|
|
158
184
|
|
|
159
|
-
export function closeServer(server: Server): Promise<void> {
|
|
160
|
-
return new Promise((resolve) => {
|
|
161
|
-
server.close(() => resolve())
|
|
162
|
-
server.closeAllConnections()
|
|
163
|
-
})
|
|
164
|
-
}
|
|
165
|
-
|
|
166
185
|
export function urlHost(host: string): string {
|
|
167
186
|
if (host === '0.0.0.0' || host === '::') return '127.0.0.1'
|
|
168
|
-
return
|
|
187
|
+
return bracketIPv6(host)
|
|
169
188
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export
|
|
3
|
-
export { servedDocument } from './host.ts'
|
|
4
|
-
export type {
|
|
5
|
-
export {
|
|
6
|
-
export {
|
|
7
|
-
export {
|
|
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
8
|
export type { PresmiumServer, ServerOptions } from './server.ts'
|