diff --git a/build.mk b/build.mk index b708c5cd..71d2b1c9 100644 --- a/build.mk +++ b/build.mk @@ -1,4 +1,6 @@ TOOLS ?= ./tools +PUBLIC_DIR ?= $(UI)/public +WEBWORKER_PKG ?= ./cmd/webworker .PHONY: clean clean: @@ -25,8 +27,14 @@ build-ui: @echo "- Building UI..." cd $(UI) && yarn build +.PHONY:build-webworker +build-webworker: + @echo "Building Go Webworker module..." && \ + GOOS=js GOARCH=wasm go build -o $(PUBLIC_DIR)/worker.wasm $(WEBWORKER_PKG) && \ + cp "$$(go env GOROOT)/misc/wasm/wasm_exec.js" $(PUBLIC_DIR) + .PHONY: build -build: clean preinstall collect-meta build-server build-ui +build: clean preinstall collect-meta build-server build-webworker build-ui @echo "- Copying assets..." cp -rf ./data $(TARGET)/data mv $(UI)/build $(TARGET)/public diff --git a/build/Dockerfile b/build/Dockerfile index 7764e051..3e26bfb0 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,8 +1,8 @@ FROM node:13-alpine as ui-build COPY web /tmp/web WORKDIR /tmp/web -ARG APP_VERSION="1.0.0" -ARG GITHUB_URL="https://github.com/x1unix/go-playground" +ARG APP_VERSION=1.0.0 +ARG GITHUB_URL=https://github.com/x1unix/go-playground RUN yarn install --silent && REACT_APP_VERSION=$APP_VERSION REACT_APP_GITHUB_URL=$GITHUB_URL yarn build FROM golang:1.13-alpine as build @@ -11,7 +11,9 @@ COPY cmd ./cmd COPY pkg ./pkg COPY go.mod . COPY go.sum . -RUN go build -o server ./cmd/playground +RUN go build -o server ./cmd/playground && \ + GOOS=js GOARCH=wasm go build -o ./worker.wasm ./cmd/webworker && \ + cp $(go env GOROOT)/misc/wasm/wasm_exec.js . FROM golang:1.13-alpine as production WORKDIR /opt/playground @@ -21,5 +23,7 @@ ENV APP_DEBUG=false COPY data ./data COPY --from=ui-build /tmp/web/build ./public COPY --from=build /tmp/playground/server . +COPY --from=build /tmp/playground/worker.wasm ./public +COPY --from=build /tmp/playground/wasm_exec.js ./public EXPOSE 8000 ENTRYPOINT /opt/playground/server -f=/opt/playground/data/packages.json -addr=:8000 -clean-interval=${APP_CLEAN_INTERVAL} -debug=${APP_DEBUG} \ No newline at end of file diff --git a/cmd/webworker/README.md b/cmd/webworker/README.md new file mode 100644 index 00000000..faffc45c --- /dev/null +++ b/cmd/webworker/README.md @@ -0,0 +1,6 @@ +# WebWorker + +`webworker` binary exports collection of tools used by UI web worker to analyze Go code and report +code errors. + +Package should be compiled as **WebAssembly** binary and loaded by JS WebWorker. \ No newline at end of file diff --git a/cmd/webworker/webworker.go b/cmd/webworker/webworker.go new file mode 100644 index 00000000..a44d84d3 --- /dev/null +++ b/cmd/webworker/webworker.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + "os" + "syscall/js" + + "github.com/x1unix/go-playground/pkg/analyzer/check" + + "github.com/x1unix/go-playground/pkg/worker" +) + +type void = struct{} + +var ( + done = make(chan void, 0) + onResult js.Value +) + +func main() { + entrypoint, err := getEntrypointFunction() + if err != nil { + panic(err) + } + + // prepare exports object + analyzeCodeCb := worker.FuncOf(analyzeCode) + exitCb := js.FuncOf(exit) + module := map[string]interface{}{ + "analyzeCode": analyzeCodeCb, + "exit": exitCb, + } + + defer analyzeCodeCb.Release() + defer exitCb.Release() + + entrypoint.Invoke(js.ValueOf(module)) + <-done + fmt.Println("Go: exit") +} + +func getEntrypointFunction() (js.Value, error) { + if len(os.Args) < 2 { + return js.Value{}, fmt.Errorf("WASM module requires at least 2 arguments: 'js' and entrypoint function name") + } + + entrypointName := os.Args[1] + entrypoint := js.Global().Get(entrypointName) + switch t := entrypoint.Type(); t { + case js.TypeFunction: + return entrypoint, nil + case js.TypeUndefined: + return js.Value{}, fmt.Errorf("function %q doesn't exists on global JS scope", entrypointName) + default: + return js.Value{}, fmt.Errorf("%q should be callable JS function, but got %d instead", entrypointName, t) + } +} + +func exit(this js.Value, args []js.Value) interface{} { + go func() { + done <- void{} + }() + return nil +} + +func analyzeCode(this js.Value, args worker.Args) (interface{}, error) { + var code string + if err := args.Bind(&code); err != nil { + return nil, err + } + + return check.Check(code) +} diff --git a/pkg/analyzer/check/check.go b/pkg/analyzer/check/check.go new file mode 100644 index 00000000..bae4a01a --- /dev/null +++ b/pkg/analyzer/check/check.go @@ -0,0 +1,22 @@ +package check + +import ( + "go/parser" + "go/scanner" + "go/token" +) + +// Check checks Go code and returns check result +func Check(src string) (*Result, error) { + fset := token.NewFileSet() + _, err := parser.ParseFile(fset, "main.go", src, parser.DeclarationErrors) + if err == nil { + return &Result{HasErrors: false}, nil + } + + if errList, ok := err.(scanner.ErrorList); ok { + return &Result{HasErrors: true, Markers: errorsListToMarkers(errList)}, nil + } + + return nil, err +} diff --git a/pkg/analyzer/check/marker.go b/pkg/analyzer/check/marker.go new file mode 100644 index 00000000..10c1445b --- /dev/null +++ b/pkg/analyzer/check/marker.go @@ -0,0 +1,46 @@ +// package check checks provided Go code and reports syntax errors +package check + +import "go/scanner" + +// MarkerSeverity is equivalent for MarkerSeverity type in monaco-editor +type MarkerSeverity = int + +const ( + Hint = MarkerSeverity(1) + Info = MarkerSeverity(2) + Warning = MarkerSeverity(3) + Error = MarkerSeverity(8) +) + +// MarkerData is a structure defining a problem/warning/etc. +// Equivalent to IMarkerData in 'monaco-editor' +type MarkerData struct { + Severity MarkerSeverity `json:"severity"` + StartLineNumber int `json:"startLineNumber"` + StartColumn int `json:"startColumn"` + EndLineNumber int `json:"endLineNumber"` + EndColumn int `json:"endColumn"` + Message string `json:"message"` +} + +func errorsListToMarkers(errList scanner.ErrorList) []MarkerData { + markers := make([]MarkerData, 0, len(errList)) + for _, err := range errList { + markers = append(markers, MarkerData{ + Severity: Error, + Message: err.Msg, + StartLineNumber: err.Pos.Line, + EndLineNumber: err.Pos.Line, + StartColumn: err.Pos.Column - 1, + EndColumn: err.Pos.Column, + }) + } + + return markers +} + +type Result struct { + HasErrors bool `json:"hasErrors"` + Markers []MarkerData `json:"markers"` +} diff --git a/pkg/worker/args.go b/pkg/worker/args.go new file mode 100644 index 00000000..156c22f3 --- /dev/null +++ b/pkg/worker/args.go @@ -0,0 +1,75 @@ +package worker + +import ( + "fmt" + "syscall/js" +) + +// NewTypeError creates a new type error +func NewTypeError(expType, gotType js.Type) error { + return fmt.Errorf("value type should be %q, but got %q", expType, gotType) +} + +type ValueUnmarshaler interface { + UnmarshalValue(js.Value) error +} + +// Args is collection if function call arguments +type Args []js.Value + +// BindIndex binds argument at specified index to passed value +func (args Args) BindIndex(index int, dest interface{}) error { + if len(args) <= index { + return fmt.Errorf("function expects %d arguments, but %d were passed", index+1, len(args)) + } + + return BindValue(args[index], dest) +} + +// Bind binds passed JS arguments to Go values +// +// Function supports *int, *bool, *string and ValueUnmarshaler values. +func (args Args) Bind(targets ...interface{}) error { + if len(args) != len(targets) { + return fmt.Errorf("function expects %d arguments, but %d were passed", len(targets), len(args)) + } + + for i, arg := range args { + if err := BindValue(arg, targets[i]); err != nil { + return fmt.Errorf("invalid argument %d type: %s", err) + } + } + + return nil +} + +// BindValue binds JS value to specified target +func BindValue(val js.Value, dest interface{}) error { + valType := val.Type() + switch v := dest.(type) { + case *int: + if valType != js.TypeNumber { + return NewTypeError(js.TypeNumber, valType) + } + + *v = val.Int() + case *bool: + if valType != js.TypeBoolean { + return NewTypeError(js.TypeBoolean, valType) + } + + *v = val.Bool() + case *string: + if valType != js.TypeString { + return NewTypeError(js.TypeString, valType) + } + + *v = val.String() + case ValueUnmarshaler: + return v.UnmarshalValue(val) + default: + return fmt.Errorf("BindValue: unsupported JS type %q", valType) + } + + return nil +} diff --git a/pkg/worker/callback.go b/pkg/worker/callback.go new file mode 100644 index 00000000..8e6d81c8 --- /dev/null +++ b/pkg/worker/callback.go @@ -0,0 +1,25 @@ +package worker + +import "syscall/js" + +// Callback is async function callback +type Callback = func(interface{}, error) + +func newCallbackFromValue(val js.Value) (Callback, error) { + if typ := val.Type(); typ != js.TypeFunction { + return nil, NewTypeError(js.TypeFunction, typ) + } + + return func(result interface{}, err error) { + if err != nil { + val.Invoke(js.ValueOf(NewErrorResponse(err).JSON())) + } + + if result == nil { + val.Invoke() + return + } + + val.Invoke(js.ValueOf(NewResponse(result, nil).JSON())) + }, nil +} diff --git a/pkg/worker/response.go b/pkg/worker/response.go new file mode 100644 index 00000000..8741d629 --- /dev/null +++ b/pkg/worker/response.go @@ -0,0 +1,33 @@ +package worker + +import ( + "encoding/json" + "fmt" +) + +type Response struct { + Error string `json:"error,omitempty"` + Result interface{} `json:"result,omitempty"` +} + +func (r Response) JSON() string { + data, err := json.Marshal(r) + if err != nil { + // Return manual JSON in case of error + return fmt.Sprintf(`{"error": %q}`, err) + } + + return string(data) +} + +func NewErrorResponse(err error) Response { + return Response{Error: err.Error()} +} + +func NewResponse(result interface{}, err error) Response { + if err != nil { + return Response{Error: err.Error()} + } + + return Response{Result: result} +} diff --git a/pkg/worker/worker.go b/pkg/worker/worker.go new file mode 100644 index 00000000..bad19084 --- /dev/null +++ b/pkg/worker/worker.go @@ -0,0 +1,46 @@ +// Package worker contains Go web-worker WASM module bridge methods +package worker + +import ( + "fmt" + "syscall/js" +) + +// Func is worker handler function +type Func = func(this js.Value, args Args) (interface{}, error) + +// ParseArgs parses async call arguments. +// +// Function expects the last argument to be a callable JS function +func ParseArgs(allArgs []js.Value) (Args, Callback, error) { + argLen := len(allArgs) + if argLen == 0 { + return nil, nil, fmt.Errorf("function requires at least 1 argument, but only 0 were passed") + } + + lastIndex := len(allArgs) - 1 + cb, err := newCallbackFromValue(allArgs[lastIndex:][0]) + if err != nil { + return nil, nil, fmt.Errorf("last function argument should be callable (%s)", err) + } + + return allArgs[:lastIndex], cb, nil +} + +func callFunc(fn Func, this js.Value, jsArgs []js.Value) { + args, callback, err := ParseArgs(jsArgs) + if err != nil { + js.Global().Get("console").Call("error", fmt.Sprintf("go worker: %s", err)) + panic(err) + } + + callback(fn(this, args)) +} + +// FuncOf wraps function into js-compatible async function with callback +func FuncOf(fn Func) js.Func { + return js.FuncOf(func(this js.Value, args []js.Value) interface{} { + go callFunc(fn, this, args) + return nil + }) +} diff --git a/web/package.json b/web/package.json index d0c554d4..d9c43b17 100644 --- a/web/package.json +++ b/web/package.json @@ -22,7 +22,8 @@ "react-scripts": "3.3.0", "redux": "^4.0.5", "redux-thunk": "^2.3.0", - "typescript": "^3.7.4" + "typescript": "^3.7.4", + "uuid": "^3.4.0" }, "scripts": { "start": "react-app-rewired start", diff --git a/web/public/.gitignore b/web/public/.gitignore new file mode 100644 index 00000000..824fb8ed --- /dev/null +++ b/web/public/.gitignore @@ -0,0 +1,2 @@ +worker.wasm +wasm_exec.js \ No newline at end of file diff --git a/web/public/worker.js b/web/public/worker.js new file mode 100644 index 00000000..15a9ca37 --- /dev/null +++ b/web/public/worker.js @@ -0,0 +1,76 @@ +importScripts('wasm_exec.js'); + +const FN_EXIT = 'exit'; +const TYPE_ANALYZE = 'ANALYZE'; +const TYPE_EXIT = 'EXIT'; + +function wrapModule(module) { + const wrapped = { + exit: () => module.exit.call(module), + }; + Object.keys(module).filter(k => k !== FN_EXIT).forEach(fnName => { + wrapped[fnName] = (...args) => (new Promise((res, rej) => { + const cb = (rawResp) => { + try { + const resp = JSON.parse(rawResp); + if (resp.error) { + rej(new Error(`${fnName}: ${resp.error}`)); + return; + } + + res(resp.result); + } catch (ex) { + rej(new Error(`${fnName}: ${ex}`)); + } + }; + + const newArgs = args.concat(cb); + module[fnName].apply(self, newArgs); + })) + }); + return wrapped; +} + +/** + * WASM module load handler + * @param module {Object} + */ +function onModuleInit(module) { + module = wrapModule(module); + onmessage = (msg) => { + const {id, type, data} = msg.data; + switch (type) { + case TYPE_ANALYZE: + module.analyzeCode(data) + .then(result => postMessage({id, type, result})) + .catch(error => postMessage({id, type, error})); + break; + case TYPE_EXIT: + module.exit(); + break; + default: + console.error('worker: unknown message type "%s"', type); + return; + } + }; +} + +function fetchAndInstantiate(url, importObject) { + return fetch(url).then(response => + response.arrayBuffer() + ).then(bytes => + WebAssembly.instantiate(bytes, importObject) + ).then(results => + results.instance + ); +} + +function main() { + const go = new Go(); + go.argv = ['js', 'onModuleInit']; + fetchAndInstantiate("worker.wasm", go.importObject) + .then(instance => go.run(instance)) + .catch(err => console.error('worker: Go error ', err)); +} + +main(); \ No newline at end of file diff --git a/web/src/editor/CodeEditor.tsx b/web/src/editor/CodeEditor.tsx index 0e894ee5..db218e1b 100644 --- a/web/src/editor/CodeEditor.tsx +++ b/web/src/editor/CodeEditor.tsx @@ -1,10 +1,13 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; -import {editor} from 'monaco-editor'; +import {editor, default as monaco} from 'monaco-editor'; import {Connect, newFileChangeAction} from '../store'; +import { Analyzer } from '../services/analyzer'; import { LANGUAGE_GOLANG, stateToOptions } from './props'; +const ANALYZE_DEBOUNCE_TIME = 500; + interface CodeEditorState { code?: string loading?:boolean @@ -17,12 +20,48 @@ interface CodeEditorState { options: s.monaco, })) export default class CodeEditor extends React.Component { - editorDidMount(editor: editor.IStandaloneCodeEditor, monaco: any) { - editor.focus(); + analyzer?: Analyzer; + _previousTimeout: any; + editorInstance?: editor.IStandaloneCodeEditor; + + editorDidMount(editorInstance: editor.IStandaloneCodeEditor, _: monaco.editor.IEditorConstructionOptions) { + this.editorInstance = editorInstance; + if (Analyzer.supported()) { + this.analyzer = new Analyzer(); + } else { + console.info('Analyzer requires WebAssembly support'); + } + + editorInstance.focus(); + } + + componentWillUnmount() { + this.analyzer?.dispose(); } onChange(newValue: string, e: editor.IModelContentChangedEvent) { this.props.dispatch(newFileChangeAction(newValue)); + + if (this.analyzer) { + this.doAnalyze(newValue); + } + } + + private doAnalyze(code: string) { + if (this._previousTimeout) { + clearTimeout(this._previousTimeout); + } + + this._previousTimeout = setTimeout(() => { + this._previousTimeout = null; + this.analyzer?.analyzeCode(code).then(result => { + editor.setModelMarkers( + this.editorInstance?.getModel() as editor.ITextModel, + this.editorInstance?.getId() as string, + result.markers + ); + }).catch(err => console.error('failed to perform code analysis: %s', err)); + }, ANALYZE_DEBOUNCE_TIME); } render() { diff --git a/web/src/services/analyzer.ts b/web/src/services/analyzer.ts new file mode 100644 index 00000000..d580d5e1 --- /dev/null +++ b/web/src/services/analyzer.ts @@ -0,0 +1,101 @@ +import { v4 as uuid } from 'uuid'; +import * as monaco from 'monaco-editor'; + +const WORKER_PATH = '/worker.js'; + +enum MessageType { + Exit = 'EXIT', + Analyze = 'ANALYZE' +} + +interface PromiseSubscription { + resolve: (result: T) => void + reject: (err: any) => void +} + +interface WorkerRequest { + id: string + type: MessageType + data: T +} + +interface WorkerResponse { + id: string + type: MessageType + error?: string + result?: T +} + +export interface AnalyzeResult { + hasErrors: boolean + markers: monaco.editor.IMarkerData[] +} + +export class Analyzer { + private terminated = false; + private worker: Worker; + private subscriptions = new Map>(); + + constructor() { + this.worker = new Worker(WORKER_PATH); + this.worker.onmessage = (m) => this.onMessage(m); + } + + async analyzeCode(code: string) { + return this.request(MessageType.Analyze, code); + } + + dispose() { + this.terminated = true; + this.worker.postMessage({type: MessageType.Exit}); + setTimeout(() => { + this.worker.terminate(); + this.cleanSubscriptions(); + }, 150); + } + + private cleanSubscriptions() { + this.subscriptions.forEach(val => val.reject('Analyzer is disposed')); + this.subscriptions.clear(); + } + + private onMessage(e: MessageEvent) { + if (this.terminated) { + return; + } + + let data = e.data as WorkerResponse; + const sub = this.subscriptions.get(data.id); + if (!sub) { + console.warn('analyzer: orphan worker event "%s"', data.id); + return; + } + + let { resolve, reject } = sub; + this.subscriptions.delete(data.id); + if (data.error) { + reject(data.error); + return + } + + resolve(data.result); + } + + private async request(type: MessageType, data: I): Promise { + if (this.terminated) { + throw new Error('Analyzer is disposed'); + } + + return new Promise((resolve, reject) => { + const id = uuid(); + this.subscriptions.set(id, {resolve, reject} as PromiseSubscription); + + const msg = {id, type, data} as WorkerRequest; + this.worker.postMessage(msg); + }); + } + + static supported() { + return 'WebAssembly' in window; + } +} \ No newline at end of file diff --git a/web/yarn.lock b/web/yarn.lock index f870b611..cfd6a32b 100644 --- a/web/yarn.lock +++ b/web/yarn.lock @@ -10479,6 +10479,11 @@ uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.3.tgz#4568f0216e78760ee1dbf3a4d2cf53e224112866" integrity sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ== +uuid@^3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" + integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== + v8-compile-cache@^2.0.3: version "2.1.0" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e"