@lincyaw/addon-exec-jupyter 0.0.0-stage → 1.0.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +46 -2
- package/components/PmTerminal.css +41 -0
- package/components/PmTerminal.tsx +145 -0
- package/package.json +54 -3
- package/src/client/index.ts +5 -0
- package/src/client/jupyter-client.ts +273 -0
- package/src/client/jupyter-token.ts +87 -0
- package/src/client/messages.ts +90 -0
- package/src/client/plugin.ts +37 -0
- package/src/domain/args.ts +22 -0
- package/src/domain/config.ts +69 -0
- package/src/domain/connection.ts +37 -0
- package/src/domain/fallback-text.ts +10 -0
- package/src/domain/index.ts +26 -0
- package/src/domain/outputs.ts +89 -0
- package/src/domain/terminal-service.ts +21 -0
- package/src/node/connection.ts +45 -0
- package/src/node/declared.ts +16 -0
- package/src/node/hub.ts +68 -0
- package/src/node/presmium.ts +24 -0
- package/src/node/provider.ts +85 -0
- package/src/node/proxy.ts +141 -0
- package/src/node/terminal.ts +44 -0
- package/src/node/vite.ts +15 -0
- package/src/shared/execute.ts +70 -0
- package/src/shared/kernel-lifecycle.ts +154 -0
- package/src/shared/kernels.ts +234 -0
- package/src/shared/load-services.ts +9 -0
- package/src/shared/refusal.ts +11 -0
- package/src/shared/terminals.ts +74 -0
- package/vite.config.ts +4 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { RunOutput } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
const ANSI = /\u001B\[[0-9;?]*[ -/]*[@-~]/g
|
|
5
|
+
|
|
6
|
+
export function stripAnsi(text: string): string {
|
|
7
|
+
return text.replace(ANSI, '')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class OutputCollector {
|
|
11
|
+
readonly outputs: RunOutput[] = []
|
|
12
|
+
#ids: (string | undefined)[] = []
|
|
13
|
+
#clearOnNext = false
|
|
14
|
+
|
|
15
|
+
add(type: string, content: unknown): void {
|
|
16
|
+
if (!isRecord(content)) return
|
|
17
|
+
if (type === 'clear_output') {
|
|
18
|
+
if (content.wait === true) this.#clearOnNext = true
|
|
19
|
+
else this.#clear()
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
if (type === 'update_display_data') {
|
|
23
|
+
const id = displayId(content)
|
|
24
|
+
const output = richOutput('display_data', content)
|
|
25
|
+
this.#ids.forEach((known, i) => {
|
|
26
|
+
if (id !== undefined && known === id && output) this.outputs[i] = output
|
|
27
|
+
})
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
const output = type === 'stream'
|
|
31
|
+
? streamOutput(content)
|
|
32
|
+
: type === 'display_data' || type === 'execute_result'
|
|
33
|
+
? richOutput(type, content)
|
|
34
|
+
: type === 'error'
|
|
35
|
+
? errorOutput(content)
|
|
36
|
+
: undefined
|
|
37
|
+
if (!output) return
|
|
38
|
+
if (this.#clearOnNext) {
|
|
39
|
+
this.#clear()
|
|
40
|
+
this.#clearOnNext = false
|
|
41
|
+
}
|
|
42
|
+
const last = this.outputs.at(-1)
|
|
43
|
+
if (output.output_type === 'stream' && last?.output_type === 'stream' && last.name === output.name) {
|
|
44
|
+
this.outputs[this.outputs.length - 1] = { ...last, text: last.text + output.text }
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
this.outputs.push(output)
|
|
48
|
+
this.#ids.push(type === 'display_data' ? displayId(content) : undefined)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
text(): { stdout: string, stderr: string } {
|
|
52
|
+
let stdout = ''
|
|
53
|
+
let stderr = ''
|
|
54
|
+
for (const output of this.outputs) {
|
|
55
|
+
if (output.output_type === 'stream') {
|
|
56
|
+
if (output.name === 'stdout') stdout += output.text
|
|
57
|
+
else stderr += output.text
|
|
58
|
+
} else if (output.output_type === 'error') {
|
|
59
|
+
stderr += `${stripAnsi(output.traceback.join('\n'))}\n`
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { stdout, stderr }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#clear(): void {
|
|
66
|
+
this.outputs.length = 0
|
|
67
|
+
this.#ids = []
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function displayId(content: Record<string, unknown>): string | undefined {
|
|
72
|
+
const transient = content.transient
|
|
73
|
+
return isRecord(transient) && typeof transient.display_id === 'string' ? transient.display_id : undefined
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function streamOutput(content: Record<string, unknown>): RunOutput | undefined {
|
|
77
|
+
if (typeof content.text !== 'string') return undefined
|
|
78
|
+
return { output_type: 'stream', name: content.name === 'stderr' ? 'stderr' : 'stdout', text: content.text }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function richOutput(type: 'display_data' | 'execute_result', content: Record<string, unknown>): RunOutput | undefined {
|
|
82
|
+
if (!isRecord(content.data)) return undefined
|
|
83
|
+
return { output_type: type, data: content.data, ...isRecord(content.metadata) && Object.keys(content.metadata).length ? { metadata: content.metadata } : {} }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function errorOutput(content: Record<string, unknown>): RunOutput {
|
|
87
|
+
const traceback = Array.isArray(content.traceback) ? content.traceback.filter((line): line is string => typeof line === 'string') : []
|
|
88
|
+
return { output_type: 'error', ename: String(content.ename ?? 'Error'), evalue: String(content.evalue ?? ''), traceback }
|
|
89
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineService } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
export interface TerminalSession {
|
|
4
|
+
send(data: string): void
|
|
5
|
+
resize(rows: number, columns: number): void
|
|
6
|
+
close(): Promise<void>
|
|
7
|
+
abandon(): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TerminalEvents {
|
|
11
|
+
output(data: string): void
|
|
12
|
+
closed(): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Terminals of the deck's Jupyter environments, for PmTerminal. */
|
|
16
|
+
export interface JupyterTerminals {
|
|
17
|
+
unavailable(name: string): string | undefined
|
|
18
|
+
openTerminal(name: string, events: TerminalEvents): Promise<TerminalSession>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const JUPYTER_TERMINALS = defineService<JupyterTerminals>('presmium.execJupyter.environments')
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ServerConnection } from '@jupyterlab/services'
|
|
2
|
+
import WebSocket from 'ws'
|
|
3
|
+
import type { ClientOptions } from 'ws'
|
|
4
|
+
import type { Connection } from '../domain/connection.ts'
|
|
5
|
+
import { loadJupyterServices } from '../shared/load-services.ts'
|
|
6
|
+
|
|
7
|
+
let quiet = false
|
|
8
|
+
|
|
9
|
+
function quietSockets(): void {
|
|
10
|
+
if (quiet) return
|
|
11
|
+
quiet = true
|
|
12
|
+
const debug = console.debug.bind(console)
|
|
13
|
+
console.debug = (...args: unknown[]) => {
|
|
14
|
+
if (typeof args[0] === 'string' && /^(?:Starting WebSocket|Kernel): /.test(args[0])) return
|
|
15
|
+
debug(...args)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Settings of a connection from Node: `ws` WebSockets and `fetch`, both with the connection's headers. */
|
|
20
|
+
export async function nodeSettings(connection: Connection, signal?: AbortSignal): Promise<ServerConnection.ISettings> {
|
|
21
|
+
quietSockets()
|
|
22
|
+
const { ServerConnection } = await loadJupyterServices()
|
|
23
|
+
const headers = connection.headers
|
|
24
|
+
class HeaderSocket extends WebSocket {
|
|
25
|
+
constructor(url: string, protocols?: string | string[], options?: ClientOptions) {
|
|
26
|
+
super(url, protocols, { ...options, headers: { ...options?.headers, ...headers } })
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return ServerConnection.makeSettings({
|
|
30
|
+
baseUrl: connection.url,
|
|
31
|
+
wsUrl: connection.url.replace(/^http/, 'ws'),
|
|
32
|
+
token: '',
|
|
33
|
+
appendToken: false,
|
|
34
|
+
WebSocket: HeaderSocket as unknown as typeof globalThis.WebSocket,
|
|
35
|
+
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
36
|
+
const request = new Request(input, init)
|
|
37
|
+
for (const [name, value] of Object.entries(headers)) request.headers.set(name, value)
|
|
38
|
+
return fetch(request, signal ? { signal: AbortSignal.any([request.signal, signal]) } : undefined).catch((error: unknown) => {
|
|
39
|
+
const cause = (error as { cause?: { code?: unknown, message?: unknown } }).cause
|
|
40
|
+
const detail = typeof cause?.code === 'string' ? cause.code : typeof cause?.message === 'string' ? cause.message : undefined
|
|
41
|
+
throw detail && error instanceof Error ? new TypeError(`${error.message} (${detail})`) : error
|
|
42
|
+
})
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EnvironmentSpec, IRPass } from '@lincyaw/kernel'
|
|
2
|
+
import { jupyterSpecs } from '../domain/index.ts'
|
|
3
|
+
|
|
4
|
+
const declared = new Map<string, ReadonlyMap<string, EnvironmentSpec>>()
|
|
5
|
+
|
|
6
|
+
/** The `kind: jupyter` environments of the deck's latest compile, which the server's proxies follow. */
|
|
7
|
+
export function declaredEnvironments(deckDir: string): ReadonlyMap<string, EnvironmentSpec> {
|
|
8
|
+
return declared.get(deckDir) ?? new Map()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const declaredEnvironmentsPass: IRPass = {
|
|
12
|
+
name: 'presmium.execJupyter.environments',
|
|
13
|
+
run(doc, ctx) {
|
|
14
|
+
declared.set(ctx.deckDir, new Map(Object.entries(jupyterSpecs(doc.config.execute?.environments))))
|
|
15
|
+
},
|
|
16
|
+
}
|
package/src/node/hub.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { EnvironmentSpec } from '@lincyaw/kernel'
|
|
3
|
+
import type { Connection, OperatorSide } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
export class HubEnvironmentError extends Error {
|
|
6
|
+
readonly code: string
|
|
7
|
+
readonly status: number
|
|
8
|
+
constructor(code: string, status = 502) { super(code); this.code = code; this.status = status }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Only operator-supplied addresses and the current owner's scoped token can reach the Hub. */
|
|
12
|
+
export async function prepareHubEnvironment(spec: EnvironmentSpec, side: OperatorSide, signal: AbortSignal): Promise<{ connection: Connection, generation: string }> {
|
|
13
|
+
if (typeof spec.profile !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(spec.profile) || spec.url || spec.image) {
|
|
14
|
+
throw new HubEnvironmentError('hub-profile-unavailable', 422)
|
|
15
|
+
}
|
|
16
|
+
const api = side.secret('JUPYTERHUB_API_URL')?.replace(/\/+$/, '')
|
|
17
|
+
const proxy = side.secret('PRESMIUM_HUB_PROXY_URL')?.replace(/\/+$/, '')
|
|
18
|
+
const token = side.secret('JUPYTERHUB_API_TOKEN')
|
|
19
|
+
const user = side.secret('JUPYTERHUB_USER')
|
|
20
|
+
if (!api || !proxy || !token || !user || ![api, proxy].every(value => /^https?:\/\//.test(value))) {
|
|
21
|
+
throw new HubEnvironmentError('hub-not-configured', 503)
|
|
22
|
+
}
|
|
23
|
+
const profiles: unknown = JSON.parse(side.secret('PRESMIUM_HUB_PROFILES') || '[]')
|
|
24
|
+
if (!Array.isArray(profiles) || !profiles.includes(spec.profile)) throw new HubEnvironmentError('hub-profile-unavailable', 422)
|
|
25
|
+
const server = `env-${spec.profile}`
|
|
26
|
+
const path = `/users/${encodeURIComponent(user)}`
|
|
27
|
+
const headers = { Authorization: `token ${token}` }
|
|
28
|
+
const request = (suffix: string, method = 'GET') => fetch(`${api}${path}${suffix}`, {
|
|
29
|
+
method, headers, redirect: 'manual', signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]),
|
|
30
|
+
})
|
|
31
|
+
const state = async () => {
|
|
32
|
+
const response = await request('')
|
|
33
|
+
if (!response.ok) throw new HubEnvironmentError('hub-unavailable')
|
|
34
|
+
const value: unknown = await response.json()
|
|
35
|
+
const servers = isRecord(value) && isRecord(value.servers) ? value.servers : {}
|
|
36
|
+
const found = servers[server]
|
|
37
|
+
return isRecord(found) ? found : undefined
|
|
38
|
+
}
|
|
39
|
+
let current = await state()
|
|
40
|
+
if (!current || (!current.ready && !current.pending)) {
|
|
41
|
+
const response = await request(`/servers/${server}`, 'POST')
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
// Another document may have started this user's same profile concurrently.
|
|
44
|
+
current = await state()
|
|
45
|
+
if (!current?.ready && current?.pending !== 'spawn') {
|
|
46
|
+
throw new HubEnvironmentError(response.status === 400 ? 'hub-profile-unavailable' : 'hub-start-failed')
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const until = Date.now() + 240_000
|
|
51
|
+
for (;;) {
|
|
52
|
+
signal.throwIfAborted()
|
|
53
|
+
current = await state()
|
|
54
|
+
if (current?.ready) {
|
|
55
|
+
return {
|
|
56
|
+
connection: { url: `${proxy}/user/${encodeURIComponent(user)}/${server}/`, headers },
|
|
57
|
+
generation: typeof current.started === 'string' ? current.started : '',
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!current || (!current.pending && current.stopped)) throw new HubEnvironmentError('hub-start-failed')
|
|
61
|
+
if (Date.now() >= until) throw new HubEnvironmentError('hub-start-timeout', 504)
|
|
62
|
+
await new Promise<void>((resolve, reject) => {
|
|
63
|
+
const abort = () => { clearTimeout(timer); reject(signal.reason) }
|
|
64
|
+
const timer = setTimeout(() => { signal.removeEventListener('abort', abort); resolve() }, 500)
|
|
65
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import type { PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
3
|
+
import { EXEC_JUPYTER_ID } from '../domain/index.ts'
|
|
4
|
+
import { declaredEnvironmentsPass } from './declared.ts'
|
|
5
|
+
import { jupyterProvider } from './provider.ts'
|
|
6
|
+
import { jupyterProxy } from './proxy.ts'
|
|
7
|
+
import { terminalDirective, terminalPass } from './terminal.ts'
|
|
8
|
+
|
|
9
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
10
|
+
|
|
11
|
+
const plugin: PresmiumNodePlugin = {
|
|
12
|
+
id: EXEC_JUPYTER_ID,
|
|
13
|
+
version,
|
|
14
|
+
kernel: '^1.0.0',
|
|
15
|
+
directives: [terminalDirective],
|
|
16
|
+
passes: [declaredEnvironmentsPass, terminalPass],
|
|
17
|
+
environments: { jupyter: jupyterProvider },
|
|
18
|
+
server: jupyterProxy,
|
|
19
|
+
rendererExtensions: {
|
|
20
|
+
notebook: { components: { PmTerminal: () => [] } },
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default plugin
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { environmentExecutor } from '@lincyaw/kernel'
|
|
3
|
+
import type { EnvironmentContext, EnvironmentProvider, EnvironmentSpec, Executor } from '@lincyaw/kernel'
|
|
4
|
+
import { jupyterKernelName, languagesOf, operatorConnection } from '../domain/index.ts'
|
|
5
|
+
import { createJupyterKernels } from '../shared/kernels.ts'
|
|
6
|
+
import type { JupyterKernels, JupyterKernelsText } from '../shared/kernels.ts'
|
|
7
|
+
import { nodeSettings } from './connection.ts'
|
|
8
|
+
|
|
9
|
+
const FINGERPRINT_TTL_MS = 60_000
|
|
10
|
+
|
|
11
|
+
function nodeText(prefix: string, url: () => string): JupyterKernelsText {
|
|
12
|
+
return {
|
|
13
|
+
unreachable: error => `${prefix}cannot reach ${url()}: ${error}`,
|
|
14
|
+
refused: `${prefix}the server at ${url()} refused the token`,
|
|
15
|
+
noKernel: (kernel, known) => `${prefix}the server has no kernel ${kernel} (it has ${known.join(', ') || 'none'})`,
|
|
16
|
+
wrongLanguage: (kernel, language, lang) => `${prefix}kernel ${kernel} runs ${language}, not ${lang}`,
|
|
17
|
+
timedOut: seconds => `${prefix}timed out after ${seconds} s; the kernel was interrupted`,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function canonical(value: unknown): string {
|
|
22
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
|
23
|
+
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical((value as Record<string, unknown>)[key])}`).join(',')}}`
|
|
24
|
+
return JSON.stringify(value)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createJupyterExecutor(name: string, spec: EnvironmentSpec, ctx: EnvironmentContext): Executor {
|
|
28
|
+
const id = environmentExecutor(name)
|
|
29
|
+
const kernelName = jupyterKernelName(spec)
|
|
30
|
+
const languages = languagesOf(spec)
|
|
31
|
+
let kernels: JupyterKernels | undefined
|
|
32
|
+
const pool = (): JupyterKernels => {
|
|
33
|
+
if (kernels) return kernels
|
|
34
|
+
const resolved = operatorConnection(name, spec, { secret: ctx.secret })
|
|
35
|
+
return kernels = createJupyterKernels({
|
|
36
|
+
executorId: id,
|
|
37
|
+
kernelName,
|
|
38
|
+
languages,
|
|
39
|
+
settings: () => 'connection' in resolved
|
|
40
|
+
? nodeSettings(resolved.connection)
|
|
41
|
+
: Promise.reject(new Error(`environment "${name}": ${'unavailable' in resolved ? resolved.unavailable : resolved.mismatch}`)),
|
|
42
|
+
cleanupFailed: error => ctx.log.warn(`environment "${name}": Jupyter cleanup failed`, error),
|
|
43
|
+
text: nodeText(`environment "${name}": `, () => ('connection' in resolved ? resolved.connection.url : '')),
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
let fingerprint: { at: number, value: Promise<string> } | undefined
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
id,
|
|
50
|
+
supports: lang => pool().supports(lang),
|
|
51
|
+
run: (request, signal) => pool().run(request, signal),
|
|
52
|
+
async endSession(session) {
|
|
53
|
+
await kernels?.endSession(session)
|
|
54
|
+
},
|
|
55
|
+
fingerprint() {
|
|
56
|
+
if (!fingerprint || Date.now() - fingerprint.at > FINGERPRINT_TTL_MS) {
|
|
57
|
+
const value = pool().describe().then((described) => {
|
|
58
|
+
const specHash = createHash('sha256').update(canonical(described.spec)).digest('hex').slice(0, 16)
|
|
59
|
+
const image = typeof spec.image === 'string' && spec.image ? spec.image : 'unpinned'
|
|
60
|
+
return `jupyter image=${image} kernelspec=${kernelName}:sha256:${specHash} kernel=${described.implementation} ${described.implementationVersion} ${described.language} ${described.languageVersion}`
|
|
61
|
+
})
|
|
62
|
+
fingerprint = { at: Date.now(), value }
|
|
63
|
+
value.catch(() => {
|
|
64
|
+
if (fingerprint?.value === value) fingerprint = undefined
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return fingerprint.value
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const jupyterProvider: EnvironmentProvider = {
|
|
73
|
+
create(name, spec, ctx) {
|
|
74
|
+
const resolved = operatorConnection(name, spec, { secret: ctx.secret })
|
|
75
|
+
if ('mismatch' in resolved) throw new Error(resolved.mismatch)
|
|
76
|
+
return createJupyterExecutor(name, spec, ctx)
|
|
77
|
+
},
|
|
78
|
+
async probe(name, spec, ctx) {
|
|
79
|
+
const resolved = operatorConnection(name, spec, { secret: ctx.secret })
|
|
80
|
+
if (!('connection' in resolved)) return 'unavailable' in resolved ? resolved.unavailable : resolved.mismatch
|
|
81
|
+
const { connection } = resolved
|
|
82
|
+
const kernels = createJupyterKernels({ executorId: environmentExecutor(name), kernelName: jupyterKernelName(spec), settings: () => nodeSettings(connection), text: nodeText('', () => connection.url) })
|
|
83
|
+
return kernels.probe()
|
|
84
|
+
},
|
|
85
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isRecord, jsonResponse, problemResponse } from '@lincyaw/kernel'
|
|
2
|
+
import type { EnvironmentSpec, ProxySpec, Route, ServerContext, ServerContribution } from '@lincyaw/kernel'
|
|
3
|
+
import { authOf, cleanupPath, operatorConnection, preparePath, proxyPath } from '../domain/index.ts'
|
|
4
|
+
import type { Connection } from '../domain/index.ts'
|
|
5
|
+
import { shutdownJupyterKernel } from '../shared/kernel-lifecycle.ts'
|
|
6
|
+
import { nodeSettings } from './connection.ts'
|
|
7
|
+
import { declaredEnvironments } from './declared.ts'
|
|
8
|
+
import { HubEnvironmentError, prepareHubEnvironment } from './hub.ts'
|
|
9
|
+
|
|
10
|
+
function proxyHeaders(connection: Connection): Record<string, string> {
|
|
11
|
+
const target = new URL(connection.url)
|
|
12
|
+
const headers: Record<string, string> = { host: target.host, origin: target.origin }
|
|
13
|
+
for (const [name, value] of Object.entries(connection.headers)) headers[name.toLowerCase()] = value
|
|
14
|
+
return headers
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Mount {
|
|
18
|
+
proxy?: ProxySpec
|
|
19
|
+
connection?: Connection
|
|
20
|
+
reason?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The server's proxy to each `auth: proxy` environment, and the route through which a page hands over its kernels' cleanup. */
|
|
24
|
+
export function jupyterProxy(ctx: ServerContext): ServerContribution {
|
|
25
|
+
const reported = new Set<string>()
|
|
26
|
+
const cleaning = new Map<string, Promise<void>>()
|
|
27
|
+
const prepared = new Map<string, { profile: unknown, connection: Connection }>()
|
|
28
|
+
const preparing = new Map<string, Promise<{ connection: Connection, generation: string }>>()
|
|
29
|
+
const lifetime = new AbortController()
|
|
30
|
+
const hubKey = (name: string, spec: EnvironmentSpec) => JSON.stringify([name, spec.profile])
|
|
31
|
+
const mounts = (): Map<string, Mount> => {
|
|
32
|
+
const found = new Map<string, Mount>()
|
|
33
|
+
for (const [name, spec] of declaredEnvironments(ctx.deckDir)) {
|
|
34
|
+
if (!['proxy', 'hub'].includes(authOf(spec))) continue
|
|
35
|
+
found.set(name, mount(name, spec))
|
|
36
|
+
}
|
|
37
|
+
return found
|
|
38
|
+
}
|
|
39
|
+
const mount = (name: string, spec: EnvironmentSpec): Mount => {
|
|
40
|
+
if (authOf(spec) === 'hub') {
|
|
41
|
+
const known = prepared.get(hubKey(name, spec))
|
|
42
|
+
if (!known || known.profile !== spec.profile) return { reason: 'hub-environment-not-prepared' }
|
|
43
|
+
return { connection: known.connection, proxy: { path: proxyPath(name), target: known.connection.url, headers: proxyHeaders(known.connection), role: 'reader' } }
|
|
44
|
+
}
|
|
45
|
+
const resolved = operatorConnection(name, spec, { secret: name => ctx.secret(name) })
|
|
46
|
+
if ('connection' in resolved) {
|
|
47
|
+
return { connection: resolved.connection, proxy: { path: proxyPath(name), target: resolved.connection.url, headers: proxyHeaders(resolved.connection), role: 'reader' } }
|
|
48
|
+
}
|
|
49
|
+
const reason = `environment "${name}": ${'unavailable' in resolved ? resolved.unavailable : resolved.mismatch}`
|
|
50
|
+
if (!reported.has(reason)) {
|
|
51
|
+
reported.add(reason)
|
|
52
|
+
ctx.log.error(`${reason}; its proxy is not mounted`)
|
|
53
|
+
}
|
|
54
|
+
return { reason }
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
get proxies() {
|
|
58
|
+
return [...mounts().values()].flatMap(found => (found.proxy ? [found.proxy] : []))
|
|
59
|
+
},
|
|
60
|
+
get routes() {
|
|
61
|
+
const routes: Record<string, Route> = {}
|
|
62
|
+
for (const [name, spec] of declaredEnvironments(ctx.deckDir)) {
|
|
63
|
+
if (authOf(spec) !== 'hub') continue
|
|
64
|
+
routes[preparePath(name)] = {
|
|
65
|
+
role: 'reader',
|
|
66
|
+
maxBody: 1024,
|
|
67
|
+
async handle(request) {
|
|
68
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
69
|
+
if (lifetime.signal.aborted) return problemResponse(503, 'hub-unavailable')
|
|
70
|
+
const key = hubKey(name, spec)
|
|
71
|
+
let task = preparing.get(key)
|
|
72
|
+
if (!task) {
|
|
73
|
+
// Shared starts outlive one disconnected tab, but are always bounded.
|
|
74
|
+
if (preparing.size >= 32) return problemResponse(429, 'hub-unavailable')
|
|
75
|
+
task = prepareHubEnvironment(spec, { secret: name => ctx.secret(name) }, AbortSignal.any([lifetime.signal, AbortSignal.timeout(250_000)]))
|
|
76
|
+
.finally(() => preparing.delete(key))
|
|
77
|
+
preparing.set(key, task)
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
const { connection, generation } = await task
|
|
81
|
+
const current = declaredEnvironments(ctx.deckDir).get(name)
|
|
82
|
+
if (lifetime.signal.aborted || !current || authOf(current) !== 'hub' || current.profile !== spec.profile) {
|
|
83
|
+
return problemResponse(409, 'hub-profile-changed')
|
|
84
|
+
}
|
|
85
|
+
prepared.set(key, { profile: spec.profile, connection })
|
|
86
|
+
return jsonResponse({ generation })
|
|
87
|
+
} catch (error) {
|
|
88
|
+
prepared.delete(key)
|
|
89
|
+
const code = error instanceof HubEnvironmentError ? error.code : 'hub-unavailable'
|
|
90
|
+
ctx.log.warn(`environment "${name}": ${code}`)
|
|
91
|
+
return problemResponse(error instanceof HubEnvironmentError ? error.status : 502, code)
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const [name, found] of mounts()) {
|
|
97
|
+
if (found.connection) {
|
|
98
|
+
const connection = found.connection
|
|
99
|
+
// Outside the proxy prefix: proxy routing takes precedence over routes.
|
|
100
|
+
routes[cleanupPath(name)] = {
|
|
101
|
+
role: 'reader',
|
|
102
|
+
maxBody: 1024,
|
|
103
|
+
async handle(request) {
|
|
104
|
+
if (request.method !== 'POST') return problemResponse(405, 'POST')
|
|
105
|
+
const body: unknown = await request.json().catch(() => undefined)
|
|
106
|
+
if (!isRecord(body) || Object.keys(body).length !== 2
|
|
107
|
+
|| typeof body.kernel !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(body.kernel)
|
|
108
|
+
|| typeof body.directory !== 'string' || !/^presmium-runs\/[a-z0-9]+-[a-z0-9]+$/.test(body.directory)) {
|
|
109
|
+
return problemResponse(400, 'expected a kernel id and its presmium-runs directory')
|
|
110
|
+
}
|
|
111
|
+
const key = JSON.stringify([connection.url, body.kernel, body.directory])
|
|
112
|
+
let task = cleaning.get(key)
|
|
113
|
+
if (!task) {
|
|
114
|
+
if (cleaning.size >= 32) return problemResponse(429, 'too many Jupyter cleanups in progress')
|
|
115
|
+
const { kernel, directory } = body
|
|
116
|
+
// Do not bind to request.signal: the browser may already be gone.
|
|
117
|
+
const timeout = AbortSignal.timeout(15_000)
|
|
118
|
+
task = nodeSettings(connection, timeout).then(settings => shutdownJupyterKernel(kernel, directory, settings))
|
|
119
|
+
.finally(() => cleaning.delete(key))
|
|
120
|
+
cleaning.set(key, task)
|
|
121
|
+
}
|
|
122
|
+
await task
|
|
123
|
+
return new Response(null, { status: 204 })
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!found.reason) continue
|
|
128
|
+
const reason = found.reason
|
|
129
|
+
routes[`${proxyPath(name)}/*`] = {
|
|
130
|
+
role: 'reader',
|
|
131
|
+
handle: () => problemResponse(502, reason),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return routes
|
|
135
|
+
},
|
|
136
|
+
async close() {
|
|
137
|
+
lifetime.abort()
|
|
138
|
+
await Promise.allSettled([...cleaning.values(), ...preparing.values()])
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { docBlocks } from '@lincyaw/kernel'
|
|
2
|
+
import type { ComponentNode, DirectiveSpec, IRPass } from '@lincyaw/kernel'
|
|
3
|
+
import { DEFAULT_TERMINAL_ROWS, EXEC_JUPYTER_ID, jupyterSpecs, terminalFallback } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
export const terminalDirective: DirectiveSpec = {
|
|
6
|
+
name: 'terminal',
|
|
7
|
+
arg: true,
|
|
8
|
+
body: false,
|
|
9
|
+
options: { height: 'number' },
|
|
10
|
+
toIR(input, ctx) {
|
|
11
|
+
const env = input.arg?.trim() ?? ''
|
|
12
|
+
if (!env) ctx.report({ checkId: EXEC_JUPYTER_ID, severity: 'error', message: '{terminal} needs the name of a kind: jupyter environment of execute.environments, e.g. :::{terminal} gpu' })
|
|
13
|
+
const height = input.options.height
|
|
14
|
+
const rows = typeof height === 'number' && Number.isInteger(height) && height > 0 ? height : DEFAULT_TERMINAL_ROWS
|
|
15
|
+
const node: ComponentNode = {
|
|
16
|
+
type: 'component',
|
|
17
|
+
plugin: EXEC_JUPYTER_ID,
|
|
18
|
+
name: 'PmTerminal',
|
|
19
|
+
props: { env, rows },
|
|
20
|
+
fallback: { type: 'root', children: [{ type: 'paragraph', children: [{ type: 'text', value: terminalFallback(env, ctx.lang) }] }] },
|
|
21
|
+
}
|
|
22
|
+
return { node }
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const terminalPass: IRPass = {
|
|
27
|
+
name: 'presmium.execJupyter.terminals',
|
|
28
|
+
run(doc, ctx) {
|
|
29
|
+
const declared = jupyterSpecs(doc.config.execute?.environments)
|
|
30
|
+
for (const { block } of docBlocks(doc)) {
|
|
31
|
+
const node = block.node
|
|
32
|
+
if (node.type !== 'component' || node.plugin !== EXEC_JUPYTER_ID || node.name !== 'PmTerminal') continue
|
|
33
|
+
const env = String(node.props.env ?? '')
|
|
34
|
+
if (!env || declared[env]) continue
|
|
35
|
+
const other = doc.config.execute?.environments?.[env]
|
|
36
|
+
ctx.report({
|
|
37
|
+
checkId: EXEC_JUPYTER_ID,
|
|
38
|
+
severity: 'error',
|
|
39
|
+
message: other ? `{terminal} ${env}: the environment is of kind ${other.kind}; a terminal needs kind: jupyter` : `{terminal} ${env}: no such environment in execute.environments of the headmatter`,
|
|
40
|
+
source: { file: block.source.file, line: block.source.line },
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
}
|
package/src/node/vite.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url)
|
|
4
|
+
|
|
5
|
+
/** `@jupyterlab/services` imports `ws`; in every bundle (the server-side rendering too) it gets the package's own shim. */
|
|
6
|
+
export function jupyterWsShim(): { name: string, enforce: 'pre', resolveId(source: string, importer?: string): string | undefined } {
|
|
7
|
+
const shim = require.resolve('@jupyterlab/services/lib/shim/ws.js')
|
|
8
|
+
return {
|
|
9
|
+
name: 'presmium:exec-jupyter-services',
|
|
10
|
+
enforce: 'pre',
|
|
11
|
+
resolveId(source, importer) {
|
|
12
|
+
return source === 'ws' && importer?.includes('/@jupyterlab/services/') ? shim : undefined
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ServerConnection } from '@jupyterlab/services'
|
|
2
|
+
import type { OutputCollector } from '../domain/outputs.ts'
|
|
3
|
+
import type { JupyterKernelHandle } from './kernel-lifecycle.ts'
|
|
4
|
+
import { loadJupyterServices } from './load-services.ts'
|
|
5
|
+
|
|
6
|
+
const INTERRUPT_GRACE_MS = 5_000
|
|
7
|
+
|
|
8
|
+
/** Writes the run's files into the kernel's directory through the Contents API. */
|
|
9
|
+
export async function writeRunFiles(handle: JupyterKernelHandle, settings: ServerConnection.ISettings, files: Record<string, string>): Promise<void> {
|
|
10
|
+
const { Drive } = await loadJupyterServices()
|
|
11
|
+
const drive = new Drive({ serverSettings: settings })
|
|
12
|
+
for (const [path, content] of Object.entries(files)) {
|
|
13
|
+
const parts = path.split('/').filter(Boolean)
|
|
14
|
+
if (parts.length === 0 || parts.some(part => part === '..' || part === '.')) continue
|
|
15
|
+
for (let i = 1; i < parts.length; i++) await drive.save(`${handle.dir}/${parts.slice(0, i).join('/')}`, { type: 'directory' })
|
|
16
|
+
await drive.save(`${handle.dir}/${parts.join('/')}`, { type: 'file', format: 'text', content })
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ExecuteOptions {
|
|
21
|
+
stdin: string
|
|
22
|
+
/** Collects the outputs; without it the code runs silently. */
|
|
23
|
+
collector?: OutputCollector
|
|
24
|
+
timeoutMs: number
|
|
25
|
+
signal?: AbortSignal
|
|
26
|
+
shutdown(): Promise<void>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Executes code on the kernel, answering its input requests with the lines of `stdin`. Past the timeout, or on abort,
|
|
31
|
+
* it interrupts the kernel, and shuts the kernel down if it is still busy after a grace period.
|
|
32
|
+
*/
|
|
33
|
+
export async function executeCode(handle: JupyterKernelHandle, code: string, options: ExecuteOptions): Promise<{ status: string, timedOut: boolean }> {
|
|
34
|
+
const { KernelAPI } = await loadJupyterServices()
|
|
35
|
+
const { collector, signal } = options
|
|
36
|
+
signal?.throwIfAborted()
|
|
37
|
+
const lines = options.stdin ? options.stdin.replace(/\n$/, '').split('\n') : []
|
|
38
|
+
const future = handle.connection.requestExecute({ code, silent: !collector, store_history: false, allow_stdin: true, stop_on_error: true })
|
|
39
|
+
future.onIOPub = (msg) => {
|
|
40
|
+
collector?.add(msg.header.msg_type, msg.content)
|
|
41
|
+
}
|
|
42
|
+
future.onStdin = (msg) => {
|
|
43
|
+
if (msg.header.msg_type === 'input_request') handle.connection.sendInputReply({ status: 'ok', value: lines.shift() ?? '' }, msg.header as never)
|
|
44
|
+
}
|
|
45
|
+
let timedOut = false
|
|
46
|
+
let stopping: ReturnType<typeof setTimeout> | undefined
|
|
47
|
+
const interrupt = () => {
|
|
48
|
+
if (stopping) return
|
|
49
|
+
void KernelAPI.interruptKernel(handle.id, handle.connection.serverSettings).catch(() => {})
|
|
50
|
+
stopping = setTimeout(() => void options.shutdown().catch(() => {}), INTERRUPT_GRACE_MS)
|
|
51
|
+
}
|
|
52
|
+
const timer = setTimeout(() => {
|
|
53
|
+
timedOut = true
|
|
54
|
+
interrupt()
|
|
55
|
+
}, options.timeoutMs)
|
|
56
|
+
const abort = () => interrupt()
|
|
57
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
58
|
+
try {
|
|
59
|
+
const reply = await future.done
|
|
60
|
+
return { status: reply.content.status, timedOut }
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (timedOut) return { status: 'error', timedOut }
|
|
63
|
+
throw error
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
clearTimeout(stopping)
|
|
67
|
+
signal?.removeEventListener('abort', abort)
|
|
68
|
+
future.dispose()
|
|
69
|
+
}
|
|
70
|
+
}
|