@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.
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }