@lincyaw/addon-exec 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/LICENSES +696 -0
- package/README.md +33 -2
- package/components/PmRunPanel.css +167 -0
- package/components/PmRunPanel.tsx +389 -0
- package/components/PmStepRun.css +61 -0
- package/components/PmStepRun.tsx +202 -0
- package/package.json +50 -3
- package/src/client/RunOutputs.css +21 -0
- package/src/client/RunOutputs.tsx +69 -0
- package/src/client/StepMark.css +3 -0
- package/src/client/StepMark.tsx +12 -0
- package/src/client/env.d.ts +13 -0
- package/src/client/feedback.ts +69 -0
- package/src/client/format.ts +30 -0
- package/src/client/index.ts +14 -0
- package/src/client/messages.ts +102 -0
- package/src/client/outputs.ts +29 -0
- package/src/client/plugin.ts +55 -0
- package/src/client/pyodide/executor.ts +94 -0
- package/src/client/pyodide/presmium_gpu.py +297 -0
- package/src/client/pyodide/protocol.ts +27 -0
- package/src/client/pyodide/pyodide.worker.ts +68 -0
- package/src/client/pyodide/run_await.py +57 -0
- package/src/client/runs-in.ts +27 -0
- package/src/client/script/executor.ts +62 -0
- package/src/client/script/script.worker.ts +9 -0
- package/src/client/slide-fit.ts +61 -0
- package/src/client/step-marks.ts +7 -0
- package/src/client/when-service.ts +14 -0
- package/src/domain/browser-python.ts +33 -0
- package/src/domain/config.ts +41 -0
- package/src/domain/index.ts +11 -0
- package/src/domain/outputs.ts +18 -0
- package/src/domain/router.ts +50 -0
- package/src/domain/runs.ts +72 -0
- package/src/domain/static-output.ts +20 -0
- package/src/domain/trace.ts +32 -0
- package/src/node/browser-imports.ts +34 -0
- package/src/node/child-process.ts +109 -0
- package/src/node/code-component.ts +48 -0
- package/src/node/notebook-cells.ts +46 -0
- package/src/node/presmium.ts +27 -0
- package/src/node/pyodide.ts +22 -0
- package/src/node/python.ts +40 -0
- package/src/node/run-panel.ts +71 -0
- package/src/node/script-main.mjs +11 -0
- package/src/node/script.ts +26 -0
- package/src/node/step-run.ts +71 -0
- package/src/node/timeouts.ts +15 -0
- package/src/shared/script-runner.d.mts +16 -0
- package/src/shared/script-runner.mjs +168 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { componentCode, jupyterKernelLanguage, languageId } from '@lincyaw/kernel'
|
|
2
|
+
import type { ComponentNode, IRBlock, MdastNode, RecordedRun, RendererContribution, RenderContext } from '@lincyaw/kernel'
|
|
3
|
+
import { paramsPrelude, shownOutput } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
function lines(text: string): string[] {
|
|
6
|
+
return text ? text.split(/(?<=\n)/) : []
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function notebookOutputs(runs: RecordedRun[]): unknown[] {
|
|
10
|
+
return runs.flatMap<unknown>(run => run.outputs ? run.outputs.map((output) => {
|
|
11
|
+
if (output.output_type === 'stream') return { ...output, text: lines(output.text) }
|
|
12
|
+
if (output.output_type === 'error') return output
|
|
13
|
+
return { ...output, metadata: output.metadata ?? {}, ...output.output_type === 'execute_result' ? { execution_count: null } : {} }
|
|
14
|
+
}) : (['stdout', 'stderr'] as const).flatMap((name) => {
|
|
15
|
+
const text = run[name]
|
|
16
|
+
return text ? [{ output_type: 'stream', name, text: lines(text) }] : []
|
|
17
|
+
}))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function notebookCell(node: ComponentNode, _block: IRBlock, ctx: RenderContext) {
|
|
21
|
+
const source = componentCode(node)
|
|
22
|
+
if (!source) return undefined
|
|
23
|
+
const params = node.props.params as Record<string, unknown> | undefined
|
|
24
|
+
const runs = source.outputs ?? []
|
|
25
|
+
const recorded = typeof node.props.recorded === 'string' ? node.props.recorded : undefined
|
|
26
|
+
const output = shownOutput(runs, recorded)
|
|
27
|
+
if (languageId(source.lang) !== jupyterKernelLanguage(ctx.config.kernel)) {
|
|
28
|
+
const children: MdastNode[] = [{ type: 'code', lang: source.lang, value: paramsPrelude(source.lang, params) + source.value }]
|
|
29
|
+
const text = output.text.trimEnd()
|
|
30
|
+
if (text) children.push({ type: 'code', lang: 'text', value: text })
|
|
31
|
+
return { cell_type: 'markdown' as const, mdast: { type: 'root', children } }
|
|
32
|
+
}
|
|
33
|
+
const outputs = output.recorded ? [{ output_type: 'stream', name: 'stdout', text: lines(output.text) }] : notebookOutputs(runs)
|
|
34
|
+
const finished = runs.length > 0 && runs.every(run => !run.timedOut)
|
|
35
|
+
const metadata = node.props.timeout !== undefined && !finished ? { tags: ['skip-execution'] } : undefined
|
|
36
|
+
return { cell_type: 'code' as const, source: lines(paramsPrelude(source.lang, params) + source.value), outputs, execution_count: null, ...metadata ? { metadata } : {} }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const rendererExtensions: Record<string, RendererContribution> = {
|
|
40
|
+
notebook: {
|
|
41
|
+
components: {
|
|
42
|
+
PmRunPanel: notebookCell,
|
|
43
|
+
PmStepRun: notebookCell,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import type { PresmiumNodePlugin } from '@lincyaw/kernel'
|
|
3
|
+
import { EXEC_ID } from '../domain/index.ts'
|
|
4
|
+
import { browserImportsCheck } from './browser-imports.ts'
|
|
5
|
+
import { rendererExtensions } from './notebook-cells.ts'
|
|
6
|
+
import { pythonExecutor } from './python.ts'
|
|
7
|
+
import { runPanelDirective, runPanelFallbackPass, runPanelPass } from './run-panel.ts'
|
|
8
|
+
import { scriptExecutor } from './script.ts'
|
|
9
|
+
import { stepCountPass, stepRunDirective } from './step-run.ts'
|
|
10
|
+
import { timeoutPass } from './timeouts.ts'
|
|
11
|
+
import { pyodideAssets } from './pyodide.ts'
|
|
12
|
+
|
|
13
|
+
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
14
|
+
|
|
15
|
+
const plugin: PresmiumNodePlugin = {
|
|
16
|
+
id: EXEC_ID,
|
|
17
|
+
version,
|
|
18
|
+
kernel: '^1.0.0',
|
|
19
|
+
directives: [runPanelDirective, stepRunDirective],
|
|
20
|
+
passes: [timeoutPass, runPanelPass, stepCountPass, runPanelFallbackPass],
|
|
21
|
+
checks: [browserImportsCheck],
|
|
22
|
+
executors: [pythonExecutor, scriptExecutor],
|
|
23
|
+
rendererExtensions,
|
|
24
|
+
assets: () => [pyodideAssets()],
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default plugin
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { RuntimeAssets } from '@lincyaw/kernel'
|
|
5
|
+
|
|
6
|
+
const PYODIDE_DIR = 'pyodide/'
|
|
7
|
+
const LICENSES = 'LICENSES'
|
|
8
|
+
const LICENSES_FILE = fileURLToPath(new URL('../../LICENSES', import.meta.url))
|
|
9
|
+
const TYPES: Record<string, string> = {
|
|
10
|
+
'pyodide.mjs': 'text/javascript',
|
|
11
|
+
'pyodide.asm.mjs': 'text/javascript',
|
|
12
|
+
'pyodide.asm.wasm': 'application/wasm',
|
|
13
|
+
'python_stdlib.zip': 'application/zip',
|
|
14
|
+
'pyodide-lock.json': 'application/json',
|
|
15
|
+
[LICENSES]: 'text/plain; charset=utf-8',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Pyodide's runtime, at `<base>pyodide/`. */
|
|
19
|
+
export function pyodideAssets(): RuntimeAssets {
|
|
20
|
+
const dir = dirname(createRequire(import.meta.url).resolve('pyodide/package.json'))
|
|
21
|
+
return { name: 'presmium:pyodide-runtime', prefix: PYODIDE_DIR, types: TYPES, source: file => (file === LICENSES ? LICENSES_FILE : join(dir, file)) }
|
|
22
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { createRequire } from 'node:module'
|
|
5
|
+
import type { Executor } from '@lincyaw/kernel'
|
|
6
|
+
import { isPython } from '../domain/index.ts'
|
|
7
|
+
import { createChildProcessRuns } from './child-process.ts'
|
|
8
|
+
|
|
9
|
+
const PYTHON_EXECUTOR_ID = 'presmium.python'
|
|
10
|
+
|
|
11
|
+
const PYTHON = 'python3'
|
|
12
|
+
const RUNNER = readFileSync(createRequire(import.meta.url).resolve('@lincyaw/kernel/runner.py'), 'utf8')
|
|
13
|
+
const RUNNER_REVISION = `runner ${createHash('sha256').update(RUNNER).digest('hex').slice(0, 16)}`
|
|
14
|
+
|
|
15
|
+
let probe: { version: string, prefixes: string[] } | undefined
|
|
16
|
+
|
|
17
|
+
function python(): { version: string, prefixes: string[] } {
|
|
18
|
+
if (!probe) {
|
|
19
|
+
const found = spawnSync(PYTHON, ['-c', 'import sys; print(sys.version.split()[0]); print(sys.prefix); print(sys.base_prefix)'], { encoding: 'utf8' })
|
|
20
|
+
const [version, ...prefixes] = found.status === 0 ? found.stdout.trim().split('\n') : []
|
|
21
|
+
probe = version ? { version: `Python ${version}`, prefixes: [...new Set(prefixes)] } : { version: '', prefixes: [] }
|
|
22
|
+
}
|
|
23
|
+
return probe
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const runs = createChildProcessRuns({
|
|
27
|
+
executorId: PYTHON_EXECUTOR_ID,
|
|
28
|
+
argv: [PYTHON, '-c', `${RUNNER}\n_pm_main()\n`],
|
|
29
|
+
read: () => python().prefixes,
|
|
30
|
+
sessions: true,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const pythonExecutor: Executor = {
|
|
34
|
+
id: PYTHON_EXECUTOR_ID,
|
|
35
|
+
supports: lang => isPython(lang) && python().version !== '',
|
|
36
|
+
fingerprint: () => python().version,
|
|
37
|
+
revision: () => RUNNER_REVISION,
|
|
38
|
+
run: runs.run,
|
|
39
|
+
endSession: runs.endSession,
|
|
40
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { componentCode, docBlocks } from '@lincyaw/kernel'
|
|
2
|
+
import type { DirectiveSpec, IRPass, MdastNode } from '@lincyaw/kernel'
|
|
3
|
+
import { EXEC_ID, paramsPrelude, shownOutput } from '../domain/index.ts'
|
|
4
|
+
import { codeComponent } from './code-component.ts'
|
|
5
|
+
|
|
6
|
+
export const runPanelDirective: DirectiveSpec = {
|
|
7
|
+
name: 'run-panel',
|
|
8
|
+
arg: true,
|
|
9
|
+
body: true,
|
|
10
|
+
options: { params: 'string', timeout: 'number', recorded: 'string', run: 'string' },
|
|
11
|
+
toIR(input, ctx) {
|
|
12
|
+
const when = input.options.run === undefined ? 'both' : String(input.options.run).trim()
|
|
13
|
+
if (when !== 'both' && when !== 'live') ctx.report({ checkId: 'presmium.exec.syntax', severity: 'warning', message: `{run-panel} :run: ${when} is not both or live; using both` })
|
|
14
|
+
const node = codeComponent('PmRunPanel', input, ctx, { when: when === 'live' ? 'live' : 'both' })
|
|
15
|
+
const recorded = input.options.recorded
|
|
16
|
+
if (typeof recorded === 'string') {
|
|
17
|
+
node.props.recorded = recorded
|
|
18
|
+
node.fallback?.children?.push({ type: 'code', lang: 'text', value: recorded })
|
|
19
|
+
}
|
|
20
|
+
return { node }
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Gives each run panel its place in the document and its code's executor and session, which its runs send. */
|
|
25
|
+
export const runPanelPass: IRPass = {
|
|
26
|
+
name: 'presmium.exec.panels',
|
|
27
|
+
order: 10,
|
|
28
|
+
run(doc, ctx) {
|
|
29
|
+
for (const { section, block: { id, node, source, stepStates } } of docBlocks(doc)) {
|
|
30
|
+
if (node.type !== 'component' || node.plugin !== EXEC_ID || !['PmRunPanel', 'PmStepRun'].includes(node.name)) continue
|
|
31
|
+
const code = componentCode(node)
|
|
32
|
+
if (!code || node.children?.filter(child => child.node.type === 'code').length !== 1) {
|
|
33
|
+
ctx.report({ checkId: 'presmium.exec', severity: 'error', message: `${node.name} requires exactly one canonical code child.`, source })
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
node.props.code = code.value
|
|
37
|
+
node.props.lang = code.lang
|
|
38
|
+
delete node.props.compiledOutput
|
|
39
|
+
for (const state of stepStates ?? []) if (state.parameters) {
|
|
40
|
+
for (const key of ['code', 'lang', 'place', 'executor', 'session', 'compiledOutput']) delete state.parameters[key]
|
|
41
|
+
}
|
|
42
|
+
node.props.place = { section: section.id, block: id }
|
|
43
|
+
const run = code.run
|
|
44
|
+
if (run?.executor) node.props.executor = run.executor
|
|
45
|
+
else delete node.props.executor
|
|
46
|
+
if (run?.session) node.props.session = run.session
|
|
47
|
+
else delete node.props.session
|
|
48
|
+
node.fallback = { type: 'root', children: [{ type: 'code', lang: code.lang, value: code.value }, ...typeof node.props.recorded === 'string' ? [{ type: 'code', lang: 'text', value: node.props.recorded }] : []] }
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** After the compile-time runs: the panel's fallback shows its code with the output, and a run that succeeded gives `compiledOutput`. */
|
|
54
|
+
export const runPanelFallbackPass: IRPass = {
|
|
55
|
+
name: 'presmium.exec.fallbacks',
|
|
56
|
+
after: 'execute',
|
|
57
|
+
run(doc) {
|
|
58
|
+
for (const { block: { node } } of docBlocks(doc)) {
|
|
59
|
+
if (node.type !== 'component' || node.plugin !== EXEC_ID || node.name !== 'PmRunPanel') continue
|
|
60
|
+
const code = componentCode(node)
|
|
61
|
+
if (!code) continue
|
|
62
|
+
const recorded = typeof node.props.recorded === 'string' ? node.props.recorded : undefined
|
|
63
|
+
const output = shownOutput(code.outputs ?? [], recorded).text.trimEnd()
|
|
64
|
+
const children: MdastNode[] = [{ type: 'code', lang: code.lang, value: paramsPrelude(code.lang, node.props.params as Record<string, unknown>) + code.value }]
|
|
65
|
+
if (output) children.push({ type: 'code', lang: 'text', value: output })
|
|
66
|
+
node.fallback = { type: 'root', children }
|
|
67
|
+
const runs = code.outputs ?? []
|
|
68
|
+
if (runs.length > 0 && runs.every(run => run.ok)) node.props.compiledOutput = shownOutput(runs, undefined).text
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { runScript } from '../shared/script-runner.mjs'
|
|
3
|
+
|
|
4
|
+
const [requestFile, resultPrefix] = process.argv.slice(2)
|
|
5
|
+
const { runs } = JSON.parse(readFileSync(requestFile, 'utf8'))
|
|
6
|
+
for (const [i, run] of runs.entries()) {
|
|
7
|
+
const result = await runScript({ code: run.code, args: run.args, typescript: /^(ts|typescript)$/i.test(run.lang ?? '') })
|
|
8
|
+
writeFileSync(`${resultPrefix}${i}.part`, JSON.stringify(result))
|
|
9
|
+
renameSync(`${resultPrefix}${i}.part`, `${resultPrefix}${i}.json`)
|
|
10
|
+
}
|
|
11
|
+
process.exit(0)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import type { Executor } from '@lincyaw/kernel'
|
|
5
|
+
import { RUN_REQUEST_FILE, RUN_RESULT_PREFIX } from '@lincyaw/kernel/node'
|
|
6
|
+
import { isScript } from '../domain/index.ts'
|
|
7
|
+
import { createChildProcessRuns } from './child-process.ts'
|
|
8
|
+
|
|
9
|
+
export const NODE_SCRIPT_EXECUTOR_ID = 'presmium.node'
|
|
10
|
+
|
|
11
|
+
const MAIN = fileURLToPath(new URL('./script-main.mjs', import.meta.url))
|
|
12
|
+
const READ = [fileURLToPath(new URL('../..', import.meta.url)), dirname(createRequire(import.meta.url).resolve('sucrase/package.json')), dirname(dirname(process.execPath))]
|
|
13
|
+
|
|
14
|
+
const runs = createChildProcessRuns({
|
|
15
|
+
executorId: NODE_SCRIPT_EXECUTOR_ID,
|
|
16
|
+
argv: [process.execPath, MAIN, RUN_REQUEST_FILE, RUN_RESULT_PREFIX],
|
|
17
|
+
read: () => READ,
|
|
18
|
+
sessions: false,
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const scriptExecutor: Executor = {
|
|
22
|
+
id: NODE_SCRIPT_EXECUTOR_ID,
|
|
23
|
+
supports: isScript,
|
|
24
|
+
fingerprint: () => `node ${process.version}`,
|
|
25
|
+
run: runs.run,
|
|
26
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { componentCode, docBlocks, PROCESS_SANDBOX } from '@lincyaw/kernel'
|
|
2
|
+
import type { DirectiveSpec, IRPass, ProcessSandbox } from '@lincyaw/kernel'
|
|
3
|
+
import { minimalRunEnv } from '@lincyaw/kernel/node'
|
|
4
|
+
import { configuredTimeout, EXEC_ID, execConfig, isPython, stringArgs } from '../domain/index.ts'
|
|
5
|
+
import { codeComponent } from './code-component.ts'
|
|
6
|
+
import { pythonExecutor } from './python.ts'
|
|
7
|
+
|
|
8
|
+
export const stepRunDirective: DirectiveSpec = {
|
|
9
|
+
name: 'step-run',
|
|
10
|
+
arg: true,
|
|
11
|
+
body: true,
|
|
12
|
+
options: { steps: 'number', params: 'string', timeout: 'number' },
|
|
13
|
+
toIR(input, ctx) {
|
|
14
|
+
const node = codeComponent('PmStepRun', input, ctx)
|
|
15
|
+
const steps = Number(input.options.steps)
|
|
16
|
+
if (!Number.isInteger(steps) || steps < 1) {
|
|
17
|
+
ctx.report({ checkId: 'presmium.exec.syntax', severity: 'warning', message: '{step-run} needs :steps:, the number of lines the code executes; without it the slide has no steps' })
|
|
18
|
+
return { node }
|
|
19
|
+
}
|
|
20
|
+
node.props.steps = steps
|
|
21
|
+
return { node, span: steps }
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const traced = new Map<string, Promise<number | undefined>>()
|
|
26
|
+
|
|
27
|
+
function traceLength(code: string, args: Record<string, string>, maxEvents: number, timeoutMs: number, sandbox: ProcessSandbox | undefined): Promise<number | undefined> {
|
|
28
|
+
const key = JSON.stringify([code, args, maxEvents])
|
|
29
|
+
let found = traced.get(key)
|
|
30
|
+
if (!found) {
|
|
31
|
+
const program = `import __main__\nprint(__main__._pm_trace(${JSON.stringify(code)}, ${JSON.stringify(JSON.stringify(args))}, '', '{}', ${maxEvents}))\n`
|
|
32
|
+
found = pythonExecutor.run({ lang: 'python', code: program, timeoutMs }, undefined, { env: minimalRunEnv(), ...sandbox ? { sandbox } : {} }).then((result) => {
|
|
33
|
+
if (!result.ok) return undefined
|
|
34
|
+
try {
|
|
35
|
+
const trace = JSON.parse(result.stdout) as { events: unknown[], truncated: boolean }
|
|
36
|
+
return trace.truncated ? undefined : trace.events.length
|
|
37
|
+
} catch {
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
traced.set(key, found)
|
|
42
|
+
}
|
|
43
|
+
return found
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Warns when a step run declares another number of steps than the lines its code executes. */
|
|
47
|
+
export const stepCountPass: IRPass = {
|
|
48
|
+
name: 'presmium.exec.steps',
|
|
49
|
+
order: 20,
|
|
50
|
+
async run(doc, ctx) {
|
|
51
|
+
if (!pythonExecutor.supports('python')) return
|
|
52
|
+
const { maxTraceEvents } = execConfig(ctx.config)
|
|
53
|
+
const sandbox = ctx.service?.(PROCESS_SANDBOX)
|
|
54
|
+
for (const { section, block } of docBlocks(doc)) {
|
|
55
|
+
const { node } = block
|
|
56
|
+
if (node.type !== 'component' || node.plugin !== EXEC_ID || node.name !== 'PmStepRun' || typeof node.props.steps !== 'number') continue
|
|
57
|
+
const code = componentCode(node)
|
|
58
|
+
if (!code || !isPython(code.lang)) continue
|
|
59
|
+
const timeoutMs = (typeof node.props.timeout === 'number' ? node.props.timeout : undefined) ?? configuredTimeout(ctx.configFor(section)) ?? execConfig(ctx.config).timeoutMs
|
|
60
|
+
const lines = await traceLength(code.value, stringArgs(node.props.params as Record<string, unknown> | undefined), maxTraceEvents, timeoutMs, sandbox)
|
|
61
|
+
if (lines === undefined || lines === node.props.steps) continue
|
|
62
|
+
ctx.report({
|
|
63
|
+
checkId: 'presmium.exec',
|
|
64
|
+
severity: 'warning',
|
|
65
|
+
message: `{step-run} declares :steps: ${node.props.steps}, but its code executes ${lines} lines: the slide's steps do not match the lines`,
|
|
66
|
+
hint: `write :steps: ${lines}`,
|
|
67
|
+
source: { file: block.source.file, line: block.source.line },
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { docBlocks } from '@lincyaw/kernel'
|
|
2
|
+
import type { IRPass } from '@lincyaw/kernel'
|
|
3
|
+
import { configuredTimeout } from '../domain/index.ts'
|
|
4
|
+
|
|
5
|
+
/** Code blocks without their own timeout take the one their section configures. */
|
|
6
|
+
export const timeoutPass: IRPass = {
|
|
7
|
+
name: 'presmium.exec.timeouts',
|
|
8
|
+
run(doc, ctx) {
|
|
9
|
+
for (const { section, block: { node } } of docBlocks(doc)) {
|
|
10
|
+
if (node.type !== 'code' || !node.run || node.run.timeoutMs !== undefined) continue
|
|
11
|
+
const timeout = configuredTimeout(ctx.configFor(section))
|
|
12
|
+
if (timeout) node.run.timeoutMs = timeout
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface ScriptRequest {
|
|
2
|
+
code: string
|
|
3
|
+
args?: Record<string, string>
|
|
4
|
+
typescript?: boolean
|
|
5
|
+
imports?: Record<string, string>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ScriptOutput {
|
|
9
|
+
stdout: string
|
|
10
|
+
stderr: string
|
|
11
|
+
exitCode: number
|
|
12
|
+
outputs: { output_type: 'stream', name: 'stdout' | 'stderr', text: string }[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function prepare(request: ScriptRequest): string
|
|
16
|
+
export function runScript(request: ScriptRequest): Promise<ScriptOutput>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { transform } from 'sucrase'
|
|
2
|
+
|
|
3
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/
|
|
4
|
+
|
|
5
|
+
function show(value) {
|
|
6
|
+
if (typeof value === 'string') return value
|
|
7
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`
|
|
8
|
+
if (typeof value === 'function') return `[Function ${value.name || '(anonymous)'}]`
|
|
9
|
+
if (typeof value === 'bigint') return `${value}n`
|
|
10
|
+
try {
|
|
11
|
+
return JSON.stringify(value) ?? String(value)
|
|
12
|
+
} catch {
|
|
13
|
+
return String(value)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function failure(error) {
|
|
18
|
+
const line = error instanceof Error ? /^.*data:text\/javascript.*?:(\d+):\d+\)?$/m.exec(error.stack ?? '')?.[1] : undefined
|
|
19
|
+
return `${show(error)}${line ? ` (line ${line})` : ''}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function literal(raw) {
|
|
23
|
+
try {
|
|
24
|
+
JSON.parse(raw)
|
|
25
|
+
return raw
|
|
26
|
+
} catch {
|
|
27
|
+
return JSON.stringify(raw)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])([^'"\n]+)\2/g
|
|
32
|
+
|
|
33
|
+
export function prepare({ code, args = {}, typescript = false, imports = {} }) {
|
|
34
|
+
const js = typescript ? transform(code, { transforms: ['typescript'], disableESTransforms: true }).code : code
|
|
35
|
+
const constants = Object.entries(args).filter(([name]) => IDENTIFIER.test(name)).map(([name, raw]) => `const ${name} = ${literal(String(raw))};`)
|
|
36
|
+
const mapped = js.replace(SPECIFIER, (match, keyword, quote, name) => Object.hasOwn(imports, name) ? `${keyword}${quote}${imports[name]}${quote}` : match)
|
|
37
|
+
return constants.join(' ') + mapped
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function trackTimers() {
|
|
41
|
+
const native = { setTimeout, clearTimeout, setInterval, clearInterval }
|
|
42
|
+
const { setTimeout: later, clearTimeout: cancel, setInterval: every } = native
|
|
43
|
+
const pending = new Set()
|
|
44
|
+
let armed = false
|
|
45
|
+
let failed
|
|
46
|
+
let settle
|
|
47
|
+
const done = new Promise((resolve, reject) => settle = { resolve, reject })
|
|
48
|
+
const decide = () => {
|
|
49
|
+
if (failed) settle.reject(failed.reason)
|
|
50
|
+
else if (pending.size === 0) settle.resolve()
|
|
51
|
+
}
|
|
52
|
+
const check = () => later(() => {
|
|
53
|
+
if (!armed) return
|
|
54
|
+
if (failed || pending.size > 0) return decide()
|
|
55
|
+
later(decide)
|
|
56
|
+
})
|
|
57
|
+
const fail = (reason) => {
|
|
58
|
+
failed ??= { reason }
|
|
59
|
+
check()
|
|
60
|
+
}
|
|
61
|
+
const call = (callback, args) => {
|
|
62
|
+
try {
|
|
63
|
+
const result = typeof callback === 'function' ? callback(...args) : undefined
|
|
64
|
+
if (typeof result?.then === 'function') result.then(undefined, fail)
|
|
65
|
+
} catch (error) {
|
|
66
|
+
fail(error)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const clear = (id) => {
|
|
70
|
+
pending.delete(id)
|
|
71
|
+
cancel(id)
|
|
72
|
+
check()
|
|
73
|
+
}
|
|
74
|
+
Object.assign(globalThis, {
|
|
75
|
+
setTimeout(callback, delay, ...args) {
|
|
76
|
+
const id = later(() => {
|
|
77
|
+
pending.delete(id)
|
|
78
|
+
call(callback, args)
|
|
79
|
+
check()
|
|
80
|
+
}, delay)
|
|
81
|
+
pending.add(id)
|
|
82
|
+
return id
|
|
83
|
+
},
|
|
84
|
+
setInterval(callback, delay, ...args) {
|
|
85
|
+
const id = every(() => call(callback, args), delay)
|
|
86
|
+
pending.add(id)
|
|
87
|
+
return id
|
|
88
|
+
},
|
|
89
|
+
clearTimeout: clear,
|
|
90
|
+
clearInterval: clear,
|
|
91
|
+
})
|
|
92
|
+
const node = typeof globalThis.process?.on === 'function' ? globalThis.process : undefined
|
|
93
|
+
const onNode = reason => fail(reason)
|
|
94
|
+
const onWorker = (event) => {
|
|
95
|
+
event.preventDefault()
|
|
96
|
+
fail(event.reason)
|
|
97
|
+
}
|
|
98
|
+
if (node) node.on('unhandledRejection', onNode)
|
|
99
|
+
else globalThis.addEventListener?.('unhandledrejection', onWorker)
|
|
100
|
+
return {
|
|
101
|
+
idle() {
|
|
102
|
+
armed = true
|
|
103
|
+
check()
|
|
104
|
+
return done
|
|
105
|
+
},
|
|
106
|
+
restore() {
|
|
107
|
+
for (const id of pending) cancel(id)
|
|
108
|
+
Object.assign(globalThis, native)
|
|
109
|
+
if (node) node.off('unhandledRejection', onNode)
|
|
110
|
+
else globalThis.removeEventListener?.('unhandledrejection', onWorker)
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function collector() {
|
|
116
|
+
const text = { stdout: [], stderr: [] }
|
|
117
|
+
const outputs = []
|
|
118
|
+
return {
|
|
119
|
+
text,
|
|
120
|
+
outputs,
|
|
121
|
+
stream: name => ({
|
|
122
|
+
push(chunk) {
|
|
123
|
+
text[name].push(chunk)
|
|
124
|
+
const last = outputs.at(-1)
|
|
125
|
+
if (last?.name === name) last.text += chunk
|
|
126
|
+
else outputs.push({ output_type: 'stream', name, text: chunk })
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function captureStreams(out, err) {
|
|
133
|
+
const streams = [[globalThis.process?.stdout, out], [globalThis.process?.stderr, err]].filter(([stream]) => typeof stream?.write === 'function')
|
|
134
|
+
const saved = streams.map(([stream]) => [stream, stream.write])
|
|
135
|
+
for (const [stream, target] of streams) {
|
|
136
|
+
stream.write = (chunk, encoding, callback) => {
|
|
137
|
+
target.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk))
|
|
138
|
+
const done = typeof encoding === 'function' ? encoding : callback
|
|
139
|
+
if (typeof done === 'function') queueMicrotask(done)
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return () => saved.forEach(([stream, write]) => stream.write = write)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function runScript(request) {
|
|
147
|
+
const output = collector()
|
|
148
|
+
const out = output.stream('stdout')
|
|
149
|
+
const err = output.stream('stderr')
|
|
150
|
+
const write = target => (...values) => void target.push(`${values.map(show).join(' ')}\n`)
|
|
151
|
+
const saved = { log: console.log, info: console.info, debug: console.debug, dir: console.dir, warn: console.warn, error: console.error }
|
|
152
|
+
Object.assign(console, { log: write(out), info: write(out), debug: write(out), dir: value => write(out)(value), warn: write(err), error: write(err) })
|
|
153
|
+
const restoreStreams = captureStreams(out, err)
|
|
154
|
+
const timers = trackTimers()
|
|
155
|
+
let exitCode = 0
|
|
156
|
+
try {
|
|
157
|
+
await import(/* @vite-ignore */ `data:text/javascript;charset=utf-8,${encodeURIComponent(prepare(request))}`)
|
|
158
|
+
await timers.idle()
|
|
159
|
+
} catch (error) {
|
|
160
|
+
err.push(`${failure(error)}\n`)
|
|
161
|
+
exitCode = 1
|
|
162
|
+
} finally {
|
|
163
|
+
timers.restore()
|
|
164
|
+
restoreStreams()
|
|
165
|
+
Object.assign(console, saved)
|
|
166
|
+
}
|
|
167
|
+
return { stdout: output.text.stdout.join(''), stderr: output.text.stderr.join(''), exitCode, outputs: output.outputs }
|
|
168
|
+
}
|