@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,273 @@
|
|
|
1
|
+
import { environmentExecutor, errorMessage, failedRun, isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { EnvironmentSpec, Executor, RunResult } from '@lincyaw/kernel'
|
|
3
|
+
import { currentLocale, pluginRoutePath, serverClient } from '@lincyaw/host'
|
|
4
|
+
import type { ServerConnection } from '@jupyterlab/services'
|
|
5
|
+
import { authOf, baseUrl, cleanupPath, EXEC_JUPYTER_ID, jupyterKernelName, languagesOf, originOf, preparePath, proxyPath } from '../domain/index.ts'
|
|
6
|
+
import type { JupyterTerminals, TerminalSession } from '../domain/index.ts'
|
|
7
|
+
import { createJupyterKernels } from '../shared/kernels.ts'
|
|
8
|
+
import type { JupyterKernels } from '../shared/kernels.ts'
|
|
9
|
+
import { loadJupyterServices } from '../shared/load-services.ts'
|
|
10
|
+
import { isRefused } from '../shared/refusal.ts'
|
|
11
|
+
import { openTerminal } from '../shared/terminals.ts'
|
|
12
|
+
import { failureText, messages } from './messages.ts'
|
|
13
|
+
import { forgetJupyterToken, jupyterTokenFor } from './jupyter-token.ts'
|
|
14
|
+
|
|
15
|
+
/** The browser's side of the deck's Jupyter environments: an executor for each, and their terminals. */
|
|
16
|
+
export interface JupyterClient {
|
|
17
|
+
terminals: JupyterTerminals
|
|
18
|
+
executors: Executor[]
|
|
19
|
+
dispose(): Promise<void>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let documentName: Promise<string> | undefined
|
|
23
|
+
function servedDocument(): Promise<string> {
|
|
24
|
+
documentName ??= serverClient().fetch('presmium.server', 'hello')
|
|
25
|
+
.then(response => (response.ok ? response.json() : {}))
|
|
26
|
+
.then((hello: unknown) => (isRecord(hello) && typeof hello.document === 'string' ? hello.document : ''))
|
|
27
|
+
.catch(() => '')
|
|
28
|
+
return documentName
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function hubUser(url: string, accessToken: string): Promise<string> {
|
|
32
|
+
const origin = originOf(url.replace('{user}', 'x'))!
|
|
33
|
+
const response = await fetch(`${origin}/hub/api/user`, { headers: { Authorization: `token ${accessToken}` } })
|
|
34
|
+
if (!response.ok) throw Object.assign(new Error(`${origin}/hub/api/user: ${response.status}`), { response })
|
|
35
|
+
const user: unknown = await response.json()
|
|
36
|
+
if (!isRecord(user) || typeof user.name !== 'string') throw new Error(`${origin}/hub/api/user: no name`)
|
|
37
|
+
return encodeURIComponent(user.name)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const IMAGES = ['image/png', 'image/jpeg', 'image/gif']
|
|
41
|
+
const BASE64 = /^[A-Za-z0-9+/=\s]*$/
|
|
42
|
+
const badImage = (data: Record<string, unknown>) => IMAGES.some(type => data[type] !== undefined && (typeof data[type] !== 'string' || !BASE64.test(data[type])))
|
|
43
|
+
|
|
44
|
+
async function sanitized(result: RunResult): Promise<RunResult> {
|
|
45
|
+
if (!result.outputs?.some(output => 'data' in output && (typeof output.data['text/html'] === 'string' || typeof output.data['image/svg+xml'] === 'string' || badImage(output.data)))) return result
|
|
46
|
+
const { default: DOMPurify } = await import('dompurify')
|
|
47
|
+
return {
|
|
48
|
+
...result,
|
|
49
|
+
outputs: result.outputs.map((output) => {
|
|
50
|
+
if (!('data' in output)) return output
|
|
51
|
+
const data = { ...output.data }
|
|
52
|
+
if (typeof data['text/html'] === 'string') data['text/html'] = DOMPurify.sanitize(data['text/html'])
|
|
53
|
+
if (typeof data['image/svg+xml'] === 'string') data['image/svg+xml'] = DOMPurify.sanitize(data['image/svg+xml'], { USE_PROFILES: { svg: true, svgFilters: true } })
|
|
54
|
+
for (const type of IMAGES) {
|
|
55
|
+
if (data[type] !== undefined && (typeof data[type] !== 'string' || !BASE64.test(data[type]))) delete data[type]
|
|
56
|
+
}
|
|
57
|
+
return { ...output, data }
|
|
58
|
+
}),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createJupyterClient(environments: Record<string, EnvironmentSpec>): JupyterClient {
|
|
63
|
+
const settings = new Map<string, Promise<ServerConnection.ISettings>>()
|
|
64
|
+
const proxyDocuments = new Map<string, string>()
|
|
65
|
+
const generations = new Map<string, string>()
|
|
66
|
+
let disposing: Promise<void> | undefined
|
|
67
|
+
|
|
68
|
+
const unavailable = (name: string): string | undefined => {
|
|
69
|
+
const t = messages()
|
|
70
|
+
const spec = environments[name]
|
|
71
|
+
if (!spec) return t.unknown(name)
|
|
72
|
+
const auth = authOf(spec)
|
|
73
|
+
if (auth === 'env') return t.envOnly(name)
|
|
74
|
+
if (auth === 'proxy' || auth === 'hub') return serverClient().available ? undefined : t.proxyStatic(name)
|
|
75
|
+
return originOf(typeof spec.url === 'string' ? spec.url.replace('{user}', 'x') : undefined) ? undefined : t.noUrl(name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const connect = async (name: string): Promise<ServerConnection.ISettings> => {
|
|
79
|
+
const reason = unavailable(name)
|
|
80
|
+
if (reason) throw new Error(reason)
|
|
81
|
+
const spec = environments[name]!
|
|
82
|
+
const { ServerConnection } = await loadJupyterServices()
|
|
83
|
+
if (['proxy', 'hub'].includes(authOf(spec))) {
|
|
84
|
+
const document = await servedDocument()
|
|
85
|
+
proxyDocuments.set(name, document)
|
|
86
|
+
if (authOf(spec) === 'hub') {
|
|
87
|
+
let response: Response
|
|
88
|
+
try {
|
|
89
|
+
response = await serverClient().fetch(EXEC_JUPYTER_ID, preparePath(name), { method: 'POST' })
|
|
90
|
+
} catch {
|
|
91
|
+
throw Object.assign(new Error(messages().reasons['hub-unavailable']), { code: 'hub-unavailable' })
|
|
92
|
+
}
|
|
93
|
+
const answer: unknown = await response.json().catch(() => undefined)
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const code = isRecord(answer) && typeof answer.error === 'string' ? answer.error : 'hub-unavailable'
|
|
96
|
+
throw Object.assign(new Error(messages().reasons[code] ?? messages().reasons['hub-unavailable']), { code })
|
|
97
|
+
}
|
|
98
|
+
generations.set(name, isRecord(answer) && typeof answer.generation === 'string' ? answer.generation : '')
|
|
99
|
+
}
|
|
100
|
+
const base = `${location.origin}${pluginRoutePath(serverClient(), EXEC_JUPYTER_ID)}${proxyPath(name)}/`
|
|
101
|
+
class DocumentSocket extends WebSocket {
|
|
102
|
+
constructor(url: string | URL, protocols?: string | string[]) {
|
|
103
|
+
const target = new URL(url)
|
|
104
|
+
target.searchParams.set('document', document)
|
|
105
|
+
super(target, protocols)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return ServerConnection.makeSettings({
|
|
109
|
+
baseUrl: base,
|
|
110
|
+
wsUrl: base.replace(/^http/, 'ws'),
|
|
111
|
+
token: '',
|
|
112
|
+
appendToken: false,
|
|
113
|
+
WebSocket: DocumentSocket,
|
|
114
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
|
115
|
+
const request = new Request(input, init)
|
|
116
|
+
request.headers.set('Presmium-Document', encodeURIComponent(document))
|
|
117
|
+
if (!request.headers.has('Accept-Language')) request.headers.set('Accept-Language', currentLocale())
|
|
118
|
+
return fetch(request)
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
const url = String(spec.url)
|
|
123
|
+
if (authOf(spec) === 'none') return ServerConnection.makeSettings({ baseUrl: baseUrl(url), token: '' })
|
|
124
|
+
const accessToken = await jupyterTokenFor(name, originOf(url.replace('{user}', 'x'))!)
|
|
125
|
+
if (!accessToken) throw new Error(messages().noToken(name))
|
|
126
|
+
let resolved: string
|
|
127
|
+
try {
|
|
128
|
+
resolved = url.includes('{user}') ? url.replace('{user}', await hubUser(url, accessToken)) : url
|
|
129
|
+
} catch (error) {
|
|
130
|
+
forget(name)
|
|
131
|
+
throw error
|
|
132
|
+
}
|
|
133
|
+
return ServerConnection.makeSettings({
|
|
134
|
+
baseUrl: baseUrl(resolved), token: accessToken, appendToken: true,
|
|
135
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, init).catch((error: unknown) => {
|
|
136
|
+
// Jupyter may omit CORS headers on auth failures, hiding the HTTP status.
|
|
137
|
+
// Let the next attempt ask again instead of retaining an unusable token.
|
|
138
|
+
forget(name)
|
|
139
|
+
throw error
|
|
140
|
+
}),
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const settingsFor = (name: string) => {
|
|
145
|
+
let known = settings.get(name)
|
|
146
|
+
if (!known) {
|
|
147
|
+
known = connect(name)
|
|
148
|
+
settings.set(name, known)
|
|
149
|
+
known.catch(() => settings.delete(name))
|
|
150
|
+
}
|
|
151
|
+
return known
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const forget = (name: string) => {
|
|
155
|
+
settings.delete(name)
|
|
156
|
+
forgetJupyterToken(name)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const terminals = new Set<TerminalSession>()
|
|
160
|
+
const kernelPools: JupyterKernels[] = []
|
|
161
|
+
const executors = Object.entries(environments).map(([name, spec]): Executor => {
|
|
162
|
+
const makeKernels = () => createJupyterKernels({
|
|
163
|
+
executorId: environmentExecutor(name),
|
|
164
|
+
kernelName: jupyterKernelName(spec),
|
|
165
|
+
languages: languagesOf(spec),
|
|
166
|
+
settings: () => settingsFor(name),
|
|
167
|
+
...(['proxy', 'hub'].includes(authOf(spec)) ? {
|
|
168
|
+
shutdown: (kernel: string, directory: string) => {
|
|
169
|
+
const request = fetch(`${pluginRoutePath(serverClient(), EXEC_JUPYTER_ID)}${cleanupPath(name)}`, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
keepalive: true,
|
|
172
|
+
headers: { 'Content-Type': 'application/json', 'Presmium-Document': encodeURIComponent(proxyDocuments.get(name) ?? '') },
|
|
173
|
+
body: JSON.stringify({ kernel, directory }),
|
|
174
|
+
})
|
|
175
|
+
return request.then(async response => {
|
|
176
|
+
if (!response.ok) throw new Error(`Jupyter cleanup failed (${response.status}): ${await response.text()}`)
|
|
177
|
+
})
|
|
178
|
+
},
|
|
179
|
+
} : {}),
|
|
180
|
+
refused: () => forget(name),
|
|
181
|
+
text: {
|
|
182
|
+
unreachable: (error, code) => messages().unreachable(name, failureText(error, code)),
|
|
183
|
+
get refused() {
|
|
184
|
+
return messages().refused(name)
|
|
185
|
+
},
|
|
186
|
+
noKernel: (kernel, known) => messages().noKernel(name, kernel, known),
|
|
187
|
+
wrongLanguage: (kernel, language, lang) => messages().wrongLanguage(name, kernel, language, lang),
|
|
188
|
+
timedOut: seconds => messages().timedOut(seconds),
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
let kernels = makeKernels()
|
|
192
|
+
let generation: string | undefined
|
|
193
|
+
let preparing: Promise<void> | undefined
|
|
194
|
+
kernelPools.push(kernels)
|
|
195
|
+
return {
|
|
196
|
+
id: environmentExecutor(name),
|
|
197
|
+
supports: lang => kernels.supports(lang),
|
|
198
|
+
run: async (request, signal) => {
|
|
199
|
+
if (disposing || signal?.aborted) return failedRun(environmentExecutor(name), messages().cancelled)
|
|
200
|
+
if (authOf(spec) === 'hub') {
|
|
201
|
+
try {
|
|
202
|
+
if (!preparing) {
|
|
203
|
+
settings.delete(name)
|
|
204
|
+
preparing = settingsFor(name).then(async () => {
|
|
205
|
+
if (disposing) return
|
|
206
|
+
const next = generations.get(name)
|
|
207
|
+
if (generation !== undefined && generation !== next) {
|
|
208
|
+
await kernels.dispose().catch(() => {})
|
|
209
|
+
if (disposing) return
|
|
210
|
+
kernels = makeKernels()
|
|
211
|
+
kernelPools.push(kernels)
|
|
212
|
+
}
|
|
213
|
+
generation = next
|
|
214
|
+
}).finally(() => { preparing = undefined })
|
|
215
|
+
}
|
|
216
|
+
await preparing
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return failedRun(environmentExecutor(name), errorMessage(error))
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (disposing || signal?.aborted) return failedRun(environmentExecutor(name), messages().cancelled)
|
|
222
|
+
return sanitized(await kernels.run(request, signal))
|
|
223
|
+
},
|
|
224
|
+
endSession: session => kernels.endSession(session),
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
const dispose = (): Promise<void> => {
|
|
229
|
+
if (disposing) return disposing
|
|
230
|
+
window.removeEventListener('pagehide', leave)
|
|
231
|
+
// These calls must run before the first await, independently of host disposal order.
|
|
232
|
+
const cleanup = kernelPools.map(kernels => kernels.dispose())
|
|
233
|
+
terminals.forEach(session => session.abandon())
|
|
234
|
+
terminals.clear()
|
|
235
|
+
settings.clear()
|
|
236
|
+
disposing = Promise.all(cleanup).then(() => undefined)
|
|
237
|
+
return disposing
|
|
238
|
+
}
|
|
239
|
+
const leave = () => void dispose().catch(error => console.error('[presmium] Jupyter cleanup failed', error))
|
|
240
|
+
window.addEventListener('pagehide', leave)
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
executors,
|
|
244
|
+
terminals: {
|
|
245
|
+
unavailable,
|
|
246
|
+
async openTerminal(name, events) {
|
|
247
|
+
if (disposing) throw new Error(messages().cancelled)
|
|
248
|
+
if (authOf(environments[name] ?? { kind: 'jupyter' }) === 'hub') settings.delete(name)
|
|
249
|
+
const connection = await settingsFor(name)
|
|
250
|
+
if (disposing) throw new Error(messages().cancelled)
|
|
251
|
+
try {
|
|
252
|
+
const session = await openTerminal(connection, events)
|
|
253
|
+
if (disposing) { await session.close(); throw new Error(messages().cancelled) }
|
|
254
|
+
terminals.add(session)
|
|
255
|
+
return {
|
|
256
|
+
...session,
|
|
257
|
+
close: () => {
|
|
258
|
+
terminals.delete(session)
|
|
259
|
+
return session.close()
|
|
260
|
+
},
|
|
261
|
+
}
|
|
262
|
+
} catch (error) {
|
|
263
|
+
if (isRefused(error)) {
|
|
264
|
+
forget(name)
|
|
265
|
+
throw new Error(messages().refused(name))
|
|
266
|
+
}
|
|
267
|
+
throw error
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
dispose,
|
|
272
|
+
}
|
|
273
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { messages } from './messages.ts'
|
|
2
|
+
|
|
3
|
+
const key = (name: string) => `presmium.execJupyter.token:${name}`
|
|
4
|
+
|
|
5
|
+
function storage(): Storage | undefined {
|
|
6
|
+
try {
|
|
7
|
+
return typeof sessionStorage === 'undefined' ? undefined : sessionStorage
|
|
8
|
+
} catch {
|
|
9
|
+
return undefined
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function storedJupyterToken(name: string): string | undefined {
|
|
14
|
+
return storage()?.getItem(key(name)) ?? undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function forgetJupyterToken(name: string): void {
|
|
18
|
+
storage()?.removeItem(key(name))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const asking = new Map<string, Promise<string | undefined>>()
|
|
22
|
+
|
|
23
|
+
/** The Jupyter token of the environment: the one this tab stored, or else the one the person enters. */
|
|
24
|
+
export function jupyterTokenFor(name: string, origin: string): Promise<string | undefined> {
|
|
25
|
+
const stored = storedJupyterToken(name)
|
|
26
|
+
if (stored) return Promise.resolve(stored)
|
|
27
|
+
let pending = asking.get(name)
|
|
28
|
+
if (!pending) {
|
|
29
|
+
pending = ask(name, origin).then((token) => {
|
|
30
|
+
if (token) storage()?.setItem(key(name), token)
|
|
31
|
+
return token
|
|
32
|
+
}).finally(() => asking.delete(name))
|
|
33
|
+
asking.set(name, pending)
|
|
34
|
+
}
|
|
35
|
+
return pending
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function element<K extends keyof HTMLElementTagNameMap>(tag: K, className: string, text?: string): HTMLElementTagNameMap[K] {
|
|
39
|
+
const node = document.createElement(tag)
|
|
40
|
+
if (className) node.className = className
|
|
41
|
+
if (text !== undefined) node.textContent = text
|
|
42
|
+
return node
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ask(name: string, origin: string): Promise<string | undefined> {
|
|
46
|
+
const t = messages()
|
|
47
|
+
const dialog = element('dialog', 'pm-jupyter-token pm-ui')
|
|
48
|
+
dialog.setAttribute('aria-labelledby', 'pm-jupyter-token-title')
|
|
49
|
+
const header = element('div', 'pm-ui-dialog__header')
|
|
50
|
+
const title = element('h2', 'pm-ui-dialog__title', t.tokenTitle(name))
|
|
51
|
+
title.id = 'pm-jupyter-token-title'
|
|
52
|
+
header.append(title)
|
|
53
|
+
const form = element('form', 'pm-ui-dialog__body')
|
|
54
|
+
form.method = 'dialog'
|
|
55
|
+
const help = element('p', '', t.tokenHelp(origin))
|
|
56
|
+
const label = element('label', 'pm-ui-field')
|
|
57
|
+
const input = element('input', 'pm-ui-input')
|
|
58
|
+
input.type = 'password'
|
|
59
|
+
input.name = 'token'
|
|
60
|
+
input.autocomplete = 'off'
|
|
61
|
+
input.required = true
|
|
62
|
+
label.append(element('span', 'pm-ui-field__label', t.tokenLabel), input)
|
|
63
|
+
const buttons = element('div', '')
|
|
64
|
+
const cancel = element('button', 'pm-ui-button', t.tokenCancel)
|
|
65
|
+
cancel.type = 'button'
|
|
66
|
+
const connect = element('button', 'pm-ui-button pm-ui-button--primary', t.tokenConnect)
|
|
67
|
+
connect.type = 'submit'
|
|
68
|
+
connect.value = 'connect'
|
|
69
|
+
buttons.append(cancel, connect)
|
|
70
|
+
form.append(help, label, buttons)
|
|
71
|
+
dialog.append(header, form)
|
|
72
|
+
Object.assign(dialog.style, { width: 'min(28rem, calc(100vw - 2rem))', padding: '0', border: '1px solid var(--pm-color-line)', borderRadius: 'var(--pm-radius-panel)', background: 'var(--pm-color-surface)', boxShadow: 'var(--pm-shadow-float)' })
|
|
73
|
+
Object.assign(form.style, { display: 'grid', gap: 'var(--pm-space-3)' })
|
|
74
|
+
help.style.margin = '0'
|
|
75
|
+
Object.assign(buttons.style, { display: 'flex', gap: 'var(--pm-space-2)', justifyContent: 'flex-end' })
|
|
76
|
+
document.body.append(dialog)
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
cancel.addEventListener('click', () => dialog.close(''))
|
|
79
|
+
dialog.addEventListener('close', () => {
|
|
80
|
+
const token = dialog.returnValue === 'connect' ? input.value.trim() : ''
|
|
81
|
+
dialog.remove()
|
|
82
|
+
resolve(token || undefined)
|
|
83
|
+
}, { once: true })
|
|
84
|
+
dialog.showModal()
|
|
85
|
+
input.focus()
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { currentLocale } from '@lincyaw/host'
|
|
2
|
+
import type { Catalog } from '@lincyaw/host'
|
|
3
|
+
|
|
4
|
+
const zhCN = {
|
|
5
|
+
unknown: (name: string) => `文稿没有声明 kind: jupyter 的环境 ${name}`,
|
|
6
|
+
envOnly: (name: string) => `环境 ${name} 的凭据方式是 env,只在导出时可用`,
|
|
7
|
+
cancelled: '已取消运行环境连接',
|
|
8
|
+
proxyStatic: (name: string) => `环境 ${name} 经 Presmium 服务端的代理连接,静态页面中不可用`,
|
|
9
|
+
noUrl: (name: string) => `环境 ${name} 没有可用的地址 url`,
|
|
10
|
+
noToken: (name: string) => `没有输入环境 ${name} 的访问令牌`,
|
|
11
|
+
refused: (name: string) => `环境 ${name} 拒绝了访问令牌。请重新运行并输入访问令牌。`,
|
|
12
|
+
unreachable: (name: string, reason: string) => `无法连接环境 ${name}。${reason}`,
|
|
13
|
+
reasons: {
|
|
14
|
+
'jupyter-no-answer': 'Jupyter 服务在 10 秒内没有响应,请稍后重新运行。',
|
|
15
|
+
'jupyter-closed': '与 Jupyter 服务的连接已断开,请重新运行。',
|
|
16
|
+
network: '无法连接 Jupyter 服务,请检查网络与服务地址后重新运行。',
|
|
17
|
+
'hub-not-configured': '此服务尚未配置 JupyterHub 环境。请联系运营者配置后重试。',
|
|
18
|
+
'hub-profile-unavailable': '此环境规格不可用。请在文稿中选择运营者提供的 profile 后重试。',
|
|
19
|
+
'hub-start-failed': '运行环境启动失败。请稍后重试,或联系运营者查看启动诊断。',
|
|
20
|
+
'hub-start-timeout': '运行环境未能及时启动。请稍后重新运行。',
|
|
21
|
+
'hub-unavailable': '暂时无法连接 JupyterHub。请稍后重新运行。',
|
|
22
|
+
'hub-profile-changed': '环境配置已变化,请重新打开当前版本的预览后运行。',
|
|
23
|
+
} as Record<string, string>,
|
|
24
|
+
noKernel: (name: string, kernel: string, known: string[]) => `环境 ${name} 中没有 ${kernel}(可用:${known.join('、') || '无'})`,
|
|
25
|
+
wrongLanguage: (name: string, kernel: string, language: string, lang: string) => `环境 ${name} 中的 ${kernel} 运行 ${language},不是 ${lang}`,
|
|
26
|
+
timedOut: (seconds: number) => `运行超过 ${seconds} 秒,已中断`,
|
|
27
|
+
tokenTitle: (name: string) => `环境 ${name} 的访问令牌`,
|
|
28
|
+
tokenHelp: (origin: string) => `粘贴你的 Jupyter 服务器的访问令牌。它只保存在本页的会话存储中(关闭标签页即清除),只发往 ${origin}。`,
|
|
29
|
+
tokenLabel: '访问令牌',
|
|
30
|
+
tokenConnect: '连接',
|
|
31
|
+
tokenCancel: '取消',
|
|
32
|
+
terminal: (name: string) => `环境 ${name} 的终端`,
|
|
33
|
+
terminalOpen: '打开终端',
|
|
34
|
+
terminalConnecting: '正在连接…',
|
|
35
|
+
terminalClosed: '终端已结束',
|
|
36
|
+
terminalReconnect: '重新打开',
|
|
37
|
+
terminalFailed: (reason: string) => `无法连接终端。${reason}`,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type JupyterMessages = typeof zhCN
|
|
41
|
+
|
|
42
|
+
const en: JupyterMessages = {
|
|
43
|
+
unknown: name => `The document declares no kind: jupyter environment ${name}`,
|
|
44
|
+
envOnly: name => `Environment ${name} authenticates with env, which works only when exporting`,
|
|
45
|
+
cancelled: 'The execution environment connection was canceled',
|
|
46
|
+
proxyStatic: name => `Environment ${name} connects through the Presmium server’s proxy, which a static page does not have`,
|
|
47
|
+
noUrl: name => `Environment ${name} has no usable url`,
|
|
48
|
+
noToken: name => `No access token was given for environment ${name}`,
|
|
49
|
+
refused: name => `Environment ${name} refused the access token. Run again and enter it.`,
|
|
50
|
+
unreachable: (name, reason) => `Couldn’t reach environment ${name}. ${reason}`,
|
|
51
|
+
reasons: {
|
|
52
|
+
'jupyter-no-answer': 'The Jupyter service didn’t answer within 10 seconds. Run again shortly.',
|
|
53
|
+
'jupyter-closed': 'The connection to the Jupyter service closed. Run again.',
|
|
54
|
+
network: 'Couldn’t reach the Jupyter service. Check the network and the service address, then run again.',
|
|
55
|
+
'hub-not-configured': 'JupyterHub environments are not configured on this service. Contact the operator, then try again.',
|
|
56
|
+
'hub-profile-unavailable': 'This environment profile is unavailable. Choose an operator-provided profile in the document, then try again.',
|
|
57
|
+
'hub-start-failed': 'The execution environment failed to start. Try again shortly, or ask the operator to check its startup diagnostics.',
|
|
58
|
+
'hub-start-timeout': 'The execution environment did not start in time. Run again shortly.',
|
|
59
|
+
'hub-unavailable': 'Couldn’t reach JupyterHub. Run again shortly.',
|
|
60
|
+
'hub-profile-changed': 'The environment configuration changed. Reopen the preview of the current version, then run again.',
|
|
61
|
+
},
|
|
62
|
+
noKernel: (name, kernel, known) => `Environment ${name} has no ${kernel} (available: ${known.join(', ') || 'none'})`,
|
|
63
|
+
wrongLanguage: (name, kernel, language, lang) => `${kernel} in environment ${name} runs ${language}, not ${lang}`,
|
|
64
|
+
timedOut: seconds => `Ran longer than ${seconds} s and was interrupted`,
|
|
65
|
+
tokenTitle: name => `Access Token for Environment ${name}`,
|
|
66
|
+
tokenHelp: origin => `Paste the access token of your Jupyter server. It stays in this page’s session storage (cleared when the tab closes) and goes to ${origin} only.`,
|
|
67
|
+
tokenLabel: 'Access token',
|
|
68
|
+
tokenConnect: 'Connect',
|
|
69
|
+
tokenCancel: 'Cancel',
|
|
70
|
+
terminal: name => `Terminal of environment ${name}`,
|
|
71
|
+
terminalOpen: 'Open Terminal',
|
|
72
|
+
terminalConnecting: 'Connecting…',
|
|
73
|
+
terminalClosed: 'The terminal ended',
|
|
74
|
+
terminalReconnect: 'Open Again',
|
|
75
|
+
terminalFailed: reason => `Couldn’t connect the terminal. ${reason}`,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const MESSAGES: Catalog<JupyterMessages> = { 'zh-CN': zhCN, en }
|
|
79
|
+
|
|
80
|
+
export function messages(): JupyterMessages {
|
|
81
|
+
return MESSAGES[currentLocale()]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A reader's sentence for why Jupyter could not be reached; the failure's own words go to the console only. */
|
|
85
|
+
export function failureText(error: string, code?: string): string {
|
|
86
|
+
const t = messages()
|
|
87
|
+
if (code && code in t.reasons) return t.reasons[code]!
|
|
88
|
+
console.warn(`presmium: Jupyter: ${error}`)
|
|
89
|
+
return t.reasons.network!
|
|
90
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { EXECUTOR } from '@lincyaw/kernel'
|
|
2
|
+
import type { PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
|
|
3
|
+
import { version } from '../../package.json'
|
|
4
|
+
import { EXEC_JUPYTER_ID, JUPYTER_TERMINALS, jupyterSpecs } from '../domain/index.ts'
|
|
5
|
+
import { createJupyterClient } from './jupyter-client.ts'
|
|
6
|
+
import type { JupyterClient } from './jupyter-client.ts'
|
|
7
|
+
|
|
8
|
+
let client: JupyterClient | undefined
|
|
9
|
+
let unregister: Unsubscribe[] = []
|
|
10
|
+
|
|
11
|
+
export const execJupyterPlugin: PluginDefinition = {
|
|
12
|
+
manifest: {
|
|
13
|
+
id: EXEC_JUPYTER_ID,
|
|
14
|
+
name: { 'zh-CN': 'Jupyter 运行环境', en: 'Jupyter Environment' },
|
|
15
|
+
version,
|
|
16
|
+
kernel: '^1.0.0',
|
|
17
|
+
provides: [JUPYTER_TERMINALS],
|
|
18
|
+
requires: [{ token: EXECUTOR, optional: true }],
|
|
19
|
+
capabilities: ['exec', 'network'],
|
|
20
|
+
},
|
|
21
|
+
async setup(ctx) {
|
|
22
|
+
await client?.dispose()
|
|
23
|
+
client = createJupyterClient(jupyterSpecs(ctx.environments))
|
|
24
|
+
ctx.provide(JUPYTER_TERMINALS, client.terminals)
|
|
25
|
+
const router = ctx.tryGet(EXECUTOR)
|
|
26
|
+
if (router) unregister = client.executors.map(executor => router.register(executor))
|
|
27
|
+
},
|
|
28
|
+
dispose() {
|
|
29
|
+
unregister.forEach(off => off())
|
|
30
|
+
unregister = []
|
|
31
|
+
const cleanup = client?.dispose()
|
|
32
|
+
client = undefined
|
|
33
|
+
return cleanup
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default execJupyterPlugin
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { languageId } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
const PYTHON_ARG = `def _pm_arg(raw):
|
|
4
|
+
import ast, json
|
|
5
|
+
try:
|
|
6
|
+
return ast.literal_eval(raw)
|
|
7
|
+
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError):
|
|
8
|
+
pass
|
|
9
|
+
try:
|
|
10
|
+
return json.loads(raw)
|
|
11
|
+
except (ValueError, TypeError, RecursionError):
|
|
12
|
+
return raw`
|
|
13
|
+
|
|
14
|
+
/** Code that assigns the run's arguments to variables before its code; in Python each value is read as a literal or as JSON when it is one. */
|
|
15
|
+
export function argsPrelude(args: Record<string, string> | undefined, lang = ''): string | undefined {
|
|
16
|
+
const python = languageId(lang) === 'python'
|
|
17
|
+
const lines = Object.entries(args ?? {})
|
|
18
|
+
.filter(([name]) => /^[A-Za-z_]\w*$/.test(name))
|
|
19
|
+
.map(([name, value]) => `${name} = ${python ? `_pm_arg(${JSON.stringify(String(value))})` : JSON.stringify(String(value))}`)
|
|
20
|
+
if (lines.length === 0) return undefined
|
|
21
|
+
return python ? [PYTHON_ARG, ...lines, 'del _pm_arg'].join('\n') : lines.join('\n')
|
|
22
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { EnvironmentAuth, EnvironmentSpec } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
export const EXEC_JUPYTER_ID = 'presmium.execJupyter'
|
|
5
|
+
|
|
6
|
+
export const JUPYTER_KIND = 'jupyter'
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_JUPYTER_KERNEL = 'python3'
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_TIMEOUT_MS = 60_000
|
|
11
|
+
|
|
12
|
+
export const RUNS_DIR = 'presmium-runs'
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_TERMINAL_ROWS = 16
|
|
15
|
+
|
|
16
|
+
export function envKey(name: string): string {
|
|
17
|
+
return name.toUpperCase().replace(/[^A-Z0-9]/g, '_')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function operatorVariables(name: string): { url: string, token: string } {
|
|
21
|
+
const key = envKey(name)
|
|
22
|
+
return { url: `PRESMIUM_ENV_${key}_URL`, token: `PRESMIUM_ENV_${key}_TOKEN` }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function proxyPath(name: string): string {
|
|
26
|
+
return `env/${envKey(name).toLowerCase()}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function cleanupPath(name: string): string {
|
|
30
|
+
return `cleanup/${envKey(name).toLowerCase()}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function preparePath(name: string): string {
|
|
34
|
+
return `prepare/${envKey(name).toLowerCase()}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function authOf(spec: EnvironmentSpec): EnvironmentAuth {
|
|
38
|
+
const auth = spec.auth
|
|
39
|
+
return auth === 'none' || auth === 'proxy' || auth === 'prompt' || auth === 'hub' ? auth : 'env'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The name of the Jupyter kernel the environment asks for (`kernel` of the headmatter). */
|
|
43
|
+
export function jupyterKernelName(spec: EnvironmentSpec): string {
|
|
44
|
+
return typeof spec.kernel === 'string' && spec.kernel.trim() ? spec.kernel.trim() : DEFAULT_JUPYTER_KERNEL
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function languagesOf(spec: EnvironmentSpec): string[] | undefined {
|
|
48
|
+
return Array.isArray(spec.languages) ? spec.languages.filter((l): l is string => typeof l === 'string') : undefined
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The `kind: jupyter` environments among those of the headmatter. */
|
|
52
|
+
export function jupyterSpecs(environments: unknown): Record<string, EnvironmentSpec> {
|
|
53
|
+
if (!isRecord(environments)) return {}
|
|
54
|
+
return Object.fromEntries(Object.entries(environments).filter((entry): entry is [string, EnvironmentSpec] => isRecord(entry[1]) && entry[1].kind === JUPYTER_KIND))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function originOf(url: string | undefined): string | undefined {
|
|
58
|
+
if (!url) return undefined
|
|
59
|
+
try {
|
|
60
|
+
const parsed = new URL(url)
|
|
61
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.origin : undefined
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function baseUrl(url: string): string {
|
|
68
|
+
return url.endsWith('/') ? url : `${url}/`
|
|
69
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { EnvironmentSpec } from '@lincyaw/kernel'
|
|
2
|
+
import { authOf, baseUrl, operatorVariables, originOf } from './config.ts'
|
|
3
|
+
|
|
4
|
+
export interface Connection {
|
|
5
|
+
url: string
|
|
6
|
+
headers: Record<string, string>
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type OperatorConnection =
|
|
10
|
+
| { connection: Connection }
|
|
11
|
+
| { unavailable: string }
|
|
12
|
+
| { mismatch: string }
|
|
13
|
+
|
|
14
|
+
export interface OperatorSide {
|
|
15
|
+
secret(name: string): string | undefined
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function operatorConnection(name: string, spec: EnvironmentSpec, side: OperatorSide): OperatorConnection {
|
|
19
|
+
const auth = authOf(spec)
|
|
20
|
+
if (auth === 'hub') return { unavailable: 'auth: hub starts an operator-defined profile on explicit browser execution; it is not started by a build or an environment probe' }
|
|
21
|
+
if (auth === 'none') {
|
|
22
|
+
const url = typeof spec.url === 'string' ? spec.url : undefined
|
|
23
|
+
if (!originOf(url)) return { unavailable: 'auth: none connects to the deck\'s url, which is missing or not an http(s) address' }
|
|
24
|
+
return { connection: { url: baseUrl(url!), headers: {} } }
|
|
25
|
+
}
|
|
26
|
+
const variables = operatorVariables(name)
|
|
27
|
+
const url = side.secret(variables.url)
|
|
28
|
+
if (!url) return { unavailable: `${variables.url} is not set: the operator gives the environment's address there, and its token (${variables.token}) is sent to that address only` }
|
|
29
|
+
const origin = originOf(url)
|
|
30
|
+
if (!origin) return { unavailable: `${variables.url} is not an http(s) address` }
|
|
31
|
+
const deck = typeof spec.url === 'string' && spec.url ? spec.url : undefined
|
|
32
|
+
if (deck && originOf(deck) !== origin) {
|
|
33
|
+
return { mismatch: `the deck's url ${deck} is not the operator's address (${variables.url}, ${origin}); the operator's token goes to the operator's address only, so nothing connects. Leave the url out, or write the operator's` }
|
|
34
|
+
}
|
|
35
|
+
const token = side.secret(variables.token)
|
|
36
|
+
return { connection: { url: baseUrl(url), headers: token ? { Authorization: `token ${token}` } : {} } }
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { deckLocale } from '@lincyaw/kernel'
|
|
2
|
+
|
|
3
|
+
const TERMINAL = {
|
|
4
|
+
'zh-CN': (name: string) => `环境 ${name} 的终端:请在网页或幻灯片中打开。`,
|
|
5
|
+
'en': (name: string) => `Terminal of environment ${name}: open it on the web page or in the slides.`,
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function terminalFallback(name: string, lang: string | undefined): string {
|
|
9
|
+
return TERMINAL[deckLocale(lang)](name)
|
|
10
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export {
|
|
2
|
+
authOf,
|
|
3
|
+
baseUrl,
|
|
4
|
+
cleanupPath,
|
|
5
|
+
DEFAULT_JUPYTER_KERNEL,
|
|
6
|
+
DEFAULT_TERMINAL_ROWS,
|
|
7
|
+
DEFAULT_TIMEOUT_MS,
|
|
8
|
+
envKey,
|
|
9
|
+
EXEC_JUPYTER_ID,
|
|
10
|
+
JUPYTER_KIND,
|
|
11
|
+
jupyterKernelName,
|
|
12
|
+
jupyterSpecs,
|
|
13
|
+
languagesOf,
|
|
14
|
+
operatorVariables,
|
|
15
|
+
originOf,
|
|
16
|
+
proxyPath,
|
|
17
|
+
preparePath,
|
|
18
|
+
RUNS_DIR,
|
|
19
|
+
} from './config.ts'
|
|
20
|
+
export { argsPrelude } from './args.ts'
|
|
21
|
+
export { operatorConnection } from './connection.ts'
|
|
22
|
+
export type { Connection, OperatorConnection, OperatorSide } from './connection.ts'
|
|
23
|
+
export { OutputCollector, stripAnsi } from './outputs.ts'
|
|
24
|
+
export { JUPYTER_TERMINALS } from './terminal-service.ts'
|
|
25
|
+
export type { JupyterTerminals, TerminalEvents, TerminalSession } from './terminal-service.ts'
|
|
26
|
+
export { terminalFallback } from './fallback-text.ts'
|