diff --git a/spx-gui/build-wasm.sh b/spx-gui/build-wasm.sh index fc1a4b230..bfbffec9f 100755 --- a/spx-gui/build-wasm.sh +++ b/spx-gui/build-wasm.sh @@ -14,3 +14,7 @@ cp ../tools/spxls/spxls-pkgdata.zip src/assets/wasm/spxls-pkgdata.zip # Build and copy ispx.wasm ( cd ../tools/ispx && ./build.sh ) cp ../tools/ispx/ispx.wasm src/assets/wasm/ispx.wasm + +# Build and copy the generic XGo executor. +( cd ../tools/xgoexec && bash build.sh ) +cp ../tools/xgoexec/xgoexec.wasm src/assets/wasm/xgoexec.wasm diff --git a/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue b/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue new file mode 100644 index 000000000..9cecd4a51 --- /dev/null +++ b/spx-gui/src/apps/xbuilder/pages/debug/xgo-executor.vue @@ -0,0 +1,176 @@ + + + diff --git a/spx-gui/src/apps/xbuilder/router.ts b/spx-gui/src/apps/xbuilder/router.ts index fbe4b8687..ae69fa274 100644 --- a/spx-gui/src/apps/xbuilder/router.ts +++ b/spx-gui/src/apps/xbuilder/router.ts @@ -118,6 +118,10 @@ const routes: Array = [ path: '/tutorials', component: () => import('./pages/tutorials/index.vue') }, + { + path: '/debug/xgo-executor', + component: () => import('./pages/debug/xgo-executor.vue') + }, { path: '/course/:courseSeriesIdInput/:courseIdInput/start', component: () => import('./pages/tutorials/course-start.vue'), diff --git a/spx-gui/src/utils/xgo-executor/core/executor.ts b/spx-gui/src/utils/xgo-executor/core/executor.ts new file mode 100644 index 000000000..d5f35cf3e --- /dev/null +++ b/spx-gui/src/utils/xgo-executor/core/executor.ts @@ -0,0 +1,187 @@ +import type { MainMessage, WorkerMessage, XGoErrorPhase, XGoExitReason } from './protocol' + +/** A JSON-compatible request/response handler. Returning a Promise makes the XGo call wait asynchronously. */ +export type XGoCapability = (request: unknown) => unknown | Promise + +/** A framework name and its explicitly allowed UI capabilities. */ +export type XGoFramework = { name: string; capabilities: Record } + +export type XGoExecutorOptions = { + /** `null` runs plain XGo; otherwise the name selects the Go-side binding and capabilities bind it to the UI. */ + framework: XGoFramework | null + /** Receives failures using the phases documented by `XGoErrorPhase`. */ + onError: (phase: XGoErrorPhase, message: string) => void + /** Receives lines written by the XGo program, including `echo` output. */ + onOutput?: (message: string) => void + /** Fires exactly once when execution completes, is stopped, or exits because of an error. */ + onExit: (reason: XGoExitReason) => void +} + +interface WorkerHandler { + postMessage(message: MainMessage): void + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void + addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void + terminate(): void +} + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (reason: unknown) => void +} + +/** + * Runs one XGo project at a time in a dedicated Web Worker/WASM runtime. + * Call `run` again after `onExit` for sequential projects. Use a separate executor for a different framework or for + * concurrent execution; separate executors are isolated but each loads its own WASM runtime. + * + * The dedicated worker keeps XGo compilation and interpretation off the UI thread and isolates the Go bridge globals + * used by each WASM runtime. The tradeoff is one WASM instance per concurrent executor; a shared worker would require + * the Go bridge to address multiple runtimes and is a possible optimization if this cost becomes significant. + */ +export class XGoExecutor { + private worker: WorkerHandler | null = null + private state: 'idle' | 'starting' | 'running' = 'idle' + private exitDeferred: Deferred | null = null + private nextEventCallId = 0 + private pendingEventCalls = new Map>() + + constructor(private options: XGoExecutorOptions) {} + + /** Builds and starts a project. The promise resolves when execution starts; completion is reported through `onExit`. */ + async run(files: Record): Promise { + if (this.state !== 'idle') throw new Error('XGo executor is already running') + this.state = 'starting' + const worker: WorkerHandler = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' }) + const ready = createDeferred() + const started = createDeferred() + this.exitDeferred = createDeferred() + this.worker = worker + worker.addEventListener('message', (event) => this.handleMessage(worker, event.data, ready, started)) + worker.addEventListener('error', (event) => { + const error = new Error(event.message) + this.options.onError('initialization', error.message) + ready.reject(error) + started.reject(error) + this.finish(worker, 'error') + }) + await ready.promise + worker.postMessage({ + type: 'run', + framework: this.options.framework?.name ?? '', + files: encodeFiles(files) + }) + await started.promise + } + + /** Requests cancellation and resolves after `onExit('stopped')` has been emitted. */ + async stop(): Promise { + const worker = this.worker + const exitDeferred = this.exitDeferred + if (worker == null || exitDeferred == null) return + if (this.state === 'starting') { + this.finish(worker, 'stopped') + return + } + worker.postMessage({ type: 'stop' }) + await exitDeferred.promise + } + + /** Dispatches a framework event and resolves once the running program has accepted it. */ + async dispatchEvent(name: string, payload: unknown): Promise { + const worker = this.worker + if (worker == null || this.state !== 'running') throw new Error('XGo executor is not running') + const id = ++this.nextEventCallId + const deferred = createDeferred() + this.pendingEventCalls.set(id, deferred) + try { + worker.postMessage({ type: 'event', id, name, payload }) + } catch (error) { + this.pendingEventCalls.delete(id) + throw error + } + await deferred.promise + } + + private handleMessage(worker: WorkerHandler, message: WorkerMessage, ready: Deferred, started: Deferred) { + if (worker !== this.worker) return + switch (message.type) { + case 'ready': + ready.resolve() + break + case 'started': + this.state = 'running' + started.resolve() + break + case 'error': { + const error = new Error(`${message.phase}: ${message.message}`) + this.options.onError(message.phase, message.message) + if (message.phase === 'initialization') ready.reject(error) + else if (this.state === 'starting') started.reject(error) + break + } + case 'exit': + this.finish(worker, message.reason) + break + case 'output': + this.options.onOutput?.(message.message) + break + case 'capabilityCall': + void this.handleCapabilityCall(worker, message.id, message.name, message.request) + break + case 'eventResult': { + const call = this.pendingEventCalls.get(message.id) + if (call == null) break + this.pendingEventCalls.delete(message.id) + if (message.error == null) call.resolve() + else call.reject(new Error(message.error)) + break + } + } + } + + private async handleCapabilityCall(worker: WorkerHandler, id: number, name: string, request: unknown) { + try { + const capability = this.options.framework?.capabilities[name] + if (capability == null) throw new Error(`unsupported capability: ${name}`) + const result = await capability(request) + if (worker !== this.worker) return + worker.postMessage({ type: 'capabilityCallResult', id, result, error: null }) + } catch (error) { + if (worker !== this.worker) return + const message = toErrorMessage(error) + this.options.onError('capability', message) + worker.postMessage({ type: 'capabilityCallResult', id, result: null, error: message }) + } + } + + private finish(worker: WorkerHandler, reason: XGoExitReason) { + if (worker !== this.worker) return + worker.terminate() + this.worker = null + this.state = 'idle' + for (const call of this.pendingEventCalls.values()) call.reject(new Error(`XGo executor exited: ${reason}`)) + this.pendingEventCalls.clear() + this.options.onExit(reason) + this.exitDeferred?.resolve() + this.exitDeferred = null + } +} + +function createDeferred(): Deferred { + let resolvePromise: (value: T) => void = () => {} + let rejectPromise: (reason: unknown) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} + +function encodeFiles(files: Record): Record { + return Object.fromEntries(Object.entries(files).map(([path, content]) => [path, new TextEncoder().encode(content)])) +} + +function toErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} diff --git a/spx-gui/src/utils/xgo-executor/core/protocol.ts b/spx-gui/src/utils/xgo-executor/core/protocol.ts new file mode 100644 index 000000000..bd0d25ba6 --- /dev/null +++ b/spx-gui/src/utils/xgo-executor/core/protocol.ts @@ -0,0 +1,44 @@ +/** + * `initialization`: worker/WASM startup; `configuration`: framework selection; `build`: XGo compilation; + * `runtime`: interpreted program execution; `capability`: the UI-side capability handler or RPC transport. + */ +export type XGoErrorPhase = 'initialization' | 'configuration' | 'build' | 'runtime' | 'capability' + +/** The terminal state reported exactly once for every started executor worker. */ +export type XGoExitReason = 'completed' | 'stopped' | 'error' + +export type RunMessage = { + type: 'run' + framework: string + files: Record +} + +export type StopMessage = { type: 'stop' } + +export type CapabilityCallResultMessage = { + type: 'capabilityCallResult' + id: number + result: unknown + error: string | null +} + +export type EventMessage = { type: 'event'; id: number; name: string; payload: unknown } + +export type MainMessage = RunMessage | StopMessage | CapabilityCallResultMessage | EventMessage + +export type ReadyMessage = { type: 'ready' } +export type StartedMessage = { type: 'started' } +export type ErrorMessage = { type: 'error'; phase: XGoErrorPhase; message: string } +export type ExitMessage = { type: 'exit'; reason: XGoExitReason } +export type OutputMessage = { type: 'output'; message: string } +export type CapabilityCallMessage = { type: 'capabilityCall'; id: number; name: string; request: unknown } +export type EventResultMessage = { type: 'eventResult'; id: number; error: string | null } + +export type WorkerMessage = + | ReadyMessage + | StartedMessage + | ErrorMessage + | ExitMessage + | OutputMessage + | CapabilityCallMessage + | EventResultMessage diff --git a/spx-gui/src/utils/xgo-executor/core/worker.ts b/spx-gui/src/utils/xgo-executor/core/worker.ts new file mode 100644 index 000000000..7d6b7a8cb --- /dev/null +++ b/spx-gui/src/utils/xgo-executor/core/worker.ts @@ -0,0 +1,163 @@ +/// + +import '@/assets/wasm/wasm_exec.js' + +import type { MainMessage, WorkerMessage, XGoErrorPhase, XGoExitReason } from './protocol' + +declare const self: DedicatedWorkerGlobalScope +declare class Go { + importObject: WebAssembly.Imports + run(instance: WebAssembly.Instance): Promise +} + +const wasmUrl = new URL('@/assets/wasm/xgoexec.wasm', import.meta.url).href + +interface XGoExecutorBridge { + /** Configures the only project in this WASM runtime. An empty name selects plain XGo. */ + xbuilder_xgoexec_configure(framework: string): Promise + /** Builds all project files. It must run after configure and before run. */ + xbuilder_xgoexec_build(files: Record): Promise + /** Starts the built program and resolves after startup, not after program exit. */ + xbuilder_xgoexec_run(): Promise + /** Requests cancellation of the running program. */ + xbuilder_xgoexec_stop(): Promise + /** Dispatches a UI event to the framework code currently listening for it. */ + xbuilder_xgoexec_dispatch_event(name: string, payload: string): Promise + /** Delivers an asynchronous capability call result back to the blocked XGo call. */ + xbuilder_xgoexec_resolve_capability_call(id: number, result: string, error: string): void + /** Called once all bridge methods above are installed by Go. */ + xbuilder_xgoexec_ready(): void + /** Reports interpreter failures. The Go bridge currently emits the `runtime` phase. */ + xbuilder_xgoexec_error(phase: 'runtime', message: string): void + /** Reports natural completion, cancellation, or runtime failure. */ + xbuilder_xgoexec_exit(reason: XGoExitReason): void + /** Starts a capability call. The result is returned through `xbuilder_xgoexec_resolve_capability_call`. */ + xbuilder_xgoexec_capability_call(id: number, name: string, request: string): void +} + +interface WorkerScope { + postMessage(message: WorkerMessage): void + addEventListener(type: 'message', listener: (event: MessageEvent) => void): void +} + +const scope: WorkerScope = self +const bridge = globalThis as unknown as XGoExecutorBridge +const workerConsole = globalThis.console +const logToConsole = workerConsole.log.bind(workerConsole) +let exited = false +let started = false +let pendingExit: XGoExitReason | null = null + +workerConsole.log = (...values) => { + scope.postMessage({ type: 'output', message: values.map(String).join(' ') }) + logToConsole(...values) +} + +bridge.xbuilder_xgoexec_ready = () => scope.postMessage({ type: 'ready' }) +bridge.xbuilder_xgoexec_error = (phase, message) => scope.postMessage({ type: 'error', phase, message }) +bridge.xbuilder_xgoexec_exit = (reason) => { + if (started) emitExit(reason) + else pendingExit = reason +} +bridge.xbuilder_xgoexec_capability_call = (id, name, request) => { + scope.postMessage({ type: 'capabilityCall', id, name, request: JSON.parse(request) }) +} + +async function runProject(message: Extract) { + try { + await bridge.xbuilder_xgoexec_configure(message.framework) + } catch (error) { + fail('configuration', error) + return + } + try { + await bridge.xbuilder_xgoexec_build(message.files) + } catch (error) { + fail('build', error) + return + } + try { + await bridge.xbuilder_xgoexec_run() + started = true + scope.postMessage({ type: 'started' }) + if (pendingExit != null) emitExit(pendingExit) + } catch (error) { + fail('runtime', error) + } +} + +async function stopProject() { + try { + await bridge.xbuilder_xgoexec_stop() + emitExit('stopped') + } catch (error) { + fail('runtime', error) + } +} + +async function dispatchEvent(message: Extract) { + try { + await bridge.xbuilder_xgoexec_dispatch_event(message.name, JSON.stringify(message.payload ?? null)) + scope.postMessage({ type: 'eventResult', id: message.id, error: null }) + } catch (error) { + scope.postMessage({ type: 'eventResult', id: message.id, error: toErrorMessage(error) }) + } +} + +function resolveCapabilityCall(message: Extract) { + let result = 'null' + let error = message.error ?? '' + if (error === '') { + try { + result = JSON.stringify(message.result ?? null) + } catch (reason) { + error = toErrorMessage(reason) + } + } + bridge.xbuilder_xgoexec_resolve_capability_call(message.id, result, error) +} + +function fail(phase: XGoErrorPhase, error: unknown) { + scope.postMessage({ type: 'error', phase, message: toErrorMessage(error) }) + emitExit('error') +} + +function emitExit(reason: XGoExitReason) { + if (exited) return + exited = true + scope.postMessage({ type: 'exit', reason }) +} + +function toErrorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +scope.addEventListener('message', (event) => { + const message = event.data + switch (message.type) { + case 'run': + void runProject(message) + break + case 'stop': + void stopProject() + break + case 'capabilityCallResult': + resolveCapabilityCall(message) + break + case 'event': + void dispatchEvent(message) + break + } +}) + +async function main() { + try { + const go = new Go() + const { instance } = await WebAssembly.instantiateStreaming(fetch(wasmUrl), go.importObject) + void go.run(instance).catch((error) => fail('initialization', error)) + } catch (error) { + fail('initialization', error) + } +} + +void main() diff --git a/spx-gui/src/utils/xgo-executor/frameworks/tutorial.ts b/spx-gui/src/utils/xgo-executor/frameworks/tutorial.ts new file mode 100644 index 000000000..ad6b03564 --- /dev/null +++ b/spx-gui/src/utils/xgo-executor/frameworks/tutorial.ts @@ -0,0 +1,24 @@ +import type { XGoExecutor, XGoFramework } from '../core/executor' + +export type TutorialProgress = { completed: number; total: number } + +export type TutorialCapabilities = { + showMessage: (content: string) => void | Promise + readCode: () => string | Promise + setProgress: (progress: TutorialProgress) => void | Promise +} + +export function createTutorialFramework(capabilities: TutorialCapabilities): XGoFramework { + return { + name: 'tutorial', + capabilities: { + showMessage: (request) => capabilities.showMessage((request as { content: string }).content), + readCode: async () => ({ code: await capabilities.readCode() }), + setProgress: (request) => capabilities.setProgress(request as TutorialProgress) + } + } +} + +export function dispatchTutorialSubmit(executor: XGoExecutor, submission: string): Promise { + return executor.dispatchEvent('submit', { submission }) +} diff --git a/spx-gui/src/utils/xgo-executor/index.ts b/spx-gui/src/utils/xgo-executor/index.ts new file mode 100644 index 000000000..35f1af4b8 --- /dev/null +++ b/spx-gui/src/utils/xgo-executor/index.ts @@ -0,0 +1,8 @@ +export { XGoExecutor, type XGoCapability, type XGoExecutorOptions, type XGoFramework } from './core/executor' +export type { XGoErrorPhase, XGoExitReason } from './core/protocol' +export { + createTutorialFramework, + dispatchTutorialSubmit, + type TutorialCapabilities, + type TutorialProgress +} from './frameworks/tutorial' diff --git a/tools/xgoexec/build.sh b/tools/xgoexec/build.sh new file mode 100644 index 000000000..c79aa8703 --- /dev/null +++ b/tools/xgoexec/build.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +go generate ./... +GOOS=js GOARCH=wasm go build -trimpath -ldflags "-s -w" -o xgoexec.wasm diff --git a/tools/xgoexec/core/capability.go b/tools/xgoexec/core/capability.go new file mode 100644 index 000000000..544eb4267 --- /dev/null +++ b/tools/xgoexec/core/capability.go @@ -0,0 +1,62 @@ +package core + +import ( + "encoding/json" + "fmt" + "sync" + "sync/atomic" +) + +type capabilityCallResponse struct { + result []byte + err error +} + +var nextCapabilityCallID atomic.Uint64 +var pendingCapabilityCalls sync.Map + +func CallCapability(name string, request, result any) error { + requestJSON, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode capability %q request: %w", name, err) + } + id := nextCapabilityCallID.Add(1) + response := make(chan capabilityCallResponse, 1) + pendingCapabilityCalls.Store(id, response) + if err := dispatchCapabilityCall(id, name, string(requestJSON)); err != nil { + pendingCapabilityCalls.Delete(id) + return err + } + value := <-response + if value.err != nil { + return value.err + } + if result == nil { + return nil + } + if err := json.Unmarshal(value.result, result); err != nil { + return fmt.Errorf("decode capability %q result: %w", name, err) + } + return nil +} + +func ResolveCapabilityCall(id uint64, result, errorMessage string) { + value, ok := pendingCapabilityCalls.LoadAndDelete(id) + if !ok { + return + } + response := capabilityCallResponse{result: []byte(result)} + if errorMessage != "" { + response.err = fmt.Errorf("capability failed: %s", errorMessage) + } + value.(chan capabilityCallResponse) <- response +} + +func RejectPendingCapabilityCalls(errorMessage string) { + pendingCapabilityCalls.Range(func(key, value any) bool { + if _, loaded := pendingCapabilityCalls.LoadAndDelete(key); loaded { + value.(chan capabilityCallResponse) <- capabilityCallResponse{err: fmt.Errorf("capability failed: %s", errorMessage)} + } + return true + }) +} diff --git a/tools/xgoexec/core/capability_js.go b/tools/xgoexec/core/capability_js.go new file mode 100644 index 000000000..c60d541a2 --- /dev/null +++ b/tools/xgoexec/core/capability_js.go @@ -0,0 +1,10 @@ +//go:build js && wasm + +package core + +import "syscall/js" + +func dispatchCapabilityCall(id uint64, name, request string) error { + js.Global().Call("xbuilder_xgoexec_capability_call", id, name, request) + return nil +} diff --git a/tools/xgoexec/core/capability_stub.go b/tools/xgoexec/core/capability_stub.go new file mode 100644 index 000000000..aa5481dac --- /dev/null +++ b/tools/xgoexec/core/capability_stub.go @@ -0,0 +1,9 @@ +//go:build !js || !wasm + +package core + +import "fmt" + +func dispatchCapabilityCall(id uint64, name, request string) error { + return fmt.Errorf("capability %q is only available in js/wasm", name) +} diff --git a/tools/xgoexec/core/event.go b/tools/xgoexec/core/event.go new file mode 100644 index 000000000..1254f86bc --- /dev/null +++ b/tools/xgoexec/core/event.go @@ -0,0 +1,54 @@ +package core + +import ( + "context" + "encoding/json" + "fmt" + "sync" +) + +type EventHandler func(payload json.RawMessage) error + +var eventState struct { + sync.RWMutex + handlers map[string]EventHandler + runContext context.Context +} + +// RegisterEventHandler connects a UI-originated event to framework code. +// Handlers should enqueue work for the program goroutine instead of entering the interpreter concurrently. +func RegisterEventHandler(name string, handler EventHandler) { + eventState.Lock() + defer eventState.Unlock() + if eventState.handlers == nil { + eventState.handlers = make(map[string]EventHandler) + } + eventState.handlers[name] = handler +} + +// DispatchEvent delivers one JSON event payload to the handler registered by the running project. +func DispatchEvent(name string, payload []byte) error { + eventState.RLock() + handler := eventState.handlers[name] + eventState.RUnlock() + if handler == nil { + return fmt.Errorf("event %q has no registered handler", name) + } + return handler(payload) +} + +// CurrentRunContext lets framework event loops stop with the project runtime. +func CurrentRunContext() context.Context { + eventState.RLock() + defer eventState.RUnlock() + if eventState.runContext == nil { + return context.Background() + } + return eventState.runContext +} + +func setCurrentRunContext(ctx context.Context) { + eventState.Lock() + defer eventState.Unlock() + eventState.runContext = ctx +} diff --git a/tools/xgoexec/core/framework.go b/tools/xgoexec/core/framework.go new file mode 100644 index 000000000..0a115fc3f --- /dev/null +++ b/tools/xgoexec/core/framework.go @@ -0,0 +1,5 @@ +package core + +type Framework interface { + Register() error +} diff --git a/tools/xgoexec/core/mapfs.go b/tools/xgoexec/core/mapfs.go new file mode 100644 index 000000000..864193c72 --- /dev/null +++ b/tools/xgoexec/core/mapfs.go @@ -0,0 +1,61 @@ +package core + +import ( + "fmt" + "io/fs" + "path" + "strings" +) + +type MapFS map[string][]byte + +func (p MapFS) ReadFile(name string) ([]byte, error) { + value, ok := p[name] + if !ok { + return nil, fs.ErrNotExist + } + return value, nil +} + +func (p MapFS) ReadDir(dirname string) ([]fs.DirEntry, error) { + prefix := "" + if dirname != "." { + prefix = dirname + "/" + } + entries := map[string]bool{} + for filename := range p { + if !strings.HasPrefix(filename, prefix) { + continue + } + name := strings.TrimPrefix(filename, prefix) + if i := strings.IndexByte(name, '/'); i >= 0 { + entries[name[:i]] = true + } else { + entries[name] = false + } + } + result := make([]fs.DirEntry, 0, len(entries)) + for name, isDir := range entries { + result = append(result, mapDirEntry{name: name, dir: isDir}) + } + return result, nil +} + +func (p MapFS) Join(elem ...string) string { return path.Join(elem...) } +func (p MapFS) Base(filename string) string { return path.Base(filename) } +func (p MapFS) Abs(filename string) (string, error) { return path.Join("/", filename), nil } + +type mapDirEntry struct { + name string + dir bool +} + +func (p mapDirEntry) Name() string { return p.name } +func (p mapDirEntry) IsDir() bool { return p.dir } +func (p mapDirEntry) Type() fs.FileMode { + if p.dir { + return fs.ModeDir + } + return 0 +} +func (p mapDirEntry) Info() (fs.FileInfo, error) { return nil, fmt.Errorf("not implemented") } diff --git a/tools/xgoexec/core/runtime.go b/tools/xgoexec/core/runtime.go new file mode 100644 index 000000000..687a8b117 --- /dev/null +++ b/tools/xgoexec/core/runtime.go @@ -0,0 +1,124 @@ +package core + +import ( + "context" + "fmt" + "sync" + + "github.com/goplus/ixgo" + "github.com/goplus/ixgo/xgobuild" +) + +type ErrorReporter func(phase, message string) +type ExitReporter func(reason string) + +type RuntimeHooks struct { + Error ErrorReporter + Exit ExitReporter +} + +type Runtime struct { + sync.Mutex + ctx *ixgo.Context + interp *ixgo.Interp + cancel context.CancelFunc + frameworks map[string]Framework + hooks RuntimeHooks +} + +func NewRuntime(frameworks map[string]Framework, hooks RuntimeHooks) *Runtime { + return &Runtime{frameworks: frameworks, hooks: hooks} +} + +func (p *Runtime) Configure(name string) error { + p.Lock() + defer p.Unlock() + if p.ctx != nil { + return fmt.Errorf("already configured") + } + if name != "" { + framework, ok := p.frameworks[name] + if !ok { + return fmt.Errorf("unknown framework: %s", name) + } + if err := framework.Register(); err != nil { + return err + } + } + ctx := ixgo.NewContext(ixgo.SupportMultipleInterp | xgobuild.StaticLoad) + ctx.SetPanic(func(info *ixgo.PanicInfo) { p.hooks.Error("runtime", info.Error.Error()) }) + for _, path := range StandardPackages { + if _, err := ctx.Loader.Import(path); err != nil { + return fmt.Errorf("import standard package %q: %w", path, err) + } + } + p.ctx = ctx + return nil +} + +func (p *Runtime) Build(files map[string][]byte) error { + p.Lock() + defer p.Unlock() + if p.ctx == nil { + return fmt.Errorf("not configured") + } + if p.cancel != nil { + return fmt.Errorf("executor is running") + } + source, err := xgobuild.BuildFSDir(p.ctx, MapFS(files), ".") + if err != nil { + return err + } + pkg, err := p.ctx.LoadFile("main.go", source) + if err != nil { + return err + } + interp, err := p.ctx.NewInterp(pkg) + if err != nil { + return err + } + p.interp = interp + return nil +} + +func (p *Runtime) Run() error { + p.Lock() + if p.ctx == nil || p.interp == nil { + p.Unlock() + return fmt.Errorf("not built") + } + if p.cancel != nil { + p.Unlock() + return fmt.Errorf("executor is running") + } + ctx, interp := p.ctx, p.interp + runCtx, cancel := context.WithCancel(context.Background()) + setCurrentRunContext(runCtx) + p.cancel = cancel + p.Unlock() + go func() { + ctx.RunContext = runCtx + _, err := ctx.RunInterp(interp, "main.go", nil) + reason := "completed" + if runCtx.Err() != nil { + reason = "stopped" + } else if err != nil { + reason = "error" + p.hooks.Error("runtime", err.Error()) + } + p.Lock() + p.cancel = nil + p.Unlock() + p.hooks.Exit(reason) + }() + return nil +} + +func (p *Runtime) Stop() { + p.Lock() + defer p.Unlock() + if p.cancel != nil { + p.cancel() + RejectPendingCapabilityCalls("executor stopped") + } +} diff --git a/tools/xgoexec/core/stdlib.go b/tools/xgoexec/core/stdlib.go new file mode 100644 index 000000000..eef325bc6 --- /dev/null +++ b/tools/xgoexec/core/stdlib.go @@ -0,0 +1,17 @@ +package core + +// StandardPackages lists the standard packages visible to XGo compilation. +// Each package must also be linked into the WASM binary by a blank import in embedded_pkgs.go. +var StandardPackages = []string{ + "bytes", + "encoding/json", + "errors", + "fmt", + "io", + "math", + "math/rand", + "sort", + "strconv", + "strings", + "time", +} diff --git a/tools/xgoexec/embedded_pkgs.go b/tools/xgoexec/embedded_pkgs.go new file mode 100644 index 000000000..006a3d458 --- /dev/null +++ b/tools/xgoexec/embedded_pkgs.go @@ -0,0 +1,20 @@ +package main + +//go:generate go tool qexp -outdir internal/pkg github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework + +// These blank imports register executable package implementations with ixgo. +// The standard-library entries must stay aligned with core.StandardPackages. +import ( + _ "github.com/goplus/builder/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework" + _ "github.com/goplus/ixgo/pkg/bytes" + _ "github.com/goplus/ixgo/pkg/encoding/json" + _ "github.com/goplus/ixgo/pkg/errors" + _ "github.com/goplus/ixgo/pkg/fmt" + _ "github.com/goplus/ixgo/pkg/io" + _ "github.com/goplus/ixgo/pkg/math" + _ "github.com/goplus/ixgo/pkg/math/rand" + _ "github.com/goplus/ixgo/pkg/sort" + _ "github.com/goplus/ixgo/pkg/strconv" + _ "github.com/goplus/ixgo/pkg/strings" + _ "github.com/goplus/ixgo/pkg/time" +) diff --git a/tools/xgoexec/frameworks/tutorial/binding/binding.go b/tools/xgoexec/frameworks/tutorial/binding/binding.go new file mode 100644 index 000000000..d1eb1f0ca --- /dev/null +++ b/tools/xgoexec/frameworks/tutorial/binding/binding.go @@ -0,0 +1,19 @@ +package tutorialbinding + +import ( + "github.com/goplus/ixgo/xgobuild" + "github.com/goplus/mod/modfile" +) + +const Name = "tutorial" + +type Binding struct{} + +func (Binding) Register() error { + xgobuild.RegisterProject(&modfile.Project{ + Ext: "_course.gox", + Class: "Course", + PkgPaths: []string{"github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework"}, + }) + return nil +} diff --git a/tools/xgoexec/frameworks/tutorial/framework/tutorial.go b/tools/xgoexec/frameworks/tutorial/framework/tutorial.go new file mode 100644 index 000000000..80b54fe90 --- /dev/null +++ b/tools/xgoexec/frameworks/tutorial/framework/tutorial.go @@ -0,0 +1,99 @@ +package tutorial + +import ( + "encoding/json" + "fmt" + + "github.com/goplus/builder/tools/xgoexec/core" +) + +const GopPackage = true + +type Course struct { + onStart func() + onSubmit func(string) + submissions chan string +} + +type CourseProto interface { + MainEntry() + Start() +} + +type messageRequest struct { + Content string `json:"content"` +} + +type codeResult struct { + Code string `json:"code"` +} + +type submissionEvent struct { + Submission string `json:"submission"` +} + +type progressRequest struct { + Completed int `json:"completed"` + Total int `json:"total"` +} + +func (p *Course) OnStart(handler func()) { p.onStart = handler } + +func (p *Course) OnSubmit(handler func(string)) { + p.onSubmit = handler + p.submissions = make(chan string, 1) + core.RegisterEventHandler("submit", func(payload json.RawMessage) error { + var event submissionEvent + if err := json.Unmarshal(payload, &event); err != nil { + return err + } + select { + case p.submissions <- event.Submission: + return nil + default: + return fmt.Errorf("submit event queue is full") + } + }) +} + +func (p *Course) Start() { + if p.onStart != nil { + p.onStart() + } + if p.onSubmit == nil { + return + } + for { + select { + case submission := <-p.submissions: + p.onSubmit(submission) + case <-core.CurrentRunContext().Done(): + return + } + } +} + +func (p *Course) ShowMessage(content string) { + mustCallCapability("showMessage", messageRequest{Content: content}, nil) +} + +func (p *Course) ReadCode() string { + var result codeResult + mustCallCapability("readCode", nil, &result) + return result.Code +} + +func (p *Course) SetProgress(completed, total int) { + mustCallCapability("setProgress", progressRequest{Completed: completed, Total: total}, nil) +} + +func Gopt_Course_Main(course CourseProto) { + course.MainEntry() + course.Start() +} + +func mustCallCapability(name string, request, result any) { + if err := core.CallCapability(name, request, result); err != nil { + panic(err) + } +} diff --git a/tools/xgoexec/go.mod b/tools/xgoexec/go.mod new file mode 100644 index 000000000..5c3ceef5c --- /dev/null +++ b/tools/xgoexec/go.mod @@ -0,0 +1,22 @@ +module github.com/goplus/builder/tools/xgoexec + +go 1.25.0 + +tool github.com/goplus/ixgo/cmd/qexp + +require ( + github.com/goplus/ixgo v1.1.0 + github.com/goplus/mod v0.20.2 +) + +require ( + github.com/goplus/gogen v1.23.0-pre.3.0.20260414234848-6641c10c9d6f // indirect + github.com/goplus/reflectx v1.7.0 // indirect + github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2 // indirect + github.com/qiniu/x v1.17.0 // indirect + github.com/timandy/routine v1.1.5 // indirect + github.com/visualfc/funcval v0.1.4 // indirect + github.com/visualfc/xtype v0.3.0 // indirect + golang.org/x/mod v0.29.0 // indirect + golang.org/x/tools v0.38.0 // indirect +) diff --git a/tools/xgoexec/go.sum b/tools/xgoexec/go.sum new file mode 100644 index 000000000..61cf3d4b0 --- /dev/null +++ b/tools/xgoexec/go.sum @@ -0,0 +1,40 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/goplus/gogen v1.23.0-pre.3.0.20260414234848-6641c10c9d6f h1:qb3VcmljqvrIgEVbs83cbZkckszvtHl3OnO/ums8Zoc= +github.com/goplus/gogen v1.23.0-pre.3.0.20260414234848-6641c10c9d6f/go.mod h1:Y7ulYW3wonQ3d9er00b0uGFEV/IUZa6okWJZh892ACQ= +github.com/goplus/ixgo v1.1.0 h1:Lrp3xTzN+xUsrb7H3bIsGMHna1GTmoCrljDhFBF8OOs= +github.com/goplus/ixgo v1.1.0/go.mod h1:fhd/bwm497c66k7hZJJ2WmmvPiKEAJUQR3f2oQiAANQ= +github.com/goplus/mod v0.20.2 h1:YX72E6AhhRLvlkVnI9cBK6PZvUwtge2hwROh7w9N6Yk= +github.com/goplus/mod v0.20.2/go.mod h1:lWW62tH7L3Vm42Lr6wlUMYHvsm5w3TkEpE2ulKTgmU8= +github.com/goplus/reflectx v1.7.0 h1:52l0Si+vDc6CvT7Y+h47h2QUs5HQp6qxPlmvLsMzo38= +github.com/goplus/reflectx v1.7.0/go.mod h1:EXX8KSPTmnb2W1PfyVkDC0Qwet0lYX2h2/S06Vhwrhw= +github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2 h1:kXIGYlJUTii+7nv1XTKR0ctMZSeTcgYMoQuEexZPr7A= +github.com/goplus/xgo v1.7.2-0.20260414235301-df19f4a1b7c2/go.mod h1:fX3+ZaYEzBE6qkgFcJx83D6DbUo/vMO8Y1Allydy2ws= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qiniu/x v1.17.0 h1:OsyDKXzYp5vw9Hc7VAe4Cso1Sp50fLKGsBuDteyTevE= +github.com/qiniu/x v1.17.0/go.mod h1:AiovSOCaRijaf3fj+0CBOpR1457pn24b0Vdb1JpwhII= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/timandy/routine v1.1.5 h1:LSpm7Iijwb9imIPlucl4krpr2EeCeAUvifiQ9Uf5X+M= +github.com/timandy/routine v1.1.5/go.mod h1:kXslgIosdY8LW0byTyPnenDgn4/azt2euufAq9rK51w= +github.com/visualfc/funcval v0.1.4 h1:lAI88zQYfRzmC7mKF4+swXeCZvb8wb1f3lMSDRAY2mQ= +github.com/visualfc/funcval v0.1.4/go.mod h1:3Izv+irhArmrTvy+lmL6pIq16gSOzx73CIka51J9eR0= +github.com/visualfc/goembed v0.3.4 h1:foORJ0KT/pKwA23dStPFOtxaW7F96PnNSiLXHVXrfVk= +github.com/visualfc/goembed v0.3.4/go.mod h1:jCVCz/yTJGyslo6Hta+pYxWWBuq9ADCcIVZBTQ0/iVI= +github.com/visualfc/xtype v0.3.0 h1:K0Oo5XMcSjv+ohqn8L6RjgOZ9dNwfzPv3/AIV1SXRfA= +github.com/visualfc/xtype v0.3.0/go.mod h1:VYIH9S2bmdWKlBb7c725ES6yKF9+pyHBU2SFNqGVMGM= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework/export.go b/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework/export.go new file mode 100755 index 000000000..c25baaa1b --- /dev/null +++ b/tools/xgoexec/internal/pkg/github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework/export.go @@ -0,0 +1,39 @@ +// export by github.com/goplus/ixgo/cmd/qexp + +package tutorial + +import ( + q "github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework" + + "go/constant" + "reflect" + + "github.com/goplus/ixgo" +) + +func init() { + ixgo.RegisterPackage(&ixgo.Package{ + Name: "tutorial", + Path: "github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/framework", + Deps: map[string]string{ + "encoding/json": "json", + "fmt": "fmt", + "github.com/goplus/builder/tools/xgoexec/core": "core", + }, + Interfaces: map[string]reflect.Type{ + "CourseProto": reflect.TypeOf((*q.CourseProto)(nil)).Elem(), + }, + NamedTypes: map[string]reflect.Type{ + "Course": reflect.TypeOf((*q.Course)(nil)).Elem(), + }, + AliasTypes: map[string]reflect.Type{}, + Vars: map[string]reflect.Value{}, + Funcs: map[string]reflect.Value{ + "Gopt_Course_Main": reflect.ValueOf(q.Gopt_Course_Main), + }, + TypedConsts: map[string]ixgo.TypedConst{}, + UntypedConsts: map[string]ixgo.UntypedConst{ + "GopPackage": {Typ: "untyped bool", Value: constant.MakeBool(bool(q.GopPackage))}, + }, + }) +} diff --git a/tools/xgoexec/main.go b/tools/xgoexec/main.go new file mode 100644 index 000000000..8bf2aa26b --- /dev/null +++ b/tools/xgoexec/main.go @@ -0,0 +1,102 @@ +//go:build js && wasm + +package main + +import ( + "fmt" + "syscall/js" + + "github.com/goplus/builder/tools/xgoexec/core" + tutorialbinding "github.com/goplus/builder/tools/xgoexec/frameworks/tutorial/binding" +) + +var runtime = core.NewRuntime( + map[string]core.Framework{tutorialbinding.Name: tutorialbinding.Binding{}}, + core.RuntimeHooks{ + Error: func(phase, message string) { js.Global().Call("xbuilder_xgoexec_error", phase, message) }, + Exit: func(reason string) { js.Global().Call("xbuilder_xgoexec_exit", reason) }, + }, +) + +func init() { + js.Global().Set("xbuilder_xgoexec_configure", js.FuncOf(configure)) + js.Global().Set("xbuilder_xgoexec_build", js.FuncOf(build)) + js.Global().Set("xbuilder_xgoexec_run", js.FuncOf(run)) + js.Global().Set("xbuilder_xgoexec_stop", js.FuncOf(stop)) + js.Global().Set("xbuilder_xgoexec_dispatch_event", js.FuncOf(dispatchEvent)) + js.Global().Set("xbuilder_xgoexec_resolve_capability_call", js.FuncOf(resolveCapabilityCall)) + js.Global().Call("xbuilder_xgoexec_ready") +} + +func configure(this js.Value, args []js.Value) any { + return newPromise(func() error { + name := "" + if len(args) > 0 { + name = args[0].String() + } + return runtime.Configure(name) + }) +} + +func build(this js.Value, args []js.Value) any { + return newPromise(func() error { + if len(args) == 0 { + return fmt.Errorf("missing files") + } + files := map[string][]byte{} + keys := js.Global().Get("Object").Call("keys", args[0]) + for i := 0; i < keys.Length(); i++ { + name := keys.Index(i).String() + value := args[0].Get(name) + data := make([]byte, value.Length()) + js.CopyBytesToGo(data, value) + files[name] = data + } + return runtime.Build(files) + }) +} + +func run(this js.Value, args []js.Value) any { + return newPromise(runtime.Run) +} + +func stop(this js.Value, args []js.Value) any { + return newPromise(func() error { + runtime.Stop() + return nil + }) +} + +func dispatchEvent(this js.Value, args []js.Value) any { + return newPromise(func() error { + if len(args) < 2 { + return fmt.Errorf("missing event name or payload") + } + return core.DispatchEvent(args[0].String(), []byte(args[1].String())) + }) +} + +func resolveCapabilityCall(this js.Value, args []js.Value) any { + if len(args) < 3 { + return nil + } + core.ResolveCapabilityCall(uint64(args[0].Int()), args[1].String(), args[2].String()) + return nil +} + +func newPromise(action func() error) js.Value { + executor := js.FuncOf(func(this js.Value, args []js.Value) any { + resolve, reject := args[0], args[1] + if err := action(); err != nil { + reject.Invoke(js.Global().Get("Error").New(err.Error())) + } else { + resolve.Invoke(js.Undefined()) + } + return nil + }) + promise := js.Global().Get("Promise").New(executor) + executor.Release() + return promise +} + +func main() { select {} }