@lincyaw/server 1.0.0-rc.0 → 1.0.0-rc.10

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 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 turns lectures written in MyST Markdown into web pages, slides, Jupyter notebooks and narrated lectures.
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.en.md): server routes
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.0",
3
+ "version": "1.0.0-rc.10",
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.0",
35
- "@lincyaw/kernel": "1.0.0-rc.0",
36
- "@lincyaw/mcp": "1.0.0-rc.0"
34
+ "@lincyaw/compiler": "1.0.0-rc.10",
35
+ "@lincyaw/kernel": "1.0.0-rc.10",
36
+ "@lincyaw/mcp": "1.0.0-rc.10"
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 as machineName, networkInterfaces } from 'node:os'
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, hostname, isLoopback, originHost } from './http.ts'
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 anyAddress(host: string): boolean {
16
- return ['0.0.0.0', '[::]'].includes(hostname(host))
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(listen: string): MachineHosts {
24
- const own = hostname(listen)
18
+ export function machineHosts(listenAddress: string): MachineHosts {
19
+ const own = normalizeHostName(listenAddress)
25
20
  if (isLoopback(own)) return isLoopback
26
- const addresses = anyAddress(own)
27
- ? Object.values(networkInterfaces()).flatMap(entries => entries ?? []).map(entry => hostname(entry.address.replace(/%.*$/, '')))
21
+ const addresses = isAnyAddress(own)
22
+ ? Object.values(networkInterfaces()).flatMap(entries => entries ?? []).map(entry => normalizeHostName(entry.address.replace(/%.*$/, '')))
28
23
  : [own]
29
- const name = machineName().toLowerCase()
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(hostname(host))) return { status: 403, message: `Host ${host ?? '(none)'} is not this machine` }
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 type { ApprovalKind, ApprovalRequest, Approvals, Member, MemberRole } from '@lincyaw/kernel'
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 presents(window: { report?: WindowReport }): boolean {
40
- return window.report?.role === 'presenter' || window.report?.role === 'audience'
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 Control {
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): AsyncIterable<{ event: string, data: unknown }> & { close(): void } {
70
+ open(id: string, member: Member, document: string, options: { observer?: boolean } = {}): PushStream<{ event: string, data: unknown }> {
68
71
  this.#windows.get(id)?.close()
69
- const queue: { event: string, data: unknown }[] = []
70
- let wake: (() => void) | undefined
71
- let closed = false
72
- const window: Window = {
73
- id,
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
- wake?.()
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(member?: Member, document?: string, viewer?: Member): { window: string, member: string, roles: MemberRole[], document: string, report: WindowReport }[] {
137
- const all = [...this.#windows.values()].filter(w => w.report?.position && (!member || w.member.id === member.id) && (document === undefined || w.document === document))
138
- const trusted = (w: Window) => hasRole(w.member, 'editor') || w.member.id === viewer?.id
139
- const rank = (w: Window) => (!trusted(w) ? 2 : presents(w) ? 0 : 1)
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, member?: Member, document?: string): number {
145
- const windows = [...this.#windows.values()].filter(w => (!member || w.member.id === member.id) && (document === undefined || w.document === document))
146
- const presenting = windows.filter(w => w.report?.role === 'presenter' || w.report?.role === 'audience')
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
- session(action: 'start' | 'end', name?: string, document?: string, member?: Member): number {
153
- const windows = [...this.#windows.values()].filter(w => (!member || w.member.id === member.id) && (document === undefined || w.document === document) && (w.report?.role === 'presenter' || w.report?.role === 'audience'))
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 { focusReport, resolveFocus } from '@lincyaw/mcp'
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
- get(document?: string): McpFocus | undefined {
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 focusBody(body: unknown): { document: string, focus: FocusReport[] } | string {
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 = focusReport(item)
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 { createServer } from 'node:http'
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 { json, problem } from '@lincyaw/kernel'
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 hostname(host: string | null | undefined): string {
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(hostname(host))
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 hostname(new URL(origin).host)
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 { json, problem }
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 = () => json({ error: `the request body is larger than this route takes (${limit} bytes)` }, 413)
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 server
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 host.includes(':') && !host.startsWith('[') ? `[${host}]` : host
187
+ return bracketIPv6(host)
169
188
  }
package/src/index.ts CHANGED
@@ -1,8 +1,8 @@
1
- export { APPROVAL_TIMEOUT_MS, Control } from './control.ts'
2
- export type { ApprovalPolicy, WindowPosition, WindowReport } from './control.ts'
3
- export { servedDocument } from './host.ts'
4
- export type { HostDocument, ServedDocument, ServedPlugin, ServerHost } from './host.ts'
5
- export { anyAddress, networkAddresses } from './auth.ts'
6
- export { bearer, isLoopback, json, limitBody, problem } from './http.ts'
7
- export { LOCAL_AUTHOR, SERVER_PATH, startServer } from './server.ts'
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'