From b1f1cba9cd4dd1124c486f11392eb544b8467225 Mon Sep 17 00:00:00 2001 From: x1unix Date: Sat, 25 Jan 2020 07:10:12 +0200 Subject: [PATCH 01/30] ui: add settings and about modals --- web/src/AboutModal.tsx | 64 ++++++++++++ web/src/Header.tsx | 43 +++++++- web/src/editor/props.ts | 3 +- web/src/services/api.ts | 6 +- web/src/services/config.ts | 8 ++ web/src/settings/SettingsModal.tsx | 136 ++++++++++++++++++++++++++ web/src/settings/SettingsProperty.tsx | 31 ++++++ web/src/settings/SettingsSection.tsx | 16 +++ web/src/settings/styles.tsx | 31 ++++++ web/src/styles/modal.ts | 58 +++++++++++ 10 files changed, 391 insertions(+), 5 deletions(-) create mode 100644 web/src/AboutModal.tsx create mode 100644 web/src/settings/SettingsModal.tsx create mode 100644 web/src/settings/SettingsProperty.tsx create mode 100644 web/src/settings/SettingsSection.tsx create mode 100644 web/src/settings/styles.tsx create mode 100644 web/src/styles/modal.ts diff --git a/web/src/AboutModal.tsx b/web/src/AboutModal.tsx new file mode 100644 index 00000000..4170787d --- /dev/null +++ b/web/src/AboutModal.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { Modal } from 'office-ui-fabric-react/lib/Modal'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import {getTheme, IconButton, FontWeights, FontSizes, mergeStyleSets} from 'office-ui-fabric-react'; +import {getContentStyles, getIconButtonStyles} from './styles/modal'; +import config from './services/config'; + +const TITLE_ID = 'AboutTitle'; +const SUB_TITLE_ID = 'AboutSubtitle'; + +interface AboutModalProps { + isOpen: boolean + onClose: () => void +} + +const modalStyles = mergeStyleSets({ + title: { + fontWeight: FontWeights.light, + fontSize: FontSizes.xxLargePlus, + padding: '1em 2em 2em 2em' + }, + footer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center' + } +}); + +export default function AboutModal(props: AboutModalProps) { + const theme = getTheme(); + const contentStyles = getContentStyles(theme); + const iconButtonStyles = getIconButtonStyles(theme); + + return ( + +
+ About + +
+
+
+ Better Go Playground +
+
+ GitHub + Version: {config.appVersion} +
+
+
+ ) +} + +AboutModal.defaultProps = {isOpen: false}; \ No newline at end of file diff --git a/web/src/Header.tsx b/web/src/Header.tsx index f8afcd2a..d8cde68a 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -2,6 +2,8 @@ import React from 'react'; import './Header.css' import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; import { getTheme } from '@uifabric/styling'; +import SettingsModal from './settings/SettingsModal'; +import AboutModal from './AboutModal'; import { Connect, newImportFileDispatcher, @@ -11,16 +13,26 @@ import { dispatchToggleTheme, shareSnippetDispatcher } from './store'; + +interface HeaderState { + showSettings: boolean + showAbout: boolean + loading: boolean +} + @Connect(s => ({darkMode: s.settings.darkMode, loading: s.status?.loading})) -export class Header extends React.Component { +export class Header extends React.Component { private fileInput?: HTMLInputElement; constructor(props) { super(props); this.state = { + showSettings: false, + showAbout: false, loading: false }; } + componentDidMount(): void { const fileElement = document.createElement('input') as HTMLInputElement; fileElement.type = 'file'; @@ -104,6 +116,32 @@ export class Header extends React.Component { ]; } + get overflowItems(): ICommandBarItemProps[] { + return [ + { + key: 'settings', + text: 'Settings', + ariaLabel: 'Settings', + iconOnly: true, + iconProps: {iconName: 'Settings'}, + disabled: this.props.loading, + onClick: () => { + this.setState({showSettings: true}); + } + }, + { + key: 'about', + text: 'About', + ariaLabel: 'About', + iconOnly: true, + iconProps: {iconName: 'Info'}, + onClick: () => { + this.setState({showAbout: true}); + } + } + ] + } + get styles() { // Apply the same colors as rest of Fabric components const theme = getTheme(); @@ -123,8 +161,11 @@ export class Header extends React.Component { className='header__commandBar' items={this.menuItems} farItems={this.asideItems} + overflowItems={this.overflowItems} ariaLabel='CodeEditor menu' /> + this.setState({showSettings: false})} isOpen={this.state.showSettings} /> + this.setState({showAbout: false})} isOpen={this.state.showAbout} /> ; } } diff --git a/web/src/editor/props.ts b/web/src/editor/props.ts index d28a78df..4f512093 100644 --- a/web/src/editor/props.ts +++ b/web/src/editor/props.ts @@ -27,5 +27,6 @@ export const DEFAULT_EDITOR_OPTIONS: monaco.editor.IEditorOptions = { mouseWheelZoom: true, automaticLayout: true, fontFamily: EDITOR_FONTS, - showUnused: true + showUnused: true, + smoothScrolling: true, }; \ No newline at end of file diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 359d57b9..8c87f653 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1,10 +1,10 @@ import * as axios from 'axios'; import {AxiosInstance} from "axios"; import * as monaco from "monaco-editor"; +import config from './config'; -const apiAddress = process.env['REACT_APP_LANG_SERVER'] ?? window.location.origin; - -let axiosClient = axios.default.create({baseURL: `${apiAddress}/api`}); +const apiAddress = config.serverUrl; +const axiosClient = axios.default.create({baseURL: `${apiAddress}/api`}); export interface ShareResponse { snippetID: string diff --git a/web/src/services/config.ts b/web/src/services/config.ts index f8ad1bb4..8f279ebe 100644 --- a/web/src/services/config.ts +++ b/web/src/services/config.ts @@ -7,7 +7,14 @@ function setThemeStyles(isDark: boolean) { loadTheme(isDark ? DarkTheme : LightTheme); } +export const getVariableValue = (key: string, defaultValue: string) => + process.env[`REACT_APP_${key}`] ?? defaultValue; + export default { + appVersion: getVariableValue('VERSION', '1.0.0'), + serverUrl: getVariableValue('LANG_SERVER', window.location.origin), + githubUrl: getVariableValue('GITHUB_URL', 'https://github.com/x1unix/go-playground'), + get darkThemeEnabled(): boolean { const v = localStorage.getItem(DARK_THEME_KEY); return v === 'true'; @@ -17,6 +24,7 @@ export default { setThemeStyles(enable); localStorage.setItem(DARK_THEME_KEY, enable ? 'true' : 'false'); }, + sync() { setThemeStyles(this.darkThemeEnabled); } diff --git a/web/src/settings/SettingsModal.tsx b/web/src/settings/SettingsModal.tsx new file mode 100644 index 00000000..50c2b6df --- /dev/null +++ b/web/src/settings/SettingsModal.tsx @@ -0,0 +1,136 @@ +import React from 'react'; +import {Modal, Dropdown, Checkbox, IDropdownOption, IconButton, getTheme} from 'office-ui-fabric-react'; +import { Pivot, PivotItem } from 'office-ui-fabric-react/lib/Pivot'; +import { getContentStyles, getIconButtonStyles } from '../styles/modal'; +import SettingsProperty from './SettingsProperty'; + +const WASM_SUPPORTED = 'WebAssembly' in window; + +const COMPILER_OPTIONS: IDropdownOption[] = [ + { key: 'GO_PLAYGROUND', text: 'Go Playground' }, + { + key: 'WASM', + text: `WebAssembly (${WASM_SUPPORTED ? 'Experimental' : 'Unsupported'})`, + disabled: !WASM_SUPPORTED + }, +]; + +const CURSOR_BLINK_STYLE_OPTS: IDropdownOption[] = [ + {key: 'blink', text: 'Blink (default)'}, + {key: 'smooth', text: 'Smooth'}, + {key: 'phase', text: 'Phase'}, + {key: 'expand', text: 'Expand'}, + {key: 'solid', text: 'Solid'}, +]; + +const CURSOR_LINE_OPTS: IDropdownOption[] = [ + {key: 'line', text: 'Line (default)'}, + {key: 'block', text: 'Block'}, + {key: 'underline', text: 'Underline'}, + {key: 'line-thin', text: 'Line thin'}, + {key: 'block-outline', text: 'Block outline'}, + {key: 'underline-thin', text: 'Underline thin'}, +]; + +interface SettingsState { + isOpen: boolean +} + +export interface SettingsProps extends SettingsState { + onClose: () => void +} + +export default class SettingsModal extends React.Component { + private titleID = 'Settings'; + private subtitleID = 'SettingsSubText'; + + constructor(props) { + super(props); + console.log('SettingsModal.constructor'); + this.state = { + isOpen: props.isOpen + } + } + + render() { + const theme = getTheme(); + const contentStyles = getContentStyles(theme); + const iconButtonStyles = getIconButtonStyles(theme); + return ( + +
+ Settings + +
+
+ + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + } + /> + } + /> + + + +
+
+ ) + } +} \ No newline at end of file diff --git a/web/src/settings/SettingsProperty.tsx b/web/src/settings/SettingsProperty.tsx new file mode 100644 index 00000000..a7885736 --- /dev/null +++ b/web/src/settings/SettingsProperty.tsx @@ -0,0 +1,31 @@ +import {settingsPropStyles} from './styles' +import React from 'react'; + +interface SettingsSectionProps { + title: string + description?: string + control: JSX.Element +} + +export default function SettingsProperty(props: SettingsSectionProps) { + if (props.description) { + return ( +
+
{props.title}
+
{props.description}
+
+ {props.control} +
+
+ ) + } + + return ( +
+
{props.title}
+
+ {props.control} +
+
+ ) +} \ No newline at end of file diff --git a/web/src/settings/SettingsSection.tsx b/web/src/settings/SettingsSection.tsx new file mode 100644 index 00000000..b50d1aa1 --- /dev/null +++ b/web/src/settings/SettingsSection.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { settingsSectionStyles } from './styles' + +interface SettingsSectionProps { + title: string + children: JSX.Element | JSX.Element[] +} + +export default function SettingsSection(props: SettingsSectionProps) { + return ( +
+
{props.title}
+ {props.children} +
+ ) +} \ No newline at end of file diff --git a/web/src/settings/styles.tsx b/web/src/settings/styles.tsx new file mode 100644 index 00000000..1ed29890 --- /dev/null +++ b/web/src/settings/styles.tsx @@ -0,0 +1,31 @@ +import { + FontWeights, + FontSizes, + mergeStyleSets, + mergeStyles, + ITheme +} from 'office-ui-fabric-react'; + +export const settingsSectionStyles = mergeStyleSets({ + title: { + fontSize: FontSizes.xLarge + }, + section: { + marginBottom: '25px' + } +}); + +export const settingsPropStyles = mergeStyleSets({ + title: { + fontWeight: FontWeights.bold, + }, + container: { + marginTop: '10px' + }, + block: { + marginTop: '15px', + }, + description: { + marginTop: '5px' + } +}); diff --git a/web/src/styles/modal.ts b/web/src/styles/modal.ts new file mode 100644 index 00000000..4dd9a9fe --- /dev/null +++ b/web/src/styles/modal.ts @@ -0,0 +1,58 @@ +import { + FontWeights, + FontSizes, + mergeStyleSets, + mergeStyles, + ITheme +} from 'office-ui-fabric-react'; + +export const getIconButtonStyles = (theme: ITheme) => mergeStyleSets({ + root: { + color: theme.palette.neutralPrimary, + marginLeft: 'auto', + marginTop: '4px', + marginRight: '2px' + }, + rootHovered: { + color: theme.palette.neutralDark + } +}); + +export const getContentStyles = (theme: ITheme) => mergeStyleSets({ + container: { + display: 'flex', + flexFlow: 'column nowrap', + alignItems: 'stretch', + width: '80%', + maxWidth: '480px' + }, + header: [ + theme.fonts.xLargePlus, + { + flex: '1 1 auto', + borderTop: `4px solid ${theme.palette.themePrimary}`, + color: theme.palette.neutralPrimary, + display: 'flex', + fontSize: FontSizes.xLarge, + alignItems: 'center', + fontWeight: FontWeights.semibold, + padding: '12px 12px 14px 24px' + } + ], + body: { + flex: '4 4 auto', + padding: '0 24px 24px 24px', + overflowY: 'hidden', + selectors: { + p: { + margin: '14px 0' + }, + 'p:first-child': { + marginTop: 0 + }, + 'p:last-child': { + marginBottom: 0 + } + } + } +}); \ No newline at end of file From 8556fc67c9a220ea2d97f8e9c971beee6ff5ac16 Mon Sep 17 00:00:00 2001 From: x1unix Date: Sat, 25 Jan 2020 09:20:32 +0200 Subject: [PATCH 02/30] ui: settings change detection --- web/src/Header.tsx | 21 ++++- web/src/editor/CodeEditor.tsx | 15 ++-- web/src/editor/props.ts | 19 ++-- web/src/settings/SettingsModal.tsx | 135 ++++++++++++++++++++++++----- web/src/settings/styles.tsx | 4 +- web/src/store/actions.ts | 34 ++++++-- web/src/store/reducers.ts | 36 +++++++- web/src/store/state.ts | 18 ++++ web/src/styles/modal.ts | 1 - 9 files changed, 231 insertions(+), 52 deletions(-) diff --git a/web/src/Header.tsx b/web/src/Header.tsx index d8cde68a..434ac0af 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -2,7 +2,7 @@ import React from 'react'; import './Header.css' import { CommandBar, ICommandBarItemProps } from 'office-ui-fabric-react/lib/CommandBar'; import { getTheme } from '@uifabric/styling'; -import SettingsModal from './settings/SettingsModal'; +import SettingsModal, {SettingsChanges} from './settings/SettingsModal'; import AboutModal from './AboutModal'; import { Connect, @@ -10,7 +10,7 @@ import { formatFileDispatcher, runFileDispatcher, saveFileDispatcher, - dispatchToggleTheme, shareSnippetDispatcher + dispatchToggleTheme, shareSnippetDispatcher, newMonacoParamsChangeAction, newBuildParamsChangeAction } from './store'; @@ -150,6 +150,21 @@ export class Header extends React.Component { } } + private onSettingsClose(changes: SettingsChanges) { + if (changes.monaco) { + // Update monaco state if some of it's settings were changed + this.props.dispatch(newMonacoParamsChangeAction(changes.monaco)); + } + + if (changes.args) { + // Save runtime settings + const { runtime, autoFormat } = changes.args; + this.props.dispatch(newBuildParamsChangeAction(runtime, autoFormat)); + } + + this.setState({showSettings: false}); + } + render() { return
{ overflowItems={this.overflowItems} ariaLabel='CodeEditor menu' /> - this.setState({showSettings: false})} isOpen={this.state.showSettings} /> + this.onSettingsClose(args)} isOpen={this.state.showSettings} /> this.setState({showAbout: false})} isOpen={this.state.showAbout} />
; } diff --git a/web/src/editor/CodeEditor.tsx b/web/src/editor/CodeEditor.tsx index 88b6b351..0e894ee5 100644 --- a/web/src/editor/CodeEditor.tsx +++ b/web/src/editor/CodeEditor.tsx @@ -1,17 +1,21 @@ import React from 'react'; import MonacoEditor from 'react-monaco-editor'; import {editor} from 'monaco-editor'; -import { Connect, newFileChangeAction } from '../store'; -// import { connect } from 'react-redux'; +import {Connect, newFileChangeAction} from '../store'; -import { DEFAULT_EDITOR_OPTIONS, LANGUAGE_GOLANG } from './props'; +import { LANGUAGE_GOLANG, stateToOptions } from './props'; interface CodeEditorState { code?: string loading?:boolean } -@Connect(s => ({code: s.editor.code, darkMode: s.settings.darkMode, loading: s.status?.loading})) +@Connect(s => ({ + code: s.editor.code, + darkMode: s.settings.darkMode, + loading: s.status?.loading, + options: s.monaco, +})) export default class CodeEditor extends React.Component { editorDidMount(editor: editor.IStandaloneCodeEditor, monaco: any) { editor.focus(); @@ -22,11 +26,12 @@ export default class CodeEditor extends React.Component { } render() { + const options = stateToOptions(this.props.options); return this.onChange(newVal, e)} editorDidMount={(e, m: any) => this.editorDidMount(e, m)} />; diff --git a/web/src/editor/props.ts b/web/src/editor/props.ts index 4f512093..a959c632 100644 --- a/web/src/editor/props.ts +++ b/web/src/editor/props.ts @@ -1,4 +1,5 @@ import * as monaco from 'monaco-editor'; +import {MonacoState} from "../store"; export const LANGUAGE_GOLANG = 'go'; @@ -22,11 +23,15 @@ export const EDITOR_FONTS = [ 'monospace' ].join(', '); -export const DEFAULT_EDITOR_OPTIONS: monaco.editor.IEditorOptions = { - selectOnLineNumbers: true, - mouseWheelZoom: true, - automaticLayout: true, - fontFamily: EDITOR_FONTS, - showUnused: true, - smoothScrolling: true, +// stateToOptions converts MonacoState to IEditorOptions +export const stateToOptions = (state: MonacoState): monaco.editor.IEditorOptions => { + const {selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, contextMenu } = state; + return { + selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, + fontFamily: EDITOR_FONTS, + showUnused: true, + automaticLayout: true, + minimap: {enabled: state.minimap}, + contextmenu: contextMenu, + }; }; \ No newline at end of file diff --git a/web/src/settings/SettingsModal.tsx b/web/src/settings/SettingsModal.tsx index 50c2b6df..583fd13f 100644 --- a/web/src/settings/SettingsModal.tsx +++ b/web/src/settings/SettingsModal.tsx @@ -1,15 +1,16 @@ import React from 'react'; -import {Modal, Dropdown, Checkbox, IDropdownOption, IconButton, getTheme} from 'office-ui-fabric-react'; -import { Pivot, PivotItem } from 'office-ui-fabric-react/lib/Pivot'; -import { getContentStyles, getIconButtonStyles } from '../styles/modal'; +import {Checkbox, Dropdown, getTheme, IconButton, IDropdownOption, Modal} from 'office-ui-fabric-react'; +import {Pivot, PivotItem} from 'office-ui-fabric-react/lib/Pivot'; +import {getContentStyles, getIconButtonStyles} from '../styles/modal'; import SettingsProperty from './SettingsProperty'; +import {BuildParamsArgs, Connect, MonacoParamsChanges, MonacoState, RuntimeType, SettingsState} from "../store"; const WASM_SUPPORTED = 'WebAssembly' in window; const COMPILER_OPTIONS: IDropdownOption[] = [ - { key: 'GO_PLAYGROUND', text: 'Go Playground' }, + { key: RuntimeType.GoPlayground, text: 'Go Playground' }, { - key: 'WASM', + key: RuntimeType.WebAssembly, text: `WebAssembly (${WASM_SUPPORTED ? 'Experimental' : 'Unsupported'})`, disabled: !WASM_SUPPORTED }, @@ -32,26 +33,48 @@ const CURSOR_LINE_OPTS: IDropdownOption[] = [ {key: 'underline-thin', text: 'Underline thin'}, ]; -interface SettingsState { - isOpen: boolean +export interface SettingsChanges { + monaco?: MonacoParamsChanges + args?: BuildParamsArgs, } -export interface SettingsProps extends SettingsState { - onClose: () => void +export interface SettingsProps { + isOpen: boolean + onClose: (changes: SettingsChanges) => void + settings?: SettingsState + monaco?: MonacoState + dispatch?: (Action) => void } -export default class SettingsModal extends React.Component { +@Connect(state => ({ + settings: state.settings, + monaco: state.monaco, +})) +export default class SettingsModal extends React.Component { private titleID = 'Settings'; private subtitleID = 'SettingsSubText'; + private changes: SettingsChanges = {}; constructor(props) { super(props); - console.log('SettingsModal.constructor'); this.state = { isOpen: props.isOpen } } + private onClose() { + this.props.onClose({...this.changes}); + this.changes = {}; + } + + private touchMonacoProperty(key: keyof MonacoState, val: any) { + if (!this.changes.monaco) { + this.changes.monaco = {}; + } + + this.changes.monaco[key] = val; + } + render() { const theme = getTheme(); const contentStyles = getContentStyles(theme); @@ -61,7 +84,7 @@ export default class SettingsModal extends React.Component this.onClose()} containerClassName={contentStyles.container} >
@@ -70,7 +93,7 @@ export default class SettingsModal extends React.Component this.onClose()} />
@@ -80,38 +103,80 @@ export default class SettingsModal extends React.Component} + control={ { + this.touchMonacoProperty('cursorBlinking', num?.key); + }} + />} /> } + control={ { + this.touchMonacoProperty('cursorStyle', num?.key); + }} + />} /> } + control={ { + this.touchMonacoProperty('cursorStyle', val); + }} + />} /> } + control={ { + this.touchMonacoProperty('minimap', val); + }} + />} /> } + control={ { + this.touchMonacoProperty('contextMenu', val); + }} + />} /> } + control={ { + this.touchMonacoProperty('smoothScrolling', val); + }} + />} /> } + control={ { + this.touchMonacoProperty('mouseWheelZoom', val); + }} + />} /> @@ -119,12 +184,36 @@ export default class SettingsModal extends React.Component} + control={ { + if (!val) { + return; + } + this.changes.args = { + runtime: val?.key as RuntimeType, + autoFormat: this.props.settings?.autoFormat ?? true, + }; + }} + />} /> } + control={ { + if (!val) { + return; + } + this.changes.args = { + autoFormat: val ?? false, + runtime: this.props.settings?.runtime ?? RuntimeType.GoPlayground, + }; + }} + />} /> diff --git a/web/src/settings/styles.tsx b/web/src/settings/styles.tsx index 1ed29890..ec0997d6 100644 --- a/web/src/settings/styles.tsx +++ b/web/src/settings/styles.tsx @@ -1,9 +1,7 @@ import { FontWeights, FontSizes, - mergeStyleSets, - mergeStyles, - ITheme + mergeStyleSets } from 'office-ui-fabric-react'; export const settingsSectionStyles = mergeStyleSets({ diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index 7ddcc05d..e00d8bea 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -1,12 +1,15 @@ import {CompilerResponse} from "../services/api"; +import {MonacoState, RuntimeType} from './state'; export enum ActionType { - IMPORT_FILE = 'IMPORT_FILE', - FILE_CHANGE = 'FILE_CHANGE', - LOADING = 'LOADING', - ERROR = 'ERROR', - COMPILE_RESULT = 'COMPILE_RESULT', - TOGGLE_THEME = 'TOGGLE_THEME' + IMPORT_FILE = 'IMPORT_FILE', + FILE_CHANGE = 'FILE_CHANGE', + LOADING = 'LOADING', + ERROR = 'ERROR', + COMPILE_RESULT = 'COMPILE_RESULT', + TOGGLE_THEME = 'TOGGLE_THEME', + BUILD_PARAMS_CHANGE = 'BUILD_PARAMS_CHANGE', + MONACO_SETTINGS_CHANGE = 'MONACO_SETTINGS_CHANGE' } export interface Action { @@ -19,6 +22,15 @@ export interface FileImportArgs { contents: string } +export interface BuildParamsArgs { + runtime: RuntimeType + autoFormat: boolean +} + +export type MonacoParamsChanges = { + [k in keyof MonacoState | string]: T; +}; + export const newImportFileAction = (fileName: string, contents: string) => ({ type: ActionType.IMPORT_FILE, @@ -55,4 +67,14 @@ export const newLoadingAction = () => payload: null, }); +export const newBuildParamsChangeAction = (runtime: RuntimeType, autoFormat: boolean) => + ({ + type: ActionType.BUILD_PARAMS_CHANGE, + payload: {runtime, autoFormat} as BuildParamsArgs + }); +export const newMonacoParamsChangeAction = (changes: MonacoParamsChanges) => + ({ + type: ActionType.MONACO_SETTINGS_CHANGE, + payload: changes + }); \ No newline at end of file diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index dde76ce4..f02dbb43 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -1,12 +1,29 @@ import { connectRouter } from 'connected-react-router'; import { combineReducers } from 'redux'; -import {Action, ActionType, FileImportArgs} from './actions'; -import {EditorState, SettingsState, State, StatusState} from './state'; +import {Action, ActionType, FileImportArgs, BuildParamsArgs, MonacoParamsChanges} from './actions'; +import { + EditorState, + SettingsState, + State, + StatusState, + MonacoState, + RuntimeType +} from './state'; import { CompilerResponse } from '../services/api'; import localConfig from '../services/config' import {mapByAction} from './helpers'; +const defaultMonacoState: MonacoState = { + cursorBlinking: 'blink', + cursorStyle: 'line', + selectOnLineNumbers: true, + minimap: true, + contextMenu: true, + smoothScrolling: true, + mouseWheelZoom: true, +}; + const reducers = { editor: mapByAction({ [ActionType.FILE_CHANGE]: (s: EditorState, a: Action) => { @@ -52,8 +69,16 @@ const reducers = { s.darkMode = !s.darkMode; localConfig.darkThemeEnabled = s.darkMode; return s; + }, + [ActionType.BUILD_PARAMS_CHANGE]: (s: SettingsState, a: Action) => { + return Object.assign({}, s, a.payload); + }, + }, {darkMode: localConfig.darkThemeEnabled, autoFormat: true, runtime: RuntimeType.GoPlayground}), + monaco: mapByAction({ + [ActionType.MONACO_SETTINGS_CHANGE]: (s: MonacoState, a: Action) => { + return Object.assign({}, s, a.payload); } - }, {darkMode: localConfig.darkThemeEnabled}) + }, defaultMonacoState) }; export const getInitialState = (): State => ({ @@ -65,8 +90,11 @@ export const getInitialState = (): State => ({ code: '' }, settings: { - darkMode: localConfig.darkThemeEnabled + darkMode: localConfig.darkThemeEnabled, + autoFormat: true, + runtime: RuntimeType.GoPlayground }, + monaco: defaultMonacoState, }); export const createRootReducer = history => combineReducers({ diff --git a/web/src/store/state.ts b/web/src/store/state.ts index b1c4030c..319c9297 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -1,6 +1,11 @@ import { connect } from 'react-redux'; import { EvalEvent } from '../services/api'; +export enum RuntimeType { + GoPlayground = 'GO_PLAYGROUND', + WebAssembly = 'WASM' +} + export interface EditorState { fileName: string, code: string @@ -14,12 +19,25 @@ export interface StatusState { export interface SettingsState { darkMode: boolean + autoFormat: boolean, + runtime: RuntimeType, +} + +export interface MonacoState { + cursorBlinking: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid', + cursorStyle: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin', + selectOnLineNumbers: boolean, + minimap: boolean, + contextMenu: boolean, + smoothScrolling: boolean, + mouseWheelZoom: boolean, } export interface State { editor: EditorState status?: StatusState, settings: SettingsState + monaco: MonacoState } export function Connect(fn: (state: State) => any) { diff --git a/web/src/styles/modal.ts b/web/src/styles/modal.ts index 4dd9a9fe..89ecb411 100644 --- a/web/src/styles/modal.ts +++ b/web/src/styles/modal.ts @@ -2,7 +2,6 @@ import { FontWeights, FontSizes, mergeStyleSets, - mergeStyles, ITheme } from 'office-ui-fabric-react'; From 4f35f5f4b7bcbff5c426607c79dd9d3654a4a61f Mon Sep 17 00:00:00 2001 From: x1unix Date: Sat, 25 Jan 2020 09:22:47 +0200 Subject: [PATCH 03/30] ui: settings change detection --- web/src/settings/SettingsModal.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/web/src/settings/SettingsModal.tsx b/web/src/settings/SettingsModal.tsx index 583fd13f..3ae93117 100644 --- a/web/src/settings/SettingsModal.tsx +++ b/web/src/settings/SettingsModal.tsx @@ -205,9 +205,6 @@ export default class SettingsModal extends React.Component { - if (!val) { - return; - } this.changes.args = { autoFormat: val ?? false, runtime: this.props.settings?.runtime ?? RuntimeType.GoPlayground, From 3c3547191678083c5000f4e2b3bac7e75a8ac2d4 Mon Sep 17 00:00:00 2001 From: x1unix Date: Sat, 25 Jan 2020 09:51:31 +0200 Subject: [PATCH 04/30] ui: show WASM disclaimer --- web/src/settings/SettingsModal.tsx | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/web/src/settings/SettingsModal.tsx b/web/src/settings/SettingsModal.tsx index 3ae93117..07f956d7 100644 --- a/web/src/settings/SettingsModal.tsx +++ b/web/src/settings/SettingsModal.tsx @@ -1,6 +1,8 @@ import React from 'react'; import {Checkbox, Dropdown, getTheme, IconButton, IDropdownOption, Modal} from 'office-ui-fabric-react'; import {Pivot, PivotItem} from 'office-ui-fabric-react/lib/Pivot'; +import {MessageBar, MessageBarType} from 'office-ui-fabric-react/lib/MessageBar'; +import {Link} from 'office-ui-fabric-react/lib/Link'; import {getContentStyles, getIconButtonStyles} from '../styles/modal'; import SettingsProperty from './SettingsProperty'; import {BuildParamsArgs, Connect, MonacoParamsChanges, MonacoState, RuntimeType, SettingsState} from "../store"; @@ -50,7 +52,7 @@ export interface SettingsProps { settings: state.settings, monaco: state.monaco, })) -export default class SettingsModal extends React.Component { +export default class SettingsModal extends React.Component { private titleID = 'Settings'; private subtitleID = 'SettingsSubText'; private changes: SettingsChanges = {}; @@ -58,7 +60,8 @@ export default class SettingsModal extends React.Component} /> - + } /> +
+ + WebAssembly is a modern runtime that gives you additional features + like possibility to interact with web browser but is unstable. + Use it at your own risk. +

+ Seedocumentation for more details. +

+
+
Date: Sat, 25 Jan 2020 12:55:01 +0200 Subject: [PATCH 05/30] working on embedded Go runner --- web/src/services/api.ts | 7 +- web/src/services/go/foundation.ts | 9 + web/src/services/go/fs.ts | 69 +++++ web/src/services/go/go.ts | 421 ++++++++++++++++++++++++++++++ web/src/services/go/stdio.ts | 44 ++++ web/src/store/actions.ts | 17 +- web/src/store/dispatch.ts | 9 +- web/src/store/reducers.ts | 13 +- 8 files changed, 580 insertions(+), 9 deletions(-) create mode 100644 web/src/services/go/foundation.ts create mode 100644 web/src/services/go/fs.ts create mode 100644 web/src/services/go/go.ts create mode 100644 web/src/services/go/stdio.ts diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 8c87f653..dfc3ab6c 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -6,6 +6,11 @@ import config from './config'; const apiAddress = config.serverUrl; const axiosClient = axios.default.create({baseURL: `${apiAddress}/api`}); +export enum EvalEventKind { + Stdout = 'stdout', + Stderr = 'stderr' +} + export interface ShareResponse { snippetID: string } @@ -17,7 +22,7 @@ export interface Snippet { export interface EvalEvent { Message: string - Kind: string + Kind: EvalEventKind Delay: number } diff --git a/web/src/services/go/foundation.ts b/web/src/services/go/foundation.ts new file mode 100644 index 00000000..f769f918 --- /dev/null +++ b/web/src/services/go/foundation.ts @@ -0,0 +1,9 @@ +export type uint = number; +export type int = number; +export type byte = number; + +export type bytes = Array; +export type NodeCallback = (Error, T) => void; + +export const encoder = new TextEncoder(); +export const decoder = new TextDecoder('utf-8'); \ No newline at end of file diff --git a/web/src/services/go/fs.ts b/web/src/services/go/fs.ts new file mode 100644 index 00000000..31d0eed7 --- /dev/null +++ b/web/src/services/go/fs.ts @@ -0,0 +1,69 @@ +import { NodeCallback } from './foundation'; + +export type FileDescriptor = number; + +export const STDOUT: FileDescriptor = 1; +export const STDERR: FileDescriptor = 2; + +/** + * IWriter is abstract writer interface + */ +export interface IWriter { + // write writes data and returns written bytes count + write(data: Uint8Array): number +} + +/** + * FileSystem is wrapper class for FS simulation + */ +export class FileSystem { + descriptors = new Map(); + readonly constants = { + O_WRONLY: -1, + O_RDWR: -1, + O_CREAT: -1, + O_TRUNC: -1, + O_APPEND: -1, + O_EXCL: -1 + }; + + constructor(stdout: IWriter, stderr: IWriter) { + this.descriptors.set(STDERR, stderr); + this.descriptors.set(STDOUT, stdout); + } + + writeSync(fd: FileDescriptor, buf: Uint8Array) { + const writer = this.descriptors.get(fd); + if (!writer) { + const err = new Error('not implemented'); + err['code'] = 'ENOENT'; + throw err; + } + + return writer.write(buf); + } + + write(fd: FileDescriptor, buf: Uint8Array, offset: number, length: number, position: number, callback: NodeCallback) { + if (offset !== 0 || length !== buf.length || position !== null) { + throw new Error("not implemented"); + } + const n = this.writeSync(fd, buf); + callback(null, n); + } + + open(path: string, flags, mode, callback) { + const err = new Error("not implemented"); + err['code'] = "ENOSYS"; + callback(err, null); + } + + read(fd: FileDescriptor, buffer, offset: number, length: number, position: number, callback: NodeCallback) { + const err = new Error("not implemented"); + err['code'] = "ENOSYS"; + callback(err, null); + } + + fsync(fd, callback) { + callback(null); + } +} \ No newline at end of file diff --git a/web/src/services/go/go.ts b/web/src/services/go/go.ts new file mode 100644 index 00000000..9f146d72 --- /dev/null +++ b/web/src/services/go/go.ts @@ -0,0 +1,421 @@ +import { FileSystem } from "./fs"; +import { ConsoleWriter } from "./stdio"; +import { encoder, decoder } from './foundation'; + +const MAX_UINT32 = 4294967296; +const NAN_HEAD = 0x7FF80000; +const MS_IN_NANO = 1000000; + +export class Go { + argv = ['js']; + env: {[k: string]: string} = {}; + timeOrigin = Date.now() - performance.now(); + exited = false; + global: any; + + private pendingEvent: any = null; + private scheduledTimeouts = new Map(); + private nextCallbackTimeoutID = 1; + private _resolveExitPromise?: (val?: any) => void; + private exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + + private inst: any; + private values: any[] = []; + private refs = new Map(); + + constructor(private fs: FileSystem) { + this.global = window; + this.global.fs = fs; + } + + get mem() { + // The buffer may change when requesting more memory. + return new DataView(this.inst.exports.mem.buffer); + } + + setInt64(addr, v) { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / MAX_UINT32), true); + } + + getInt64(addr) { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * MAX_UINT32; + } + + loadValue(addr: number) { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this.values[id]; + } + + storeValue(addr, v) { + if (typeof v === "number") { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, NAN_HEAD, true); + this.mem.setUint32(addr, 0, true); + return; + } + if (v === 0) { + this.mem.setUint32(addr + 4, NAN_HEAD, true); + this.mem.setUint32(addr, 1, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + switch (v) { + case undefined: + this.mem.setFloat64(addr, 0, true); + return; + case null: + this.mem.setUint32(addr + 4, NAN_HEAD, true); + this.mem.setUint32(addr, 2, true); + return; + case true: + this.mem.setUint32(addr + 4, NAN_HEAD, true); + this.mem.setUint32(addr, 3, true); + return; + case false: + this.mem.setUint32(addr + 4, NAN_HEAD, true); + this.mem.setUint32(addr, 4, true); + return; + } + + let ref = this.refs.get(v); + if (ref === undefined) { + ref = this.values.length; + this.values.push(v); + this.refs.set(v, ref); + } + let typeFlag = 0; + switch (typeof v) { + case "string": + typeFlag = 1; + break; + case "symbol": + typeFlag = 2; + break; + case "function": + typeFlag = 3; + break; + } + this.mem.setUint32(addr + 4, NAN_HEAD | typeFlag, true); + this.mem.setUint32(addr, ref, true); + } + + loadSlice(addr) { + const array = this.getInt64(addr + 0); + const len = this.getInt64(addr + 8); + return new Uint8Array(this.inst.exports.mem.buffer, array, len); + } + + loadSliceOfValues(addr) { + const array = this.getInt64(addr + 0); + const len = this.getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = this.loadValue(array + i * 8); + } + return a; + } + + loadString(addr) { + const saddr = this.getInt64(addr + 0); + const len = this.getInt64(addr + 8); + return decoder.decode(new DataView(this.inst.exports.mem.buffer, saddr, len)); + } + + importObject = { + go: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this.inst; + delete this.values; + delete this.refs; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + const fd = this.getInt64(sp + 8); + const p = this.getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + this.fs.writeSync(fd, new Uint8Array(this.inst.exports.mem.buffer, p, n)); + }, + + // func nanotime() int64 + "runtime.nanotime": (sp) => { + this.setInt64(sp + 8, (this.timeOrigin + performance.now()) * MS_IN_NANO); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + const msec = (new Date).getTime(); + this.setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * MS_IN_NANO, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + const id = this.nextCallbackTimeoutID; + this.nextCallbackTimeoutID++; + this.scheduledTimeouts.set(id, setTimeout( + () => { + this.resume(); + while (this.scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this.resume(); + } + }, + this.getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this.scheduledTimeouts.get(id)); + this.scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + crypto.getRandomValues(this.loadSlice(sp + 8)); + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + this.storeValue(sp + 24, this.loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + const result = Reflect.get(this.loadValue(sp + 8), this.loadString(sp + 16)); + sp = this.inst.exports.getsp(); // see comment above + this.storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + Reflect.set(this.loadValue(sp + 8), this.loadString(sp + 16), this.loadValue(sp + 32)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + this.storeValue(sp + 24, Reflect.get(this.loadValue(sp + 8), this.getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + Reflect.set(this.loadValue(sp + 8), this.getInt64(sp + 16), this.loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + try { + const v = this.loadValue(sp + 8); + const m = Reflect.get(v, this.loadString(sp + 16)); + const args = this.loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this.inst.exports.getsp(); // see comment above + this.storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + this.storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + try { + const v = this.loadValue(sp + 8); + const args = this.loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this.inst.exports.getsp(); // see comment above + this.storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + this.storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + try { + const v = this.loadValue(sp + 8); + const args = this.loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this.inst.exports.getsp(); // see comment above + this.storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + this.storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + this.setInt64(sp + 16, parseInt(this.loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + const str = encoder.encode(String(this.loadValue(sp + 8))); + this.storeValue(sp + 16, str); + this.setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + const str = this.loadValue(sp + 8); + this.loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + this.mem.setUint8(sp + 24, this.loadValue(sp + 8) instanceof this.loadValue(sp + 16)); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + const dst = this.loadSlice(sp + 8); + const src = this.loadValue(sp + 32); + if (!(src instanceof Uint8Array)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + this.setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + const dst = this.loadValue(sp + 8); + const src = this.loadSlice(sp + 16); + if (!(dst instanceof Uint8Array)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + this.setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + }, + }; + + async run(instance: WebAssembly.Instance) { + this.inst = instance; + this.values = [ // TODO: garbage collection + NaN, + 0, + null, + true, + false, + global, + this, + ]; + this.refs = new Map(); + this.exited = false; + + const mem = new DataView(this.inst.exports.mem.buffer); + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs: number[] = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + + const keys = Object.keys(this.env).sort(); + argvPtrs.push(keys.length); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + + const argv = offset; + argvPtrs.forEach((ptr) => { + mem.setUint32(offset, ptr, true); + mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + this.inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise && this._resolveExitPromise(); + } + await this.exitPromise; + } + + resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this.inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise && this._resolveExitPromise(); + } + } + + makeFuncWrapper(id) { + const go = this; + return function () { + // @ts-ignore + const event: any = { id: id, this: this, args: arguments }; + go.pendingEvent = event; + go.resume(); + return event.result; + }; + } + + exit(code = 0) { + if (code !== 0) { + console.warn('exit code: ', code); + } + } +} \ No newline at end of file diff --git a/web/src/services/go/stdio.ts b/web/src/services/go/stdio.ts new file mode 100644 index 00000000..6f0f9fda --- /dev/null +++ b/web/src/services/go/stdio.ts @@ -0,0 +1,44 @@ +/** + * Client-side environment for Go WASM programs + */ + +import { encoder, decoder } from './foundation'; +import {FileDescriptor, IWriter} from './fs'; +import {DispatchFn} from '../../store/dispatch'; +import {newProgramWriteAction} from '../../store/actions'; +import {EvalEventKind} from "../api"; + +export class ConsoleWriter { + outputBuf = ''; + private dispatchFn?: DispatchFn; + + setDispatchHook(fn: DispatchFn) { + this.dispatchFn = fn; + } + + private getWriter(kind: EvalEventKind) { + return { + write: (data: Uint8Array) => { + this.outputBuf += decoder.decode(data); + const nl = this.outputBuf.lastIndexOf('\n'); + if (nl != -1) { + const message = this.outputBuf.substr(0, nl); + console.log(message); + if (this.dispatchFn) { + this.dispatchFn(newProgramWriteAction({Message: message, Kind: kind, Delay: 0})); + } + this.outputBuf = this.outputBuf.substr(nl + 1); + } + return data.length; + } + }; + } + + get stdoutPipe(): IWriter { + return this.getWriter(EvalEventKind.Stdout); + } + + get stderrPipe(): IWriter { + return this.getWriter(EvalEventKind.Stderr); + } +} \ No newline at end of file diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index e00d8bea..e711a2fb 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -1,4 +1,4 @@ -import {CompilerResponse} from "../services/api"; +import {CompilerResponse, EvalEvent} from "../services/api"; import {MonacoState, RuntimeType} from './state'; export enum ActionType { @@ -9,7 +9,12 @@ export enum ActionType { COMPILE_RESULT = 'COMPILE_RESULT', TOGGLE_THEME = 'TOGGLE_THEME', BUILD_PARAMS_CHANGE = 'BUILD_PARAMS_CHANGE', - MONACO_SETTINGS_CHANGE = 'MONACO_SETTINGS_CHANGE' + MONACO_SETTINGS_CHANGE = 'MONACO_SETTINGS_CHANGE', + + // Special actions used by Go WASM bridge + EVAL_START = 'EVAL_START', + EVAL_EVENT = 'EVAL_EVENT', + EVAL_FINISH = 'EVAL_FINISH' } export interface Action { @@ -31,6 +36,8 @@ export type MonacoParamsChanges = { [k in keyof MonacoState | string]: T; }; +export const actionOf = (type: ActionType) => ({type}); + export const newImportFileAction = (fileName: string, contents: string) => ({ type: ActionType.IMPORT_FILE, @@ -77,4 +84,10 @@ export const newMonacoParamsChangeAction = (changes: MonacoParamsChanges) ({ type: ActionType.MONACO_SETTINGS_CHANGE, payload: changes + }); + +export const newProgramWriteAction = (event: EvalEvent) => + ({ + type: ActionType.EVAL_EVENT, + payload: event }); \ No newline at end of file diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index f65a8f65..cfa6814e 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -5,16 +5,17 @@ import { newBuildResultAction, newImportFileAction, newLoadingAction, - newToggleThemeAction + newToggleThemeAction, + Action } from './actions'; import {State} from "./state"; import client from '../services/api'; import config from '../services/config'; import {DEMO_CODE} from '../editor/props'; -type StateProvider = () => State -type DispatchFn = (Action) => any -type Dispatcher = (DispatchFn, StateProvider) => void +export type StateProvider = () => State +export type DispatchFn = (a: Action|any) => any +export type Dispatcher = (dispatch: DispatchFn, getState: StateProvider) => void ///////////////////////////// // Dispatchers // diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index f02dbb43..cce53e9d 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -10,7 +10,7 @@ import { MonacoState, RuntimeType } from './state'; -import { CompilerResponse } from '../services/api'; +import {CompilerResponse, EvalEvent} from '../services/api'; import localConfig from '../services/config' import {mapByAction} from './helpers'; @@ -60,9 +60,18 @@ const reducers = { [ActionType.ERROR]: (s: StatusState, a: Action) => { return {...s, loading: false, lastError: a.payload} }, - [ActionType.LOADING]: (s: StatusState, a: Action) => { + [ActionType.LOADING]: (s: StatusState, _: Action) => { return {...s, loading: true} }, + [ActionType.EVAL_START]: (s: StatusState, _: Action) => { + return {lastError: null, loading: true, events: []} + }, + [ActionType.EVAL_EVENT]: (s: StatusState, a: Action) => { + return {lastError: null, loading: true, events: s.events?.concat(a.payload)} + }, + [ActionType.EVAL_FINISH]: (s: StatusState, _: Action) => { + return {...s, loading: false} + }, }, {loading: false}), settings: mapByAction({ [ActionType.TOGGLE_THEME]: (s: SettingsState, a: Action) => { From f4cbbc1cffad4c37689177c1ce06b759ddd04a2a Mon Sep 17 00:00:00 2001 From: x1unix Date: Sat, 25 Jan 2020 16:22:21 +0200 Subject: [PATCH 06/30] go-wasm: add Go wasm to Storage adapter --- web/src/App.tsx | 7 ++++++- web/src/services/go/fs.ts | 11 +++++++++-- web/src/services/go/go.ts | 22 ++++++++++------------ web/src/services/go/index.ts | 35 +++++++++++++++++++++++++++++++++++ web/src/services/go/stdio.ts | 22 +++++++++------------- web/src/store/dispatch.ts | 21 +++++++++++++++++++-- 6 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 web/src/services/go/index.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 12046d96..81aaa012 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,15 +4,20 @@ import {Fabric} from 'office-ui-fabric-react/lib/Fabric'; import { ConnectedRouter } from 'connected-react-router'; import {Switch, Route} from "react-router-dom"; -import { configureStore } from './store'; +import { configureStore, createGoConsoleAdapter } from './store'; import { history } from './store/configure'; +import { bootstrapGo } from './services/go'; import Playground from './Playground'; import './App.css'; import config from './services/config' +// Configure store and import config from localStorage const store = configureStore(); config.sync(); +// Bootstrap Go and storage bridge +bootstrapGo(createGoConsoleAdapter(a => store.dispatch(a))); + function App() { return ( diff --git a/web/src/services/go/fs.ts b/web/src/services/go/fs.ts index 31d0eed7..7c08b6d0 100644 --- a/web/src/services/go/fs.ts +++ b/web/src/services/go/fs.ts @@ -5,6 +5,13 @@ export type FileDescriptor = number; export const STDOUT: FileDescriptor = 1; export const STDERR: FileDescriptor = 2; +export interface IFileSystem { + writeSync(fd: FileDescriptor, buf: Uint8Array): number + write(fd: FileDescriptor, buf: Uint8Array, offset: number, length: number, position: number, callback: NodeCallback) + open(path: string, flags, mode, callback) + fsync(fd, callback) +} + /** * IWriter is abstract writer interface */ @@ -14,9 +21,9 @@ export interface IWriter { } /** - * FileSystem is wrapper class for FS simulation + * FileSystemWrapper is wrapper class for FS simulation */ -export class FileSystem { +export class FileSystemWrapper { descriptors = new Map(); readonly constants = { O_WRONLY: -1, diff --git a/web/src/services/go/go.ts b/web/src/services/go/go.ts index 9f146d72..88fc1572 100644 --- a/web/src/services/go/go.ts +++ b/web/src/services/go/go.ts @@ -1,18 +1,19 @@ -import { FileSystem } from "./fs"; -import { ConsoleWriter } from "./stdio"; -import { encoder, decoder } from './foundation'; +import {IFileSystem} from "./fs"; +import {encoder, decoder} from './foundation'; const MAX_UINT32 = 4294967296; const NAN_HEAD = 0x7FF80000; const MS_IN_NANO = 1000000; +export interface Global extends Window { + fs: IFileSystem +} + export class Go { argv = ['js']; env: {[k: string]: string} = {}; timeOrigin = Date.now() - performance.now(); exited = false; - global: any; - private pendingEvent: any = null; private scheduledTimeouts = new Map(); private nextCallbackTimeoutID = 1; @@ -25,10 +26,7 @@ export class Go { private values: any[] = []; private refs = new Map(); - constructor(private fs: FileSystem) { - this.global = window; - this.global.fs = fs; - } + constructor(private global: Global) {} get mem() { // The buffer may change when requesting more memory. @@ -159,7 +157,7 @@ export class Go { const fd = this.getInt64(sp + 8); const p = this.getInt64(sp + 16); const n = this.mem.getInt32(sp + 24, true); - this.fs.writeSync(fd, new Uint8Array(this.inst.exports.mem.buffer, p, n)); + this.global.fs.writeSync(fd, new Uint8Array(this.inst.exports.mem.buffer, p, n)); }, // func nanotime() int64 @@ -298,7 +296,7 @@ export class Go { // func valueInstanceOf(v ref, t ref) bool "syscall/js.valueInstanceOf": (sp) => { - this.mem.setUint8(sp + 24, this.loadValue(sp + 8) instanceof this.loadValue(sp + 16)); + this.mem.setUint8(sp + 24, Number(this.loadValue(sp + 8) instanceof this.loadValue(sp + 16))); }, // func copyBytesToGo(dst []byte, src ref) (int, bool) @@ -335,7 +333,7 @@ export class Go { }, }; - async run(instance: WebAssembly.Instance) { + public async run(instance: WebAssembly.Instance) { this.inst = instance; this.values = [ // TODO: garbage collection NaN, diff --git a/web/src/services/go/index.ts b/web/src/services/go/index.ts new file mode 100644 index 00000000..686158bd --- /dev/null +++ b/web/src/services/go/index.ts @@ -0,0 +1,35 @@ +import { FileSystemWrapper } from './fs'; +import { Go, Global } from './go'; +import {StdioWrapper, ConsoleLogger} from './stdio'; + +let instance: Go; + +export const run = async(m: WebAssembly.Instance) => { + if (!instance) { + throw new Error('Go runner instance is not initialized'); + } + + return instance.run(m); +}; + +export const bootstrapGo = (logger: ConsoleLogger) => { + if (instance) { + // Skip double initialization + return; + } + + // Wrap Go's calls to os.Stdout and os.Stderr + const w = new StdioWrapper(logger); + const mocks = { + fs: new FileSystemWrapper(w.stdoutPipe, w.stderrPipe), + }; + + // Wrap global object to make it accessible to Go's wasm bridge + const globalWrapper = new Proxy(window as any, { + has: (obj, prop) => prop in obj || prop in mocks, + get: (obj, prop) => prop in obj ? obj[prop] : mocks[prop] + }); + + // Create instance + instance = new Go(globalWrapper); +}; \ No newline at end of file diff --git a/web/src/services/go/stdio.ts b/web/src/services/go/stdio.ts index 6f0f9fda..3be9425f 100644 --- a/web/src/services/go/stdio.ts +++ b/web/src/services/go/stdio.ts @@ -2,19 +2,18 @@ * Client-side environment for Go WASM programs */ -import { encoder, decoder } from './foundation'; -import {FileDescriptor, IWriter} from './fs'; -import {DispatchFn} from '../../store/dispatch'; -import {newProgramWriteAction} from '../../store/actions'; +import {decoder} from './foundation'; +import {IWriter} from './fs'; import {EvalEventKind} from "../api"; -export class ConsoleWriter { +export interface ConsoleLogger { + log(eventType: EvalEventKind, message: string) +} + +export class StdioWrapper { outputBuf = ''; - private dispatchFn?: DispatchFn; - setDispatchHook(fn: DispatchFn) { - this.dispatchFn = fn; - } + constructor(private logger: ConsoleLogger) {} private getWriter(kind: EvalEventKind) { return { @@ -23,10 +22,7 @@ export class ConsoleWriter { const nl = this.outputBuf.lastIndexOf('\n'); if (nl != -1) { const message = this.outputBuf.substr(0, nl); - console.log(message); - if (this.dispatchFn) { - this.dispatchFn(newProgramWriteAction({Message: message, Kind: kind, Delay: 0})); - } + this.logger.log(kind, message); this.outputBuf = this.outputBuf.substr(nl + 1); } return data.length; diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index cfa6814e..8ebd2972 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -6,10 +6,10 @@ import { newImportFileAction, newLoadingAction, newToggleThemeAction, - Action + Action, newProgramWriteAction } from './actions'; import {State} from "./state"; -import client from '../services/api'; +import client, {EvalEventKind} from '../services/api'; import config from '../services/config'; import {DEMO_CODE} from '../editor/props'; @@ -113,3 +113,20 @@ export const dispatchToggleTheme: Dispatcher = config.darkThemeEnabled = !darkMode; dispatch(newToggleThemeAction()) }; + + +////////////////////////////////// +// Adapters // +////////////////////////////////// + +export const createGoConsoleAdapter = (dispatch: DispatchFn) => + ({ + log: (eventType: EvalEventKind, message: string) => { + console.log('%s:\t%s', eventType, message); + dispatch(newProgramWriteAction({ + Kind: eventType, + Message: message, + Delay: 0, + })); + } + }); From 13f11a375c2a000f115abdcf26278fc98f25af14 Mon Sep 17 00:00:00 2001 From: x1unix Date: Sat, 25 Jan 2020 16:25:11 +0200 Subject: [PATCH 07/30] go-wasm: fix warnings --- web/src/services/go/go.ts | 2 +- web/src/services/go/stdio.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/services/go/go.ts b/web/src/services/go/go.ts index 88fc1572..f77b1aa8 100644 --- a/web/src/services/go/go.ts +++ b/web/src/services/go/go.ts @@ -167,7 +167,7 @@ export class Go { // func walltime() (sec int64, nsec int32) "runtime.walltime": (sp) => { - const msec = (new Date).getTime(); + const msec = (new Date()).getTime(); this.setInt64(sp + 8, msec / 1000); this.mem.setInt32(sp + 16, (msec % 1000) * MS_IN_NANO, true); }, diff --git a/web/src/services/go/stdio.ts b/web/src/services/go/stdio.ts index 3be9425f..330f3ddc 100644 --- a/web/src/services/go/stdio.ts +++ b/web/src/services/go/stdio.ts @@ -20,7 +20,7 @@ export class StdioWrapper { write: (data: Uint8Array) => { this.outputBuf += decoder.decode(data); const nl = this.outputBuf.lastIndexOf('\n'); - if (nl != -1) { + if (nl !== -1) { const message = this.outputBuf.substr(0, nl); this.logger.log(kind, message); this.outputBuf = this.outputBuf.substr(nl + 1); From 140738c9435dd10793e3bc8ed464cd05f8bd258f Mon Sep 17 00:00:00 2001 From: x1unix Date: Sun, 26 Jan 2020 21:23:41 +0200 Subject: [PATCH 08/30] server: add build service --- go.mod | 2 + go.sum | 3 + pkg/compiler/compiler.go | 94 +++++++++++++++++++++++++ pkg/compiler/error.go | 23 +++++++ pkg/storage/artifact.go | 33 +++++++++ pkg/storage/local.go | 143 +++++++++++++++++++++++++++++++++++++++ pkg/storage/storage.go | 15 ++++ 7 files changed, 313 insertions(+) create mode 100644 pkg/compiler/compiler.go create mode 100644 pkg/compiler/error.go create mode 100644 pkg/storage/artifact.go create mode 100644 pkg/storage/local.go create mode 100644 pkg/storage/storage.go diff --git a/go.mod b/go.mod index 64e8ea73..3299c55d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.13 require ( github.com/gorilla/mux v1.7.3 github.com/gorilla/websocket v1.4.1 + github.com/pkg/errors v0.8.1 + github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 go.uber.org/atomic v1.5.1 // indirect go.uber.org/multierr v1.4.0 // indirect go.uber.org/zap v1.13.0 diff --git a/go.sum b/go.sum index 5f2ded10..e942693e 100644 --- a/go.sum +++ b/go.sum @@ -11,12 +11,15 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 h1:hNna6Fi0eP1f2sMBe/rJicDmaHmoXGe1Ta84FPYHLuE= +github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5/go.mod h1:f1SCnEOt6sc3fOJfPQDRDzHOtSXuTtnz0ImG9kPRDV0= go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.5.1 h1:rsqfU5vBkVknbhUGbAUwQKR2H4ItV8tjJ+6kJX4cxHM= diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go new file mode 100644 index 00000000..dd8a03f8 --- /dev/null +++ b/pkg/compiler/compiler.go @@ -0,0 +1,94 @@ +package compiler + +import ( + "context" + "github.com/pkg/errors" + "github.com/x1unix/go-playground/pkg/storage" + "go.uber.org/zap" + "io" + "os" + "os/exec" +) + +var buildArgs = []string{ + "CGO_ENABLED=0", + "GOOS=js", + "GOARCH=wasm", +} + +type Result struct { + FileName string + Data io.ReadCloser +} + +type BuildService struct { + log *zap.SugaredLogger + storage storage.StoreProvider +} + +func NewBuildService(log *zap.SugaredLogger, store storage.StoreProvider) BuildService { + return BuildService{ + log: log.Named("builder"), + storage: store, + } +} + +func (s BuildService) buildSource(ctx context.Context, outputLocation, sourceLocation string) (io.ReadCloser, error) { + cmd := exec.CommandContext(ctx, "go", + "build", + "-o", + outputLocation, + sourceLocation, + ) + + cmd.Env = buildArgs + errPipe, err := cmd.StderrPipe() + if err != nil { + s.log.Errorw("failed to attach to go builder stdout", "err", err) + return nil, err + } + + defer errPipe.Close() + s.log.Debugw("starting go build", "command", cmd.Args) + if err := cmd.Start(); err != nil { + return nil, err + } + + if err := cmd.Wait(); err != nil { + return nil, newBuildError(errPipe, err) + } + + // build finishes, now let's get the wasm file + f, err := os.Open(outputLocation) + if err != nil { + return nil, errors.Wrap(err, "failed to open compiled WASM file") + } + + return f, nil +} + +func (s BuildService) Build(ctx context.Context, data []byte) (*Result, error) { + aid, err := storage.GetArtifactID(data) + if err != nil { + return nil, err + } + + result := &Result{FileName: aid.Ext(storage.ExtWasm)} + compiled, err := s.storage.GetItem(aid) + if err == nil { + // Just return precompiled result if data is cached already + result.Data = compiled + return result, nil + } + + if err != storage.ErrNotExists { + s.log.Errorw("failed to open cached file", "artifact", aid.String(), "err", err) + } + + _ = s.storage.CreateLocationAndDo(aid, data, func(wasmLocation, sourceLocation string) error { + result.Data, err = s.buildSource(ctx, wasmLocation, sourceLocation) + return nil + }) + + return result, err +} diff --git a/pkg/compiler/error.go b/pkg/compiler/error.go new file mode 100644 index 00000000..77a83b99 --- /dev/null +++ b/pkg/compiler/error.go @@ -0,0 +1,23 @@ +package compiler + +import ( + "io" + "io/ioutil" +) + +type BuildError struct { + message string +} + +func (e BuildError) Error() string { + return e.message +} + +func newBuildError(errPipe io.ReadCloser, baseErr error) BuildError { + data, err := ioutil.ReadAll(errPipe) + if err != nil { + return BuildError{message: baseErr.Error()} + } + + return BuildError{message: string(data)} +} diff --git a/pkg/storage/artifact.go b/pkg/storage/artifact.go new file mode 100644 index 00000000..ec518e3b --- /dev/null +++ b/pkg/storage/artifact.go @@ -0,0 +1,33 @@ +package storage + +import ( + "bytes" + "crypto/md5" +) + +const ( + ExtWasm = "wasm" + ExtGo = "go" +) + +// ArtifactID represents artifact ID +type ArtifactID string + +// Ext returns string with artifact ID and extension +func (a ArtifactID) Ext(ext string) string { + return string(a) + "." + ext +} + +func (a ArtifactID) String() string { + return string(a) +} + +func GetArtifactID(data []byte) (ArtifactID, error) { + h := md5.New() + if _, err := h.Write(bytes.TrimSpace(data)); err != nil { + return "", err + } + + fName := ArtifactID(h.Sum(nil)) + return fName, nil +} diff --git a/pkg/storage/local.go b/pkg/storage/local.go new file mode 100644 index 00000000..3873ef25 --- /dev/null +++ b/pkg/storage/local.go @@ -0,0 +1,143 @@ +package storage + +import ( + "context" + "io" + "io/ioutil" + "os" + "path/filepath" + "sync" + "time" + + "github.com/pkg/errors" + "github.com/tevino/abool" + "go.uber.org/zap" +) + +const ( + srcDirName = "src" + binDirName = "bin" + workDirName = "goplay-builds" + + maxCleanTime = time.Second * 10 +) + +type LocalStorage struct { + log *zap.SugaredLogger + useLock *sync.Mutex + dirty *abool.AtomicBool + workDir string + srcDir string + binDir string +} + +func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (*LocalStorage, error) { + workDir := filepath.Join(baseDir, workDirName) + if err := os.Mkdir(workDir, os.ModeDir); err != nil { + if !os.IsExist(err) { + return nil, errors.Wrap(err, "failed to create temporary build directory for WASM compiler") + } + } + + return &LocalStorage{ + workDir: workDir, + useLock: &sync.Mutex{}, + dirty: abool.NewBool(false), + log: log.Named("storage"), + binDir: filepath.Join(workDir, binDirName), + srcDir: filepath.Join(workDir, srcDirName), + }, nil +} + +func (s LocalStorage) getOutputLocation(id ArtifactID) string { + return filepath.Join(s.binDir, id.Ext(ExtWasm)) +} + +func (s LocalStorage) GetItem(id ArtifactID) (io.ReadCloser, error) { + fPath := s.getOutputLocation(id) + f, err := os.Open(fPath) + if os.IsNotExist(err) { + return nil, ErrNotExists + } + + return f, err +} + +func (s LocalStorage) CreateLocationAndDo(id ArtifactID, data []byte, cb Callback) error { + s.useLock.Lock() + defer s.useLock.Unlock() + s.dirty.Set() // mark storage as dirty + tmpSrcDir := filepath.Join(s.srcDir, id.String()) + if err := os.MkdirAll(tmpSrcDir, os.ModeDir); err != nil { + if !os.IsExist(err) { + s.log.Errorw("failed to create a temporary build directory", + "artifact", id.String(), + "dir", tmpSrcDir, + "err", err.Error(), + ) + return errors.Wrapf(err, "failed to create temporary build directory") + } + + s.log.Debugw("build directory already exists", "artifact", id.String()) + } + + wasmLocation := s.getOutputLocation(id) + goFileName := id.Ext(ExtGo) + srcFile := filepath.Join(tmpSrcDir, goFileName) + if err := ioutil.WriteFile(srcFile, data, 0644); err != nil { + s.log.Errorw( + "failed to save source file", + "artifact", id.String(), + "file", srcFile, + "err", err, + ) + + return errors.Wrapf(err, "failed to save source file %q", goFileName) + } + + return cb(wasmLocation, srcFile) +} + +func (s LocalStorage) clean() error { + if !s.dirty.IsSet() { + s.log.Debug("storage is not dirty, skipping") + return nil + } + + s.log.Debug("cleanup start") + t := time.AfterFunc(maxCleanTime, func() { + s.log.Warnf("cleanup took more than %.0f seconds!", maxCleanTime.Seconds()) + }) + s.useLock.Lock() + defer s.useLock.Unlock() + defer t.Stop() + + // cleanup sources and binaries + for _, dir := range []string{s.srcDir, s.binDir} { + if err := os.RemoveAll(dir); err != nil { + if os.IsNotExist(err) { + continue + } + return errors.Wrapf(err, "failed to remove %q", dir) + } + } + + s.dirty.UnSet() // remove dirty flag + return nil +} + +func (s LocalStorage) StartCleaner(ctx context.Context, interval time.Duration) { + for { + select { + case <-ctx.Done(): + s.log.Debug("context done, cleanup exit") + return + default: + } + + <-time.After(interval) + if err := s.clean(); err != nil { + s.log.Error(err) + } + } +} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go new file mode 100644 index 00000000..2cc2aa77 --- /dev/null +++ b/pkg/storage/storage.go @@ -0,0 +1,15 @@ +package storage + +import ( + "errors" + "io" +) + +var ErrNotExists = errors.New("item not exists") + +type Callback = func(wasmLocation, sourceLocation string) error + +type StoreProvider interface { + GetItem(id ArtifactID) (io.ReadCloser, error) + CreateLocationAndDo(id ArtifactID, data []byte, cb Callback) error +} From d5ce6bd6ec74aa696ea747d531c222f2801b2c11 Mon Sep 17 00:00:00 2001 From: x1unix Date: Sun, 26 Jan 2020 22:45:35 +0200 Subject: [PATCH 09/30] server: fix some storage errors and add tests --- pkg/compiler/compiler.go | 2 +- pkg/{ => compiler}/storage/artifact.go | 5 +- pkg/{ => compiler}/storage/local.go | 17 +++-- pkg/compiler/storage/local_test.go | 98 ++++++++++++++++++++++++++ pkg/{ => compiler}/storage/storage.go | 0 5 files changed, 115 insertions(+), 7 deletions(-) rename pkg/{ => compiler}/storage/artifact.go (85%) rename pkg/{ => compiler}/storage/local.go (86%) create mode 100644 pkg/compiler/storage/local_test.go rename pkg/{ => compiler}/storage/storage.go (100%) diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go index dd8a03f8..d17e5076 100644 --- a/pkg/compiler/compiler.go +++ b/pkg/compiler/compiler.go @@ -3,7 +3,7 @@ package compiler import ( "context" "github.com/pkg/errors" - "github.com/x1unix/go-playground/pkg/storage" + "github.com/x1unix/go-playground/pkg/compiler/storage" "go.uber.org/zap" "io" "os" diff --git a/pkg/storage/artifact.go b/pkg/compiler/storage/artifact.go similarity index 85% rename from pkg/storage/artifact.go rename to pkg/compiler/storage/artifact.go index ec518e3b..7ef84e99 100644 --- a/pkg/storage/artifact.go +++ b/pkg/compiler/storage/artifact.go @@ -3,6 +3,7 @@ package storage import ( "bytes" "crypto/md5" + "encoding/hex" ) const ( @@ -28,6 +29,6 @@ func GetArtifactID(data []byte) (ArtifactID, error) { return "", err } - fName := ArtifactID(h.Sum(nil)) - return fName, nil + fName := hex.EncodeToString(h.Sum(nil)) + return ArtifactID(fName), nil } diff --git a/pkg/storage/local.go b/pkg/compiler/storage/local.go similarity index 86% rename from pkg/storage/local.go rename to pkg/compiler/storage/local.go index 3873ef25..4d522306 100644 --- a/pkg/storage/local.go +++ b/pkg/compiler/storage/local.go @@ -20,12 +20,14 @@ const ( workDirName = "goplay-builds" maxCleanTime = time.Second * 10 + perm = 0777 ) type LocalStorage struct { log *zap.SugaredLogger useLock *sync.Mutex dirty *abool.AtomicBool + gcRun *abool.AtomicBool workDir string srcDir string binDir string @@ -33,7 +35,7 @@ type LocalStorage struct { func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (*LocalStorage, error) { workDir := filepath.Join(baseDir, workDirName) - if err := os.Mkdir(workDir, os.ModeDir); err != nil { + if err := os.MkdirAll(workDir, perm); err != nil { if !os.IsExist(err) { return nil, errors.Wrap(err, "failed to create temporary build directory for WASM compiler") } @@ -43,6 +45,7 @@ func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (*LocalStorage, err workDir: workDir, useLock: &sync.Mutex{}, dirty: abool.NewBool(false), + gcRun: abool.NewBool(false), log: log.Named("storage"), binDir: filepath.Join(workDir, binDirName), srcDir: filepath.Join(workDir, srcDirName), @@ -68,7 +71,7 @@ func (s LocalStorage) CreateLocationAndDo(id ArtifactID, data []byte, cb Callbac defer s.useLock.Unlock() s.dirty.Set() // mark storage as dirty tmpSrcDir := filepath.Join(s.srcDir, id.String()) - if err := os.MkdirAll(tmpSrcDir, os.ModeDir); err != nil { + if err := os.MkdirAll(tmpSrcDir, perm); err != nil { if !os.IsExist(err) { s.log.Errorw("failed to create a temporary build directory", "artifact", id.String(), @@ -84,7 +87,7 @@ func (s LocalStorage) CreateLocationAndDo(id ArtifactID, data []byte, cb Callbac wasmLocation := s.getOutputLocation(id) goFileName := id.Ext(ExtGo) srcFile := filepath.Join(tmpSrcDir, goFileName) - if err := ioutil.WriteFile(srcFile, data, 0644); err != nil { + if err := ioutil.WriteFile(srcFile, data, perm); err != nil { s.log.Errorw( "failed to save source file", "artifact", id.String(), @@ -120,17 +123,23 @@ func (s LocalStorage) clean() error { } return errors.Wrapf(err, "failed to remove %q", dir) } + + s.log.Debugf("cleaner: removed directory %q", dir) } s.dirty.UnSet() // remove dirty flag + s.log.Debug("cleaner: cleanup end") return nil } func (s LocalStorage) StartCleaner(ctx context.Context, interval time.Duration) { + s.gcRun.Set() + s.log.Debug("cleaner worker starter") for { select { case <-ctx.Done(): - s.log.Debug("context done, cleanup exit") + s.log.Debug("context done, cleaner worker stopped") + s.gcRun.UnSet() return default: } diff --git a/pkg/compiler/storage/local_test.go b/pkg/compiler/storage/local_test.go new file mode 100644 index 00000000..e00a883c --- /dev/null +++ b/pkg/compiler/storage/local_test.go @@ -0,0 +1,98 @@ +package storage + +import ( + "context" + "errors" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zaptest" + "io/ioutil" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +var testDir = os.TempDir() + +func TestLocalStorage_GetItem(t *testing.T) { + r := require.New(t) + s, err := NewLocalStorage(zaptest.NewLogger(t).Sugar(), testDir) + r.NoError(err, "failed to create test storage") + r.Falsef(s.dirty.IsSet(), "dirty flag is not false") + expectData := []byte("foo") + aid, err := GetArtifactID(expectData) + must(t, err, "failed to create a test artifact ID") + + _, err = s.GetItem(aid) + r.EqualError(err, ErrNotExists.Error(), "got unexpected error type") + + // Start trash collector in background + ctx, cancelFunc := context.WithCancel(context.Background()) + cleanInterval := time.Second * 2 + go s.StartCleaner(ctx, cleanInterval) + defer cancelFunc() + runtime.Gosched() // Ask Go to switch to cleaner goroutine + r.True(s.gcRun.IsSet(), "gc start flag not true") + + // Create some data + expErr := errors.New("create error") + err = s.CreateLocationAndDo(aid, expectData, func(wasmLocation, sourceLocation string) error { + strEndsWith(t, wasmLocation, ExtWasm) + strEndsWith(t, sourceLocation, ExtGo) + t.Logf("\nWASM:\t%s\nSRC:\t%s", wasmLocation, sourceLocation) + f, err := os.Open(sourceLocation) + r.NoError(err, "failed to open source file") + data, err := ioutil.ReadAll(f) + r.NoError(err, "failed to read test file") + + r.Equal(data, expectData, "input and result don't match") + + must(t, os.Mkdir(filepath.Join(s.binDir), perm), "failed to create bin dir") + err = ioutil.WriteFile(wasmLocation, expectData, perm) + must(t, err, "failed to write dest file") + return expErr + }) + + // Check storage dirty state + r.EqualError(err, expErr.Error(), "expected and returned error mismatch") + r.True(s.dirty.IsSet(), "dirty flag should be true after file manipulation") + + // Try to get item from storage + dataFile, err := s.GetItem(aid) + defer dataFile.Close() + r.NoError(err, "failed to get saved cached data") + + contents, err := ioutil.ReadAll(dataFile) + must(t, err, "failed to read saved file") + r.Equal(expectData, contents) + + // Trash collector should clean all our garbage after some time + runtime.Gosched() + time.Sleep(cleanInterval + time.Second) + r.False(s.dirty.IsSet(), "storage is still dirty after cleanup") + _, err = s.GetItem(aid) + r.Error(err, "test item was not removed after cleanup") + r.EqualError(err, ErrNotExists.Error(), "should return ErrNotExists") + cancelFunc() + + // Ensure that collector stopped after context done + time.Sleep(cleanInterval) + r.False(s.gcRun.IsSet(), "collector not stopped after context death") + + must(t, os.RemoveAll(testDir), "failed to remove test dir after exit") +} + +func must(t *testing.T, err error, msg string) { + if err == nil { + return + } + t.Helper() + t.Fatalf("test internal error:\t%s - %s", msg, err) +} + +func strEndsWith(t *testing.T, str string, suffix string) { + t.Helper() + got := str[len(str)-len(suffix):] + require.Equal(t, suffix, got) +} diff --git a/pkg/storage/storage.go b/pkg/compiler/storage/storage.go similarity index 100% rename from pkg/storage/storage.go rename to pkg/compiler/storage/storage.go From 9961cef7d27f92416128dccf8ed4b6ad6e2b5749 Mon Sep 17 00:00:00 2001 From: x1unix Date: Sun, 26 Jan 2020 22:47:48 +0200 Subject: [PATCH 10/30] storage: change default permissions --- go.mod | 1 + go.sum | 4 ++++ pkg/compiler/storage/local.go | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 3299c55d..279d33ed 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gorilla/mux v1.7.3 github.com/gorilla/websocket v1.4.1 github.com/pkg/errors v0.8.1 + github.com/stretchr/testify v1.4.0 github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 go.uber.org/atomic v1.5.1 // indirect go.uber.org/multierr v1.4.0 // indirect diff --git a/go.sum b/go.sum index e942693e..ebed6020 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= @@ -13,10 +14,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 h1:hNna6Fi0eP1f2sMBe/rJicDmaHmoXGe1Ta84FPYHLuE= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5/go.mod h1:f1SCnEOt6sc3fOJfPQDRDzHOtSXuTtnz0ImG9kPRDV0= @@ -61,6 +64,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/pkg/compiler/storage/local.go b/pkg/compiler/storage/local.go index 4d522306..0bad3dfe 100644 --- a/pkg/compiler/storage/local.go +++ b/pkg/compiler/storage/local.go @@ -20,7 +20,7 @@ const ( workDirName = "goplay-builds" maxCleanTime = time.Second * 10 - perm = 0777 + perm = 0744 ) type LocalStorage struct { From 5b99b9c19c2ea5321b808962221804fa3c25989b Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 02:33:56 +0200 Subject: [PATCH 11/30] compiler: add to handler --- cmd/playground/main.go | 16 ++++-- pkg/compiler/compiler.go | 18 ++++--- pkg/compiler/error.go | 25 +++++---- pkg/goplay/client.go | 2 +- pkg/goplay/methods.go | 8 +-- pkg/langserver/server.go | 108 +++++++++++++++++++++++++++++++++------ 6 files changed, 129 insertions(+), 48 deletions(-) diff --git a/cmd/playground/main.go b/cmd/playground/main.go index 338843db..d7bfeae4 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -5,6 +5,8 @@ import ( "fmt" "github.com/gorilla/mux" "github.com/x1unix/go-playground/pkg/analyzer" + "github.com/x1unix/go-playground/pkg/compiler" + "github.com/x1unix/go-playground/pkg/compiler/storage" "github.com/x1unix/go-playground/pkg/langserver" "go.uber.org/zap" "log" @@ -16,8 +18,10 @@ func main() { var packagesFile string var addr string var debug bool + var buildDir string flag.StringVar(&packagesFile, "f", "packages.json", "Path to packages index JSON file") flag.StringVar(&addr, "addr", ":8080", "TCP Listen address") + flag.StringVar(&buildDir, "wasm-build-dir", os.TempDir(), "Directory for WASM builds") flag.BoolVar(&debug, "debug", false, "Enable debug mode") goRoot, ok := os.LookupEnv("GOROOT") @@ -29,7 +33,7 @@ func main() { flag.Parse() l := getLogger(debug) defer l.Sync() - if err := start(packagesFile, addr, goRoot, debug); err != nil { + if err := start(packagesFile, addr, goRoot, buildDir, debug); err != nil { l.Sugar().Fatal(err) } } @@ -51,7 +55,7 @@ func getLogger(debug bool) (l *zap.Logger) { return l } -func start(packagesFile, addr, goRoot string, debug bool) error { +func start(packagesFile, addr, goRoot, buildDir string, debug bool) error { zap.S().Infof("GOROOT is %q", goRoot) zap.S().Infof("Packages file is %q", packagesFile) analyzer.SetRoot(goRoot) @@ -60,8 +64,14 @@ func start(packagesFile, addr, goRoot string, debug bool) error { return fmt.Errorf("failed to read packages file %q: %s", packagesFile, err) } + store, err := storage.NewLocalStorage(zap.S(), buildDir) + if err != nil { + return err + } + r := mux.NewRouter() - langserver.New(packages).Mount(r.PathPrefix("/api").Subrouter()) + langserver.New(packages, compiler.NewBuildService(zap.S(), store)). + Mount(r.PathPrefix("/api").Subrouter()) r.PathPrefix("/").Handler(langserver.SpaFileServer("./public")) zap.S().Infof("Listening on %q", addr) diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go index d17e5076..a6cedc7c 100644 --- a/pkg/compiler/compiler.go +++ b/pkg/compiler/compiler.go @@ -1,6 +1,7 @@ package compiler import ( + "bytes" "context" "github.com/pkg/errors" "github.com/x1unix/go-playground/pkg/compiler/storage" @@ -14,6 +15,7 @@ var buildArgs = []string{ "CGO_ENABLED=0", "GOOS=js", "GOARCH=wasm", + "HOME=" + os.Getenv("HOME"), } type Result struct { @@ -42,20 +44,19 @@ func (s BuildService) buildSource(ctx context.Context, outputLocation, sourceLoc ) cmd.Env = buildArgs - errPipe, err := cmd.StderrPipe() - if err != nil { - s.log.Errorw("failed to attach to go builder stdout", "err", err) - return nil, err - } + buff := &bytes.Buffer{} + cmd.Stderr = buff - defer errPipe.Close() - s.log.Debugw("starting go build", "command", cmd.Args) + s.log.Debugw("starting go build", "command", cmd.Args, "env", cmd.Env) if err := cmd.Start(); err != nil { return nil, err } if err := cmd.Wait(); err != nil { - return nil, newBuildError(errPipe, err) + errMsg := buff.String() + s.log.Debugw("build failed", "err", err, "stderr", errMsg) + return nil, newBuildError(errMsg) + //return nil, newBuildError(errPipe, err) } // build finishes, now let's get the wasm file @@ -77,6 +78,7 @@ func (s BuildService) Build(ctx context.Context, data []byte) (*Result, error) { compiled, err := s.storage.GetItem(aid) if err == nil { // Just return precompiled result if data is cached already + s.log.Debugw("build cached, returning cached data", "artifact", aid.String()) result.Data = compiled return result, nil } diff --git a/pkg/compiler/error.go b/pkg/compiler/error.go index 77a83b99..9f83d521 100644 --- a/pkg/compiler/error.go +++ b/pkg/compiler/error.go @@ -1,23 +1,22 @@ package compiler -import ( - "io" - "io/ioutil" -) - type BuildError struct { message string } -func (e BuildError) Error() string { +func (e *BuildError) Error() string { return e.message } -func newBuildError(errPipe io.ReadCloser, baseErr error) BuildError { - data, err := ioutil.ReadAll(errPipe) - if err != nil { - return BuildError{message: baseErr.Error()} - } - - return BuildError{message: string(data)} +func newBuildError(msg string) *BuildError { + return &BuildError{message: msg} } + +//func newBuildError(errPipe io.ReadCloser, baseErr error) *BuildError { +// data, err := ioutil.ReadAll(errPipe) +// if err != nil { +// return &BuildError{message: baseErr.Error()} +// } +// +// return &BuildError{message: string(data)} +//} diff --git a/pkg/goplay/client.go b/pkg/goplay/client.go index dba3113e..f4461200 100644 --- a/pkg/goplay/client.go +++ b/pkg/goplay/client.go @@ -58,7 +58,7 @@ func doRequest(ctx context.Context, method, url, contentType string, body io.Rea if err != nil { return nil, err } - if err = ValidateContentLength(bodyBytes); err != nil { + if err = ValidateContentLength(bodyBytes.Len()); err != nil { return nil, err } diff --git a/pkg/goplay/methods.go b/pkg/goplay/methods.go index 8802600e..cfc8e5d2 100644 --- a/pkg/goplay/methods.go +++ b/pkg/goplay/methods.go @@ -11,12 +11,8 @@ import ( "net/url" ) -type lener interface { - Len() int -} - -func ValidateContentLength(r lener) error { - if r.Len() > maxSnippetSize { +func ValidateContentLength(itemLen int) error { + if itemLen > maxSnippetSize { return ErrSnippetTooLarge } diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 9d587b52..1cfd75f1 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -1,34 +1,55 @@ package langserver import ( + "context" "fmt" + "github.com/x1unix/go-playground/pkg/compiler" + "io" + "io/ioutil" + "net/http" + "strconv" + "time" + "github.com/gorilla/mux" "github.com/x1unix/go-playground/pkg/analyzer" "github.com/x1unix/go-playground/pkg/goplay" "go.uber.org/zap" - "io/ioutil" - "net/http" + "golang.org/x/time/rate" +) + +const ( + // limit for wasm compile requests per second (250ms per request) + compileRequestsPerFrame = 4 + frameTime = time.Second + maxBuildTimeDuration = time.Second * 30 + + wasmMimeType = "application/wasm" ) type Service struct { - log *zap.SugaredLogger - index analyzer.PackageIndex + log *zap.SugaredLogger + index analyzer.PackageIndex + compiler compiler.BuildService + limiter *rate.Limiter } -func New(packages []*analyzer.Package) *Service { +func New(packages []*analyzer.Package, builder compiler.BuildService) *Service { return &Service{ - log: zap.S().Named("langserver"), - index: analyzer.BuildPackageIndex(packages), + compiler: builder, + log: zap.S().Named("langserver"), + index: analyzer.BuildPackageIndex(packages), + limiter: rate.NewLimiter(rate.Every(frameTime), compileRequestsPerFrame), } } // Mount mounts service on route func (s *Service) Mount(r *mux.Router) { - r.Path("/suggest").HandlerFunc(s.GetSuggestion) - r.Path("/compile").Methods(http.MethodPost).HandlerFunc(s.Compile) - r.Path("/format").Methods(http.MethodPost).HandlerFunc(s.FormatCode) - r.Path("/share").Methods(http.MethodPost).HandlerFunc(s.Share) - r.Path("/snippet/{id}").Methods(http.MethodGet).HandlerFunc(s.GetSnippet) + r.Path("/suggest").HandlerFunc(s.HandleGetSuggestion) + r.Path("/run").Methods(http.MethodPost).HandlerFunc(s.HandleRunCode) + r.Path("/compile").Methods(http.MethodPost).HandlerFunc(s.HandleCompile) + r.Path("/format").Methods(http.MethodPost).HandlerFunc(s.HandleFormatCode) + r.Path("/share").Methods(http.MethodPost).HandlerFunc(s.HandleShare) + r.Path("/snippet/{id}").Methods(http.MethodGet).HandlerFunc(s.HandleGetSnippet) } func (s *Service) lookupBuiltin(val string) (*SuggestionsResponse, error) { @@ -75,7 +96,7 @@ func (s *Service) provideSuggestion(req SuggestionRequest) (*SuggestionsResponse return s.lookupBuiltin(req.Value) } -func (s *Service) GetSuggestion(w http.ResponseWriter, r *http.Request) { +func (s *Service) HandleGetSuggestion(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() value := q.Get("value") pkgName := q.Get("packageName") @@ -89,7 +110,14 @@ func (s *Service) GetSuggestion(w http.ResponseWriter, r *http.Request) { resp.Write(w) } +// goImportsCode reads code from request and performs "goimports" on it +// if any error occurs, it sends error response to client and closes connection func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, error, bool) { + if err := goplay.ValidateContentLength(int(r.ContentLength)); err != nil { + Errorf(http.StatusRequestEntityTooLarge, err.Error()).Write(w) + return nil, err, false + } + src, err := ioutil.ReadAll(r.Body) if err != nil { Errorf(http.StatusBadGateway, "failed to read request: %s", err).Write(w) @@ -117,7 +145,7 @@ func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, return []byte(resp.Body), nil, changed } -func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { +func (s *Service) HandleFormatCode(w http.ResponseWriter, r *http.Request) { code, err, _ := s.goImportsCode(w, r) if err != nil { if goplay.IsCompileError(err) { @@ -131,7 +159,7 @@ func (s *Service) FormatCode(w http.ResponseWriter, r *http.Request) { WriteJSON(w, CompilerResponse{Formatted: string(code)}) } -func (s *Service) Share(w http.ResponseWriter, r *http.Request) { +func (s *Service) HandleShare(w http.ResponseWriter, r *http.Request) { shareID, err := goplay.Share(r.Context(), r.Body) defer r.Body.Close() if err != nil { @@ -147,7 +175,7 @@ func (s *Service) Share(w http.ResponseWriter, r *http.Request) { WriteJSON(w, ShareResponse{SnippetID: shareID}) } -func (s *Service) GetSnippet(w http.ResponseWriter, r *http.Request) { +func (s *Service) HandleGetSnippet(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) snippetID := vars["id"] snippet, err := goplay.GetSnippet(r.Context(), snippetID) @@ -171,7 +199,7 @@ func (s *Service) GetSnippet(w http.ResponseWriter, r *http.Request) { }) } -func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { +func (s *Service) HandleRunCode(w http.ResponseWriter, r *http.Request) { src, err, changed := s.goImportsCode(w, r) if err != nil { if goplay.IsCompileError(err) { @@ -202,3 +230,49 @@ func (s *Service) Compile(w http.ResponseWriter, r *http.Request) { s.log.Debugw("response from compiler", "res", res) WriteJSON(w, result) } + +func (s *Service) HandleCompile(w http.ResponseWriter, r *http.Request) { + // Limit for request timeout + ctx, _ := context.WithDeadline(r.Context(), time.Now().Add(maxBuildTimeDuration)) + + // Wait for our queue in line for compilation + if err := s.limiter.Wait(ctx); err != nil { + Errorf(http.StatusTooManyRequests, err.Error()).Write(w) + return + } + + data, err, _ := s.goImportsCode(w, r) + if err != nil { + if goplay.IsCompileError(err) { + return + } + + s.log.Error(err) + return + } + + result, err := s.compiler.Build(ctx, data) + if err != nil { + if compileErr, ok := err.(*compiler.BuildError); ok { + Errorf(http.StatusBadRequest, compileErr.Error()).Write(w) + return + } + + Errorf(http.StatusBadRequest, err.Error()).Write(w) + return + } + + n, err := io.Copy(w, result.Data) + if err != nil { + s.log.Errorw("failed to send WASM response", + "file", result.FileName, + "err", err, + ) + NewErrorResponse(err).Write(w) + return + } + + w.Header().Set("Content-Type", wasmMimeType) + w.Header().Set("Content-Length", strconv.FormatInt(n, 10)) + w.WriteHeader(http.StatusOK) +} From 8d8bbe4b599b7d4cb9dd9e9009ab613494c54f7e Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 03:19:47 +0200 Subject: [PATCH 12/30] server: fix response status for wasm --- go.mod | 2 +- go.sum | 7 +++++-- pkg/langserver/server.go | 1 - 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 279d33ed..a296394f 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.13 require ( github.com/gorilla/mux v1.7.3 - github.com/gorilla/websocket v1.4.1 github.com/pkg/errors v0.8.1 github.com/stretchr/testify v1.4.0 github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 @@ -12,5 +11,6 @@ require ( go.uber.org/multierr v1.4.0 // indirect go.uber.org/zap v1.13.0 golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f // indirect + golang.org/x/time v0.0.0-20191024005414-555d28b269f0 golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69 // indirect ) diff --git a/go.sum b/go.sum index ebed6020..e6ec0a71 100644 --- a/go.sum +++ b/go.sum @@ -6,11 +6,11 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= -github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= -github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -51,6 +51,8 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0 h1:/5xXl8Y5W96D+TtHSlonuFqGHIWVuyCkGJLwGh9JJFs= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -62,6 +64,7 @@ golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapK golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 1cfd75f1..821ee9c3 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -274,5 +274,4 @@ func (s *Service) HandleCompile(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", wasmMimeType) w.Header().Set("Content-Length", strconv.FormatInt(n, 10)) - w.WriteHeader(http.StatusOK) } From 45c8ca87d21859c4f51362b96963e8a6e41088cf Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 03:20:02 +0200 Subject: [PATCH 13/30] ui: run wasm go --- web/src/services/api.ts | 34 +++++++++++++++++++++++++++----- web/src/services/go/go.ts | 18 ++++++++--------- web/src/services/go/index.ts | 6 ++++-- web/src/store/dispatch.ts | 38 ++++++++++++++++++++++++++---------- web/src/store/reducers.ts | 4 ++-- 5 files changed, 72 insertions(+), 28 deletions(-) diff --git a/web/src/services/api.ts b/web/src/services/api.ts index dfc3ab6c..7c1d58a6 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -3,8 +3,8 @@ import {AxiosInstance} from "axios"; import * as monaco from "monaco-editor"; import config from './config'; -const apiAddress = config.serverUrl; -const axiosClient = axios.default.create({baseURL: `${apiAddress}/api`}); +const apiAddress = `${config.serverUrl}/api`; +const axiosClient = axios.default.create({baseURL: apiAddress}); export enum EvalEventKind { Stdout = 'stdout', @@ -38,8 +38,18 @@ export interface IAPIClient { formatCode(code: string): Promise getSnippet(id: string): Promise shareSnippet(code: string): Promise + compileToWasm(code: string): Promise } +export const instantiateStreaming = async (resp, importObject) => { + if ('instantiateStreaming' in WebAssembly) { + return await WebAssembly.instantiateStreaming(resp, importObject); + } + + const source = await (await resp).arrayBuffer(); + return await WebAssembly.instantiate(source, importObject); +}; + class Client implements IAPIClient { get axiosClient() { return this.client; @@ -52,8 +62,22 @@ class Client implements IAPIClient { return this.get(`/suggest?${queryParams}`); } + async compileToWasm(code: string): Promise { + const resp = await fetch(`${apiAddress}/compile`, { + method: 'POST', + body: code, + }); + + if (resp.status >= 300) { + const err = await resp.json(); + throw new Error(err.message ?? resp.statusText); + } + + return resp; + } + async evaluateCode(code: string): Promise { - return this.post('/compile', code); + return this.post('/run', code); } async formatCode(code: string): Promise { @@ -77,9 +101,9 @@ class Client implements IAPIClient { } } - private async post(uri: string, data: any): Promise { + private async post(uri: string, data: any, cfg?: axios.AxiosRequestConfig): Promise { try { - const resp = await this.client.post(uri, data); + const resp = await this.client.post(uri, data, cfg); return resp.data; } catch(err) { throw Client.extractAPIError(err); diff --git a/web/src/services/go/go.ts b/web/src/services/go/go.ts index f77b1aa8..6810a38e 100644 --- a/web/src/services/go/go.ts +++ b/web/src/services/go/go.ts @@ -14,11 +14,12 @@ export class Go { env: {[k: string]: string} = {}; timeOrigin = Date.now() - performance.now(); exited = false; + private lastExitCode = 0; private pendingEvent: any = null; private scheduledTimeouts = new Map(); private nextCallbackTimeoutID = 1; - private _resolveExitPromise?: (val?: any) => void; - private exitPromise = new Promise((resolve) => { + private _resolveExitPromise?: (exitCode: number) => void; + private exitPromise = new Promise((resolve) => { this._resolveExitPromise = resolve; }); @@ -333,7 +334,8 @@ export class Go { }, }; - public async run(instance: WebAssembly.Instance) { + public async run(instance: WebAssembly.Instance): Promise { + this.lastExitCode = 0; this.inst = instance; this.values = [ // TODO: garbage collection NaN, @@ -385,9 +387,9 @@ export class Go { this.inst.exports.run(argc, argv); if (this.exited) { - this._resolveExitPromise && this._resolveExitPromise(); + this._resolveExitPromise && this._resolveExitPromise(this.lastExitCode); } - await this.exitPromise; + return await this.exitPromise; } resume() { @@ -396,7 +398,7 @@ export class Go { } this.inst.exports.resume(); if (this.exited) { - this._resolveExitPromise && this._resolveExitPromise(); + this._resolveExitPromise && this._resolveExitPromise(this.lastExitCode); } } @@ -412,8 +414,6 @@ export class Go { } exit(code = 0) { - if (code !== 0) { - console.warn('exit code: ', code); - } + this.lastExitCode = code; } } \ No newline at end of file diff --git a/web/src/services/go/index.ts b/web/src/services/go/index.ts index 686158bd..e3c1394f 100644 --- a/web/src/services/go/index.ts +++ b/web/src/services/go/index.ts @@ -4,14 +4,16 @@ import {StdioWrapper, ConsoleLogger} from './stdio'; let instance: Go; -export const run = async(m: WebAssembly.Instance) => { +export const goRun = async(m: WebAssembly.WebAssemblyInstantiatedSource) => { if (!instance) { throw new Error('Go runner instance is not initialized'); } - return instance.run(m); + return instance.run(m.instance); }; +export const getImportObject = () => instance.importObject; + export const bootstrapGo = (logger: ConsoleLogger) => { if (instance) { // Skip double initialization diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 8ebd2972..76fa307a 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -1,17 +1,20 @@ -import { saveAs } from 'file-saver'; -import { push } from 'connected-react-router'; +import {saveAs} from 'file-saver'; +import {push} from 'connected-react-router'; import { - newErrorAction, + Action, + ActionType, newBuildResultAction, + newErrorAction, newImportFileAction, newLoadingAction, - newToggleThemeAction, - Action, newProgramWriteAction + newProgramWriteAction, + newToggleThemeAction } from './actions'; -import {State} from "./state"; -import client, {EvalEventKind} from '../services/api'; +import {RuntimeType, State} from "./state"; +import client, {EvalEventKind, instantiateStreaming} from '../services/api'; import config from '../services/config'; import {DEMO_CODE} from '../editor/props'; +import {getImportObject, goRun} from '../services/go'; export type StateProvider = () => State export type DispatchFn = (a: Action|any) => any @@ -84,9 +87,24 @@ export const runFileDispatcher: Dispatcher = async (dispatch: DispatchFn, getState: StateProvider) => { dispatch(newLoadingAction()); try { - const {code} = getState().editor; - const res = await client.evaluateCode(code); - dispatch(newBuildResultAction(res)); + const { settings, editor } = getState(); + switch (settings.runtime) { + case RuntimeType.GoPlayground: + const res = await client.evaluateCode(editor.code); + dispatch(newBuildResultAction(res)); + break; + case RuntimeType.WebAssembly: + let resp = await client.compileToWasm(editor.code); + let instance = await instantiateStreaming(resp, getImportObject()); + dispatch({type: ActionType.EVAL_START}); + goRun(instance) + .then(result => console.log('exit code: %d', result)) + .catch(err => console.log('err', err)) + .finally(() => dispatch({type: ActionType.EVAL_FINISH})); + break; + default: + dispatch(newErrorAction(`AppError: Unknown Go runtime type "${settings.runtime}"`)); + } } catch (err) { dispatch(newErrorAction(err.message)); } diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index cce53e9d..b025cd0e 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -64,10 +64,10 @@ const reducers = { return {...s, loading: true} }, [ActionType.EVAL_START]: (s: StatusState, _: Action) => { - return {lastError: null, loading: true, events: []} + return {lastError: null, loading: false, events: []} }, [ActionType.EVAL_EVENT]: (s: StatusState, a: Action) => { - return {lastError: null, loading: true, events: s.events?.concat(a.payload)} + return {lastError: null, loading: false, events: s.events?.concat(a.payload)} }, [ActionType.EVAL_FINISH]: (s: StatusState, _: Action) => { return {...s, loading: false} From 76713e92179b453eb190f796c06b422662d75be7 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 04:22:52 +0200 Subject: [PATCH 14/30] go-wasm: use patched "wasm_exec.js" from Go SDK --- web/src/services/go/go.ts | 89 +++--- web/src/services/go/index.ts | 40 ++- web/src/services/go/stdio.ts | 4 + web/src/services/go/wasm_exec.js | 454 +++++++++++++++++++++++++++++++ 4 files changed, 536 insertions(+), 51 deletions(-) create mode 100644 web/src/services/go/wasm_exec.js diff --git a/web/src/services/go/go.ts b/web/src/services/go/go.ts index 6810a38e..42158852 100644 --- a/web/src/services/go/go.ts +++ b/web/src/services/go/go.ts @@ -1,6 +1,9 @@ import {IFileSystem} from "./fs"; import {encoder, decoder} from './foundation'; +// Doesn't work (idk why), Go throws "undefined" errors +// Use "wasm_exec.js" for now + const MAX_UINT32 = 4294967296; const NAN_HEAD = 0x7FF80000; const MS_IN_NANO = 1000000; @@ -16,22 +19,25 @@ export class Go { exited = false; private lastExitCode = 0; private pendingEvent: any = null; - private scheduledTimeouts = new Map(); - private nextCallbackTimeoutID = 1; + private _scheduledTimeouts = new Map(); + private _nextCallbackTimeoutID = 1; private _resolveExitPromise?: (exitCode: number) => void; private exitPromise = new Promise((resolve) => { this._resolveExitPromise = resolve; }); - private inst: any; - private values: any[] = []; - private refs = new Map(); + public _inst: any; + public _values: any[] = []; + public _refs = new Map(); - constructor(private global: Global) {} + constructor(private global: Global) { + this._values = []; + this._refs = new Map(); + } get mem() { // The buffer may change when requesting more memory. - return new DataView(this.inst.exports.mem.buffer); + return new DataView(this._inst.exports.mem.buffer); } setInt64(addr, v) { @@ -55,7 +61,7 @@ export class Go { } const id = this.mem.getUint32(addr, true); - return this.values[id]; + return this._values[id]; } storeValue(addr, v) { @@ -92,11 +98,12 @@ export class Go { return; } - let ref = this.refs.get(v); + console.log(this); + let ref = this._refs.get(v); if (ref === undefined) { - ref = this.values.length; - this.values.push(v); - this.refs.set(v, ref); + ref = this._values.length; + this._values.push(v); + this._refs.set(v, ref); } let typeFlag = 0; switch (typeof v) { @@ -117,7 +124,7 @@ export class Go { loadSlice(addr) { const array = this.getInt64(addr + 0); const len = this.getInt64(addr + 8); - return new Uint8Array(this.inst.exports.mem.buffer, array, len); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); } loadSliceOfValues(addr) { @@ -133,7 +140,7 @@ export class Go { loadString(addr) { const saddr = this.getInt64(addr + 0); const len = this.getInt64(addr + 8); - return decoder.decode(new DataView(this.inst.exports.mem.buffer, saddr, len)); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); } importObject = { @@ -147,9 +154,12 @@ export class Go { "runtime.wasmExit": (sp) => { const code = this.mem.getInt32(sp + 8, true); this.exited = true; - delete this.inst; - delete this.values; - delete this.refs; + // delete this._inst; + // this._inst = null; + this._values = []; + this._refs = new Map(); + // delete this._values; + // delete this._refs; this.exit(code); }, @@ -158,7 +168,7 @@ export class Go { const fd = this.getInt64(sp + 8); const p = this.getInt64(sp + 16); const n = this.mem.getInt32(sp + 24, true); - this.global.fs.writeSync(fd, new Uint8Array(this.inst.exports.mem.buffer, p, n)); + this.global.fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); }, // func nanotime() int64 @@ -175,16 +185,16 @@ export class Go { // func scheduleTimeoutEvent(delay int64) int32 "runtime.scheduleTimeoutEvent": (sp) => { - const id = this.nextCallbackTimeoutID; - this.nextCallbackTimeoutID++; - this.scheduledTimeouts.set(id, setTimeout( + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( () => { - this.resume(); - while (this.scheduledTimeouts.has(id)) { + this._resume(); + while (this._scheduledTimeouts.has(id)) { // for some reason Go failed to register the timeout event, log and try again // (temporary workaround for https://github.com/golang/go/issues/28975) console.warn("scheduleTimeoutEvent: missed timeout event"); - this.resume(); + this._resume(); } }, this.getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early @@ -195,8 +205,8 @@ export class Go { // func clearTimeoutEvent(id int32) "runtime.clearTimeoutEvent": (sp) => { const id = this.mem.getInt32(sp + 8, true); - clearTimeout(this.scheduledTimeouts.get(id)); - this.scheduledTimeouts.delete(id); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); }, // func getRandomData(r []byte) @@ -212,7 +222,7 @@ export class Go { // func valueGet(v ref, p string) ref "syscall/js.valueGet": (sp) => { const result = Reflect.get(this.loadValue(sp + 8), this.loadString(sp + 16)); - sp = this.inst.exports.getsp(); // see comment above + sp = this._inst.exports.getsp(); // see comment above this.storeValue(sp + 32, result); }, @@ -238,7 +248,7 @@ export class Go { const m = Reflect.get(v, this.loadString(sp + 16)); const args = this.loadSliceOfValues(sp + 32); const result = Reflect.apply(m, v, args); - sp = this.inst.exports.getsp(); // see comment above + sp = this._inst.exports.getsp(); // see comment above this.storeValue(sp + 56, result); this.mem.setUint8(sp + 64, 1); } catch (err) { @@ -253,7 +263,7 @@ export class Go { const v = this.loadValue(sp + 8); const args = this.loadSliceOfValues(sp + 16); const result = Reflect.apply(v, undefined, args); - sp = this.inst.exports.getsp(); // see comment above + sp = this._inst.exports.getsp(); // see comment above this.storeValue(sp + 40, result); this.mem.setUint8(sp + 48, 1); } catch (err) { @@ -268,7 +278,7 @@ export class Go { const v = this.loadValue(sp + 8); const args = this.loadSliceOfValues(sp + 16); const result = Reflect.construct(v, args); - sp = this.inst.exports.getsp(); // see comment above + sp = this._inst.exports.getsp(); // see comment above this.storeValue(sp + 40, result); this.mem.setUint8(sp + 48, 1); } catch (err) { @@ -336,8 +346,9 @@ export class Go { public async run(instance: WebAssembly.Instance): Promise { this.lastExitCode = 0; - this.inst = instance; - this.values = [ // TODO: garbage collection + this._inst = instance; + this._refs = new Map(); + this._values = [ // TODO: garbage collection NaN, 0, null, @@ -346,10 +357,10 @@ export class Go { global, this, ]; - this.refs = new Map(); + this._refs = new Map(); this.exited = false; - const mem = new DataView(this.inst.exports.mem.buffer); + const mem = new DataView(this._inst.exports.mem.buffer); // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. let offset = 4096; @@ -385,30 +396,30 @@ export class Go { offset += 8; }); - this.inst.exports.run(argc, argv); + this._inst.exports.run(argc, argv); if (this.exited) { this._resolveExitPromise && this._resolveExitPromise(this.lastExitCode); } return await this.exitPromise; } - resume() { + _resume() { if (this.exited) { throw new Error("Go program has already exited"); } - this.inst.exports.resume(); + this._inst.exports.resume(); if (this.exited) { this._resolveExitPromise && this._resolveExitPromise(this.lastExitCode); } } - makeFuncWrapper(id) { + _makeFuncWrapper(id) { const go = this; return function () { // @ts-ignore const event: any = { id: id, this: this, args: arguments }; go.pendingEvent = event; - go.resume(); + go._resume(); return event.result; }; } diff --git a/web/src/services/go/index.ts b/web/src/services/go/index.ts index e3c1394f..71d47bd2 100644 --- a/web/src/services/go/index.ts +++ b/web/src/services/go/index.ts @@ -1,14 +1,17 @@ import { FileSystemWrapper } from './fs'; -import { Go, Global } from './go'; +// import { Go, Global } from './go'; +import { Go } from './wasm_exec'; import {StdioWrapper, ConsoleLogger} from './stdio'; let instance: Go; +let wrapper: StdioWrapper; export const goRun = async(m: WebAssembly.WebAssemblyInstantiatedSource) => { if (!instance) { throw new Error('Go runner instance is not initialized'); } + wrapper.reset(); return instance.run(m.instance); }; @@ -21,17 +24,30 @@ export const bootstrapGo = (logger: ConsoleLogger) => { } // Wrap Go's calls to os.Stdout and os.Stderr - const w = new StdioWrapper(logger); - const mocks = { - fs: new FileSystemWrapper(w.stdoutPipe, w.stderrPipe), - }; - - // Wrap global object to make it accessible to Go's wasm bridge - const globalWrapper = new Proxy(window as any, { - has: (obj, prop) => prop in obj || prop in mocks, - get: (obj, prop) => prop in obj ? obj[prop] : mocks[prop] - }); + wrapper = new StdioWrapper(logger); + + // Monkey-patch global object to override some IO stuff (like FS) + const globalWrapper = window as any; + globalWrapper.global = window; + globalWrapper.fs = new FileSystemWrapper(wrapper.stdoutPipe, wrapper.stderrPipe); + globalWrapper.Go = Go; + + // TODO: Uncomment, when "go.ts" will be fixed + // const mocks = { + // fs: new FileSystemWrapper(w.stdoutPipe, w.stderrPipe), + // Go: Go, + // }; + // + // // Wrap global object to make it accessible to Go's wasm bridge + // const globalWrapper = new Proxy(window as any, { + // has: (obj, prop) => prop in obj || prop in mocks, + // get: (obj, prop) => { + // console.log('go: get %s', prop); + // return prop in obj ? obj[prop] : mocks[prop] + // } + // }); // Create instance - instance = new Go(globalWrapper); + // instance = new Go(globalWrapper); + instance = new Go(); }; \ No newline at end of file diff --git a/web/src/services/go/stdio.ts b/web/src/services/go/stdio.ts index 330f3ddc..cf0ae50d 100644 --- a/web/src/services/go/stdio.ts +++ b/web/src/services/go/stdio.ts @@ -30,6 +30,10 @@ export class StdioWrapper { }; } + reset() { + this.outputBuf = ''; + } + get stdoutPipe(): IWriter { return this.getWriter(EvalEventKind.Stdout); } diff --git a/web/src/services/go/wasm_exec.js b/web/src/services/go/wasm_exec.js new file mode 100644 index 00000000..d8c9fe88 --- /dev/null +++ b/web/src/services/go/wasm_exec.js @@ -0,0 +1,454 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// Map multiple JavaScript environments to a single common API, +// preferring web standards over Node.js API. +// +// Adapted original wasm_exec.js from Go SDK, (c) 2020, x1unix + +if (typeof global !== "undefined") { + // global already exists +} else if (typeof window !== "undefined") { + window.global = window; +} else { + throw new Error("cannot export Go (neither global, window nor self is defined)"); +} + +if (!global.crypto) { + const nodeCrypto = require("crypto"); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; +} + +if (!global.performance) { + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; +} + +if (!global.TextEncoder) { + global.TextEncoder = require("util").TextEncoder; +} + +if (!global.TextDecoder) { + global.TextDecoder = require("util").TextDecoder; +} + +// End of polyfills for common API. + +const encoder = new TextEncoder("utf-8"); +const decoder = new TextDecoder("utf-8"); + +export class Go { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.lastExitCode = 0; + this.exit = (code) => { + this.lastExitCode = code; + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.mem.buffer); + } + + const setInt64 = (addr, v) => { + mem().setUint32(addr + 0, v, true); + mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const getInt64 = (addr) => { + const low = mem().getUint32(addr + 0, true); + const high = mem().getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = mem().getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = mem().getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number") { + if (isNaN(v)) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 0, true); + return; + } + if (v === 0) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 1, true); + return; + } + mem().setFloat64(addr, v, true); + return; + } + + switch (v) { + case undefined: + mem().setFloat64(addr, 0, true); + return; + case null: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 2, true); + return; + case true: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 3, true); + return; + case false: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 4, true); + return; + default: + break; + } + + let ref = this._refs.get(v); + if (ref === undefined) { + ref = this._values.length; + this._values.push(v); + this._refs.set(v, ref); + } + let typeFlag = 0; + switch (typeof v) { + case "string": + typeFlag = 1; + break; + case "symbol": + typeFlag = 2; + break; + case "function": + typeFlag = 3; + break; + default: + break; + } + mem().setUint32(addr + 4, nanHead | typeFlag, true); + mem().setUint32(addr, ref, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + go: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + const code = mem().getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._refs; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = mem().getInt32(sp + 24, true); + global.fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func nanotime() int64 + "runtime.nanotime": (sp) => { + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + const msec = (new Date()).getTime(); + setInt64(sp + 8, msec / 1000); + mem().setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8) + 1, // setTimeout has been seen to fire up to 1 millisecond early + )); + mem().setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + const id = mem().getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 56, result); + mem().setUint8(sp + 64, 1); + } catch (err) { + storeValue(sp + 56, err); + mem().setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp(); // see comment above + storeValue(sp + 40, result); + mem().setUint8(sp + 48, 1); + } catch (err) { + storeValue(sp + 40, err); + mem().setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + mem().setUint8(sp + 24, loadValue(sp + 8) instanceof loadValue(sp + 16)); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array)) { + mem().setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + mem().setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array)) { + mem().setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + mem().setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + this._inst = instance; + this._values = [ // TODO: garbage collection + NaN, + 0, + null, + true, + false, + global, + this, + ]; + this._refs = new Map(); + this.exited = false; + + const mem = new DataView(this._inst.exports.mem.buffer) + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + + const keys = Object.keys(this.env).sort(); + argvPtrs.push(keys.length); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + + const argv = offset; + argvPtrs.forEach((ptr) => { + mem.setUint32(offset, ptr, true); + mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } +} +// +// global.Go = Go; From ee3474752d5cef1d8eb65c05409d50bc0720bf18 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 04:23:05 +0200 Subject: [PATCH 15/30] ui: fix preview overflow --- web/src/Preview.css | 1 + 1 file changed, 1 insertion(+) diff --git a/web/src/Preview.css b/web/src/Preview.css index 5e8427c1..eee56fca 100644 --- a/web/src/Preview.css +++ b/web/src/Preview.css @@ -4,6 +4,7 @@ max-height: 640px; height: 50%; box-sizing: border-box; + overflow: auto; } .app-preview__content { From f0c273c648fadd14a2e30233224336b9d21fabd4 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 04:35:57 +0200 Subject: [PATCH 16/30] ui: preview - show timers and exit status only for Go playground build --- web/src/EvalEventView.tsx | 3 ++- web/src/Preview.tsx | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/web/src/EvalEventView.tsx b/web/src/EvalEventView.tsx index c817a950..896c6bfe 100644 --- a/web/src/EvalEventView.tsx +++ b/web/src/EvalEventView.tsx @@ -7,6 +7,7 @@ interface ViewData { message: string, kind: string, delay: number + showDelay: boolean } const pad = (num: number, size: number) => ('000000000' + num).substr(-size); @@ -24,7 +25,7 @@ export default class EvalEventView extends React.Component { render() { return
{this.props.message}
- [{this.delay}] + {this.props.showDelay ? `[${this.delay}]` : ''}
} } \ No newline at end of file diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index cc9c4bf4..2d3c68bb 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -1,11 +1,11 @@ import React from 'react'; import './Preview.css'; -import { EDITOR_FONTS } from './editor/props'; -import { Connect } from './store'; +import {EDITOR_FONTS} from './editor/props'; +import {Connect, RuntimeType} from './store'; import {EvalEvent} from './services/api'; import EvalEventView from './EvalEventView'; -import { getTheme } from '@uifabric/styling'; -import { MessageBar, MessageBarType } from 'office-ui-fabric-react'; +import {getTheme} from '@uifabric/styling'; +import {MessageBar, MessageBarType} from 'office-ui-fabric-react'; import {ProgressIndicator} from "office-ui-fabric-react/lib/ProgressIndicator"; @@ -13,9 +13,10 @@ interface PreviewProps { lastError?:string | null; events?: EvalEvent[] loading?: boolean + runtime?: RuntimeType } -@Connect(s => ({darkMode: s.settings.darkMode, ...s.status})) +@Connect(s => ({darkMode: s.settings.darkMode, runtime: s.settings.runtime, ...s.status})) export default class Preview extends React.Component { get styles() { const { palette } = getTheme(); @@ -31,6 +32,8 @@ export default class Preview extends React.Component { } render() { + // Some content should not be displayed in WASM mode (like delay, etc) + const isWasm = this.props.runtime === RuntimeType.WebAssembly; let content; if (this.props.lastError) { content = @@ -45,9 +48,12 @@ export default class Preview extends React.Component { message={e.Message} delay={e.Delay} kind={e.Kind} + showDelay={!isWasm} />); - content.push(
Program exited.
) + if (!isWasm) { + content.push(
Program exited.
) + } } else { content = Press "Run" to compile program.; } From 7c28dac6aa118388acce90e5b5995c641015c372 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 04:36:19 +0200 Subject: [PATCH 17/30] ui: reducers - reset build output if build runtime changed --- web/src/store/reducers.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index b025cd0e..e1be8434 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -72,6 +72,14 @@ const reducers = { [ActionType.EVAL_FINISH]: (s: StatusState, _: Action) => { return {...s, loading: false} }, + [ActionType.BUILD_PARAMS_CHANGE]: (s: StatusState, a: Action) => { + if (a.payload.runtime) { + // Reset build output if build runtime was changed + return {loading: false, lastError: null} + } + + return s; + }, }, {loading: false}), settings: mapByAction({ [ActionType.TOGGLE_THEME]: (s: SettingsState, a: Action) => { From 5177814b8927b170155a6955d996a65dac35cf1f Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 05:18:41 +0200 Subject: [PATCH 18/30] ui: sync monaco and runtime with local storage --- web/src/Header.tsx | 9 ++- web/src/Preview.tsx | 3 +- web/src/editor/props.ts | 4 +- web/src/services/config.ts | 110 ++++++++++++++++++++++++++++- web/src/settings/SettingsModal.tsx | 7 +- web/src/store/actions.ts | 4 +- web/src/store/dispatch.ts | 26 +++++-- web/src/store/reducers.ts | 27 +++---- web/src/store/state.ts | 18 +---- 9 files changed, 155 insertions(+), 53 deletions(-) diff --git a/web/src/Header.tsx b/web/src/Header.tsx index 434ac0af..72f53ad8 100644 --- a/web/src/Header.tsx +++ b/web/src/Header.tsx @@ -10,7 +10,10 @@ import { formatFileDispatcher, runFileDispatcher, saveFileDispatcher, - dispatchToggleTheme, shareSnippetDispatcher, newMonacoParamsChangeAction, newBuildParamsChangeAction + dispatchToggleTheme, + shareSnippetDispatcher, + newBuildParamsChangeDispatcher, + newMonacoParamsChangeDispatcher } from './store'; @@ -153,13 +156,13 @@ export class Header extends React.Component { private onSettingsClose(changes: SettingsChanges) { if (changes.monaco) { // Update monaco state if some of it's settings were changed - this.props.dispatch(newMonacoParamsChangeAction(changes.monaco)); + this.props.dispatch(newMonacoParamsChangeDispatcher(changes.monaco)); } if (changes.args) { // Save runtime settings const { runtime, autoFormat } = changes.args; - this.props.dispatch(newBuildParamsChangeAction(runtime, autoFormat)); + this.props.dispatch(newBuildParamsChangeDispatcher(runtime, autoFormat)); } this.setState({showSettings: false}); diff --git a/web/src/Preview.tsx b/web/src/Preview.tsx index 2d3c68bb..2b8930b9 100644 --- a/web/src/Preview.tsx +++ b/web/src/Preview.tsx @@ -1,7 +1,8 @@ import React from 'react'; import './Preview.css'; import {EDITOR_FONTS} from './editor/props'; -import {Connect, RuntimeType} from './store'; +import {Connect} from './store'; +import { RuntimeType } from './services/config'; import {EvalEvent} from './services/api'; import EvalEventView from './EvalEventView'; import {getTheme} from '@uifabric/styling'; diff --git a/web/src/editor/props.ts b/web/src/editor/props.ts index a959c632..91410fc5 100644 --- a/web/src/editor/props.ts +++ b/web/src/editor/props.ts @@ -1,5 +1,5 @@ import * as monaco from 'monaco-editor'; -import {MonacoState} from "../store"; +import {MonacoSettings} from "../services/config"; export const LANGUAGE_GOLANG = 'go'; @@ -24,7 +24,7 @@ export const EDITOR_FONTS = [ ].join(', '); // stateToOptions converts MonacoState to IEditorOptions -export const stateToOptions = (state: MonacoState): monaco.editor.IEditorOptions => { +export const stateToOptions = (state: MonacoSettings): monaco.editor.IEditorOptions => { const {selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, contextMenu } = state; return { selectOnLineNumbers, mouseWheelZoom, smoothScrolling, cursorBlinking, cursorStyle, diff --git a/web/src/services/config.ts b/web/src/services/config.ts index 8f279ebe..96c6da94 100644 --- a/web/src/services/config.ts +++ b/web/src/services/config.ts @@ -2,6 +2,34 @@ import {loadTheme} from '@uifabric/styling'; import {DarkTheme, LightTheme} from "./colors"; const DARK_THEME_KEY = 'ui.darkTheme.enabled'; +const RUNTIME_TYPE_KEY = 'go.build.runtime'; +const AUTOFORMAT_KEY = 'go.build.autoFormat'; +const MONACO_SETTINGS = 'ms.monaco.settings'; + +export enum RuntimeType { + GoPlayground = 'GO_PLAYGROUND', + WebAssembly = 'WASM' +} + +export interface MonacoSettings { + cursorBlinking: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid', + cursorStyle: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin', + selectOnLineNumbers: boolean, + minimap: boolean, + contextMenu: boolean, + smoothScrolling: boolean, + mouseWheelZoom: boolean, +} + +const defaultMonacoSettings: MonacoSettings = { + cursorBlinking: 'blink', + cursorStyle: 'line', + selectOnLineNumbers: true, + minimap: true, + contextMenu: true, + smoothScrolling: true, + mouseWheelZoom: true, +}; function setThemeStyles(isDark: boolean) { loadTheme(isDark ? DarkTheme : LightTheme); @@ -11,18 +39,94 @@ export const getVariableValue = (key: string, defaultValue: string) => process.env[`REACT_APP_${key}`] ?? defaultValue; export default { + _cache: {}, appVersion: getVariableValue('VERSION', '1.0.0'), serverUrl: getVariableValue('LANG_SERVER', window.location.origin), githubUrl: getVariableValue('GITHUB_URL', 'https://github.com/x1unix/go-playground'), get darkThemeEnabled(): boolean { - const v = localStorage.getItem(DARK_THEME_KEY); - return v === 'true'; + if (this._cache[DARK_THEME_KEY]) { + return this._cache[DARK_THEME_KEY]; + } + return this.getBoolean(DARK_THEME_KEY, false); }, set darkThemeEnabled(enable: boolean) { setThemeStyles(enable); - localStorage.setItem(DARK_THEME_KEY, enable ? 'true' : 'false'); + this._cache[DARK_THEME_KEY] = enable; + localStorage.setItem(DARK_THEME_KEY, enable.toString()); + }, + + get runtimeType(): RuntimeType { + if (this._cache[RUNTIME_TYPE_KEY]) { + return this._cache[RUNTIME_TYPE_KEY]; + } + return this.getValue(RUNTIME_TYPE_KEY, RuntimeType.GoPlayground); + }, + + set runtimeType(newVal: RuntimeType) { + this._cache[RUNTIME_TYPE_KEY] = newVal; + localStorage.setItem(RUNTIME_TYPE_KEY, newVal); + }, + + get autoFormat(): boolean { + if (this._cache[AUTOFORMAT_KEY]) { + return this._cache[AUTOFORMAT_KEY]; + } + return this.getBoolean(AUTOFORMAT_KEY, true); + }, + + set autoFormat(v: boolean) { + this._cache[AUTOFORMAT_KEY] = v; + localStorage.setItem(AUTOFORMAT_KEY, v.toString()); + }, + + get monacoSettings(): MonacoSettings { + if (this._cache[MONACO_SETTINGS]) { + return this._cache[MONACO_SETTINGS]; + } + const val = localStorage.getItem(MONACO_SETTINGS); + if (!val) { + this._cache[MONACO_SETTINGS] = defaultMonacoSettings; + return defaultMonacoSettings; + } + + try { + const obj = JSON.parse(val); + this._cache[MONACO_SETTINGS] = obj; + return obj as MonacoSettings; + } catch (err) { + console.warn('failed to read Monaco settings', err); + this._cache[MONACO_SETTINGS] = defaultMonacoSettings; + return defaultMonacoSettings; + } + }, + + set monacoSettings(m: MonacoSettings) { + this._cache[MONACO_SETTINGS] = m; + localStorage.setItem(MONACO_SETTINGS, JSON.stringify(m)); + }, + + getValue(key: string, defaultVal: T): T { + if (this._cache[key]) { + return this._cache[key]; + } + + const val = localStorage.getItem(key); + return (val ?? defaultVal) as T; + }, + + getBoolean(key: string, defVal: boolean): boolean { + if (this._cache[key]) { + return this._cache[key]; + } + + const val = localStorage.getItem(key); + if (!val) { + return defVal; + } + + return val === 'true'; }, sync() { diff --git a/web/src/settings/SettingsModal.tsx b/web/src/settings/SettingsModal.tsx index 07f956d7..c38d1a80 100644 --- a/web/src/settings/SettingsModal.tsx +++ b/web/src/settings/SettingsModal.tsx @@ -5,7 +5,8 @@ import {MessageBar, MessageBarType} from 'office-ui-fabric-react/lib/MessageBar' import {Link} from 'office-ui-fabric-react/lib/Link'; import {getContentStyles, getIconButtonStyles} from '../styles/modal'; import SettingsProperty from './SettingsProperty'; -import {BuildParamsArgs, Connect, MonacoParamsChanges, MonacoState, RuntimeType, SettingsState} from "../store"; +import {MonacoSettings, RuntimeType} from '../services/config'; +import {BuildParamsArgs, Connect, MonacoParamsChanges, SettingsState} from "../store"; const WASM_SUPPORTED = 'WebAssembly' in window; @@ -44,7 +45,7 @@ export interface SettingsProps { isOpen: boolean onClose: (changes: SettingsChanges) => void settings?: SettingsState - monaco?: MonacoState + monaco?: MonacoSettings dispatch?: (Action) => void } @@ -70,7 +71,7 @@ export default class SettingsModal extends React.Component = { - [k in keyof MonacoState | string]: T; + [k in keyof MonacoSettings | string]: T; }; export const actionOf = (type: ActionType) => ({type}); diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 76fa307a..76f2f4dc 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -2,17 +2,17 @@ import {saveAs} from 'file-saver'; import {push} from 'connected-react-router'; import { Action, - ActionType, + ActionType, MonacoParamsChanges, newBuildParamsChangeAction, newBuildResultAction, newErrorAction, newImportFileAction, - newLoadingAction, + newLoadingAction, newMonacoParamsChangeAction, newProgramWriteAction, newToggleThemeAction } from './actions'; -import {RuntimeType, State} from "./state"; +import {State} from "./state"; import client, {EvalEventKind, instantiateStreaming} from '../services/api'; -import config from '../services/config'; +import config, {RuntimeType} from '../services/config'; import {DEMO_CODE} from '../editor/props'; import {getImportObject, goRun} from '../services/go'; @@ -41,6 +41,24 @@ export function newImportFileDispatcher(f: File): Dispatcher { }; } +export function newMonacoParamsChangeDispatcher(changes: MonacoParamsChanges): Dispatcher { + return (dispatch: DispatchFn, _: StateProvider) => { + const current = config.monacoSettings; + config.monacoSettings = Object.assign(current, changes); + dispatch(newMonacoParamsChangeAction(changes)); + }; +} + + +export function newBuildParamsChangeDispatcher(runtime: RuntimeType, autoFormat: boolean): Dispatcher { + return (dispatch: DispatchFn, _: StateProvider) => { + config.runtimeType = runtime; + config.autoFormat = autoFormat; + dispatch(newBuildParamsChangeAction(runtime, autoFormat)); + }; +} + + export function newSnippetLoadDispatcher(snippetID: string): Dispatcher { return async(dispatch: DispatchFn, _: StateProvider) => { if (!snippetID) { diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index e1be8434..17917698 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -7,22 +7,11 @@ import { SettingsState, State, StatusState, - MonacoState, - RuntimeType } from './state'; import {CompilerResponse, EvalEvent} from '../services/api'; -import localConfig from '../services/config' +import localConfig, {MonacoSettings, RuntimeType} from '../services/config' import {mapByAction} from './helpers'; - -const defaultMonacoState: MonacoState = { - cursorBlinking: 'blink', - cursorStyle: 'line', - selectOnLineNumbers: true, - minimap: true, - contextMenu: true, - smoothScrolling: true, - mouseWheelZoom: true, -}; +import config from "../services/config"; const reducers = { editor: mapByAction({ @@ -91,11 +80,11 @@ const reducers = { return Object.assign({}, s, a.payload); }, }, {darkMode: localConfig.darkThemeEnabled, autoFormat: true, runtime: RuntimeType.GoPlayground}), - monaco: mapByAction({ - [ActionType.MONACO_SETTINGS_CHANGE]: (s: MonacoState, a: Action) => { + monaco: mapByAction({ + [ActionType.MONACO_SETTINGS_CHANGE]: (s: MonacoSettings, a: Action) => { return Object.assign({}, s, a.payload); } - }, defaultMonacoState) + }, config.monacoSettings) }; export const getInitialState = (): State => ({ @@ -108,10 +97,10 @@ export const getInitialState = (): State => ({ }, settings: { darkMode: localConfig.darkThemeEnabled, - autoFormat: true, - runtime: RuntimeType.GoPlayground + autoFormat: localConfig.autoFormat, + runtime: localConfig.runtimeType, }, - monaco: defaultMonacoState, + monaco: config.monacoSettings, }); export const createRootReducer = history => combineReducers({ diff --git a/web/src/store/state.ts b/web/src/store/state.ts index 319c9297..9f58699c 100644 --- a/web/src/store/state.ts +++ b/web/src/store/state.ts @@ -1,10 +1,6 @@ import { connect } from 'react-redux'; import { EvalEvent } from '../services/api'; - -export enum RuntimeType { - GoPlayground = 'GO_PLAYGROUND', - WebAssembly = 'WASM' -} +import {MonacoSettings, RuntimeType} from '../services/config'; export interface EditorState { fileName: string, @@ -23,21 +19,11 @@ export interface SettingsState { runtime: RuntimeType, } -export interface MonacoState { - cursorBlinking: 'blink' | 'smooth' | 'phase' | 'expand' | 'solid', - cursorStyle: 'line' | 'block' | 'underline' | 'line-thin' | 'block-outline' | 'underline-thin', - selectOnLineNumbers: boolean, - minimap: boolean, - contextMenu: boolean, - smoothScrolling: boolean, - mouseWheelZoom: boolean, -} - export interface State { editor: EditorState status?: StatusState, settings: SettingsState - monaco: MonacoState + monaco: MonacoSettings } export function Connect(fn: (state: State) => any) { From 0c2a39ac5e31f5ea66544227ac9c01bda64882c0 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 05:24:19 +0200 Subject: [PATCH 19/30] ui: set format param on build --- web/src/services/api.ts | 12 ++++++------ web/src/store/dispatch.ts | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 7c1d58a6..74332be7 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -34,11 +34,11 @@ export interface CompilerResponse { export interface IAPIClient { readonly axiosClient: AxiosInstance getSuggestions(query: {packageName?: string, value?:string}): Promise - evaluateCode(code: string): Promise + evaluateCode(code: string, format: boolean): Promise formatCode(code: string): Promise getSnippet(id: string): Promise shareSnippet(code: string): Promise - compileToWasm(code: string): Promise + compileToWasm(code: string, format: boolean): Promise } export const instantiateStreaming = async (resp, importObject) => { @@ -62,8 +62,8 @@ class Client implements IAPIClient { return this.get(`/suggest?${queryParams}`); } - async compileToWasm(code: string): Promise { - const resp = await fetch(`${apiAddress}/compile`, { + async compileToWasm(code: string, format: boolean): Promise { + const resp = await fetch(`${apiAddress}/compile?format=${Boolean(format)}`, { method: 'POST', body: code, }); @@ -76,8 +76,8 @@ class Client implements IAPIClient { return resp; } - async evaluateCode(code: string): Promise { - return this.post('/run', code); + async evaluateCode(code: string, format: boolean): Promise { + return this.post(`/run?format=${Boolean(format)}`, code); } async formatCode(code: string): Promise { diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 76f2f4dc..77ccf037 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -108,11 +108,11 @@ export const runFileDispatcher: Dispatcher = const { settings, editor } = getState(); switch (settings.runtime) { case RuntimeType.GoPlayground: - const res = await client.evaluateCode(editor.code); + const res = await client.evaluateCode(editor.code, settings.autoFormat); dispatch(newBuildResultAction(res)); break; case RuntimeType.WebAssembly: - let resp = await client.compileToWasm(editor.code); + let resp = await client.compileToWasm(editor.code, settings.autoFormat); let instance = await instantiateStreaming(resp, getImportObject()); dispatch({type: ActionType.EVAL_START}); goRun(instance) From 3d78fc61ee5cc0f05a444a247d1fdd00a6327443 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 05:31:06 +0200 Subject: [PATCH 20/30] server: respect format param --- pkg/langserver/server.go | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 821ee9c3..30c27849 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -23,7 +23,8 @@ const ( frameTime = time.Second maxBuildTimeDuration = time.Second * 30 - wasmMimeType = "application/wasm" + wasmMimeType = "application/wasm" + formatQueryParam = "format" ) type Service struct { @@ -112,18 +113,35 @@ func (s *Service) HandleGetSuggestion(w http.ResponseWriter, r *http.Request) { // goImportsCode reads code from request and performs "goimports" on it // if any error occurs, it sends error response to client and closes connection +// +// if "format" url query param is undefined or set to "false", just returns code as is func (s *Service) goImportsCode(w http.ResponseWriter, r *http.Request) ([]byte, error, bool) { if err := goplay.ValidateContentLength(int(r.ContentLength)); err != nil { Errorf(http.StatusRequestEntityTooLarge, err.Error()).Write(w) return nil, err, false } + shouldFormatCode, err := strconv.ParseBool(r.URL.Query().Get(formatQueryParam)) + if err != nil { + Errorf( + http.StatusBadRequest, + "invalid %q query parameter value (expected boolean)", + formatQueryParam, + ).Write(w) + return nil, err, false + } + src, err := ioutil.ReadAll(r.Body) if err != nil { Errorf(http.StatusBadGateway, "failed to read request: %s", err).Write(w) return nil, err, false } + if !shouldFormatCode { + // return code as is if don't need to format code + return src, nil, false + } + defer r.Body.Close() resp, err := goplay.GoImports(r.Context(), src) if err != nil { From fe3e58f7b652fd2bf3cecfd1793b0afe596b6deb Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 06:25:05 +0200 Subject: [PATCH 21/30] return reformatted code for wasm --- pkg/compiler/compiler.go | 40 +++++++++------------ pkg/compiler/storage/local.go | 34 +++++++++++++++++- pkg/compiler/storage/local_test.go | 5 +-- pkg/compiler/storage/storage.go | 1 + pkg/langserver/request.go | 7 +++- pkg/langserver/server.go | 55 ++++++++++++++++++++-------- web/src/services/api.ts | 58 ++++++++++++++++++------------ web/src/store/actions.ts | 4 +-- web/src/store/dispatch.ts | 6 ++-- web/src/store/reducers.ts | 6 ++-- 10 files changed, 146 insertions(+), 70 deletions(-) diff --git a/pkg/compiler/compiler.go b/pkg/compiler/compiler.go index a6cedc7c..248a582e 100644 --- a/pkg/compiler/compiler.go +++ b/pkg/compiler/compiler.go @@ -3,7 +3,6 @@ package compiler import ( "bytes" "context" - "github.com/pkg/errors" "github.com/x1unix/go-playground/pkg/compiler/storage" "go.uber.org/zap" "io" @@ -20,7 +19,6 @@ var buildArgs = []string{ type Result struct { FileName string - Data io.ReadCloser } type BuildService struct { @@ -35,7 +33,7 @@ func NewBuildService(log *zap.SugaredLogger, store storage.StoreProvider) BuildS } } -func (s BuildService) buildSource(ctx context.Context, outputLocation, sourceLocation string) (io.ReadCloser, error) { +func (s BuildService) buildSource(ctx context.Context, outputLocation, sourceLocation string) error { cmd := exec.CommandContext(ctx, "go", "build", "-o", @@ -49,23 +47,20 @@ func (s BuildService) buildSource(ctx context.Context, outputLocation, sourceLoc s.log.Debugw("starting go build", "command", cmd.Args, "env", cmd.Env) if err := cmd.Start(); err != nil { - return nil, err + return err } if err := cmd.Wait(); err != nil { errMsg := buff.String() s.log.Debugw("build failed", "err", err, "stderr", errMsg) - return nil, newBuildError(errMsg) - //return nil, newBuildError(errPipe, err) + return newBuildError(errMsg) } - // build finishes, now let's get the wasm file - f, err := os.Open(outputLocation) - if err != nil { - return nil, errors.Wrap(err, "failed to open compiled WASM file") - } + return nil +} - return f, nil +func (s BuildService) GetArtifact(id storage.ArtifactID) (io.ReadCloser, error) { + return s.storage.GetItem(id) } func (s BuildService) Build(ctx context.Context, data []byte) (*Result, error) { @@ -75,21 +70,20 @@ func (s BuildService) Build(ctx context.Context, data []byte) (*Result, error) { } result := &Result{FileName: aid.Ext(storage.ExtWasm)} - compiled, err := s.storage.GetItem(aid) - if err == nil { - // Just return precompiled result if data is cached already - s.log.Debugw("build cached, returning cached data", "artifact", aid.String()) - result.Data = compiled - return result, nil + isCached, err := s.storage.HasItem(aid) + if err != nil { + s.log.Errorw("failed to check cache", "artifact", aid.String(), "err", err) + return nil, err } - if err != storage.ErrNotExists { - s.log.Errorw("failed to open cached file", "artifact", aid.String(), "err", err) + if isCached { + // Just return precompiled result if data is cached already + s.log.Debugw("build cached, returning cached file", "artifact", aid.String()) + return result, nil } - _ = s.storage.CreateLocationAndDo(aid, data, func(wasmLocation, sourceLocation string) error { - result.Data, err = s.buildSource(ctx, wasmLocation, sourceLocation) - return nil + err = s.storage.CreateLocationAndDo(aid, data, func(wasmLocation, sourceLocation string) error { + return s.buildSource(ctx, wasmLocation, sourceLocation) }) return result, err diff --git a/pkg/compiler/storage/local.go b/pkg/compiler/storage/local.go index 0bad3dfe..d8b19717 100644 --- a/pkg/compiler/storage/local.go +++ b/pkg/compiler/storage/local.go @@ -23,6 +23,17 @@ const ( perm = 0744 ) +type cachedFile struct { + io.ReadCloser + useLock *sync.Mutex +} + +func (c cachedFile) Read(p []byte) (n int, err error) { + c.useLock.Lock() + defer c.useLock.Unlock() + return c.ReadCloser.Read(p) +} + type LocalStorage struct { log *zap.SugaredLogger useLock *sync.Mutex @@ -56,14 +67,35 @@ func (s LocalStorage) getOutputLocation(id ArtifactID) string { return filepath.Join(s.binDir, id.Ext(ExtWasm)) } +func (s LocalStorage) HasItem(id ArtifactID) (bool, error) { + s.useLock.Lock() + s.useLock.Unlock() + fPath := s.getOutputLocation(id) + _, err := os.Stat(fPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + + return false, err + } + + return true, nil +} + func (s LocalStorage) GetItem(id ArtifactID) (io.ReadCloser, error) { + s.useLock.Lock() + defer s.useLock.Unlock() fPath := s.getOutputLocation(id) f, err := os.Open(fPath) if os.IsNotExist(err) { return nil, ErrNotExists } - return f, err + return cachedFile{ + ReadCloser: f, + useLock: s.useLock, + }, err } func (s LocalStorage) CreateLocationAndDo(id ArtifactID, data []byte, cb Callback) error { diff --git a/pkg/compiler/storage/local_test.go b/pkg/compiler/storage/local_test.go index e00a883c..5a2d5457 100644 --- a/pkg/compiler/storage/local_test.go +++ b/pkg/compiler/storage/local_test.go @@ -47,8 +47,9 @@ func TestLocalStorage_GetItem(t *testing.T) { r.NoError(err, "failed to read test file") r.Equal(data, expectData, "input and result don't match") - - must(t, os.Mkdir(filepath.Join(s.binDir), perm), "failed to create bin dir") + if err := os.Mkdir(filepath.Join(s.binDir), perm); !os.IsExist(err) { + must(t, err, "failed to create bin dir") + } err = ioutil.WriteFile(wasmLocation, expectData, perm) must(t, err, "failed to write dest file") return expErr diff --git a/pkg/compiler/storage/storage.go b/pkg/compiler/storage/storage.go index 2cc2aa77..0532c030 100644 --- a/pkg/compiler/storage/storage.go +++ b/pkg/compiler/storage/storage.go @@ -10,6 +10,7 @@ var ErrNotExists = errors.New("item not exists") type Callback = func(wasmLocation, sourceLocation string) error type StoreProvider interface { + HasItem(id ArtifactID) (bool, error) GetItem(id ArtifactID) (io.ReadCloser, error) CreateLocationAndDo(id ArtifactID, data []byte, cb Callback) error } diff --git a/pkg/langserver/request.go b/pkg/langserver/request.go index 8078a000..cf4c7c27 100644 --- a/pkg/langserver/request.go +++ b/pkg/langserver/request.go @@ -68,7 +68,12 @@ func (r SuggestionsResponse) Write(w http.ResponseWriter) { WriteJSON(w, r) } -type CompilerResponse struct { +type BuildResponse struct { + Formatted string `json:"formatted,omitempty"` + FileName string `json:"fileName,omitempty"` +} + +type RunResponse struct { Formatted string `json:"formatted,omitempty"` Events []*goplay.CompileEvent `json:"events,omitempty"` } diff --git a/pkg/langserver/server.go b/pkg/langserver/server.go index 30c27849..bea99811 100644 --- a/pkg/langserver/server.go +++ b/pkg/langserver/server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "github.com/x1unix/go-playground/pkg/compiler" + "github.com/x1unix/go-playground/pkg/compiler/storage" "io" "io/ioutil" "net/http" @@ -25,6 +26,7 @@ const ( wasmMimeType = "application/wasm" formatQueryParam = "format" + artifactParamVal = "artifactId" ) type Service struct { @@ -51,6 +53,7 @@ func (s *Service) Mount(r *mux.Router) { r.Path("/format").Methods(http.MethodPost).HandlerFunc(s.HandleFormatCode) r.Path("/share").Methods(http.MethodPost).HandlerFunc(s.HandleShare) r.Path("/snippet/{id}").Methods(http.MethodGet).HandlerFunc(s.HandleGetSnippet) + r.Path("/artifacts/{artifactId:[a-fA-F0-9]+}.wasm").Methods(http.MethodGet).HandlerFunc(s.HandleArtifactRequest) } func (s *Service) lookupBuiltin(val string) (*SuggestionsResponse, error) { @@ -174,7 +177,7 @@ func (s *Service) HandleFormatCode(w http.ResponseWriter, r *http.Request) { return } - WriteJSON(w, CompilerResponse{Formatted: string(code)}) + WriteJSON(w, RunResponse{Formatted: string(code)}) } func (s *Service) HandleShare(w http.ResponseWriter, r *http.Request) { @@ -239,7 +242,7 @@ func (s *Service) HandleRunCode(w http.ResponseWriter, r *http.Request) { return } - result := CompilerResponse{Events: res.Events} + result := RunResponse{Events: res.Events} if changed { // Return formatted code if goimports had any effect result.Formatted = string(src) @@ -249,6 +252,35 @@ func (s *Service) HandleRunCode(w http.ResponseWriter, r *http.Request) { WriteJSON(w, result) } +func (s *Service) HandleArtifactRequest(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + artifactId := storage.ArtifactID(vars[artifactParamVal]) + data, err := s.compiler.GetArtifact(artifactId) + if err != nil { + if err == storage.ErrNotExists { + Errorf(http.StatusNotFound, "artifact not found").Write(w) + return + } + + NewErrorResponse(err).Write(w) + return + } + + n, err := io.Copy(w, data) + defer data.Close() + if err != nil { + s.log.Errorw("failed to send artifact", + "artifactID", artifactId, + "err", err, + ) + NewErrorResponse(err).Write(w) + return + } + + w.Header().Set("Content-Type", wasmMimeType) + w.Header().Set("Content-Length", strconv.FormatInt(n, 10)) +} + func (s *Service) HandleCompile(w http.ResponseWriter, r *http.Request) { // Limit for request timeout ctx, _ := context.WithDeadline(r.Context(), time.Now().Add(maxBuildTimeDuration)) @@ -259,7 +291,7 @@ func (s *Service) HandleCompile(w http.ResponseWriter, r *http.Request) { return } - data, err, _ := s.goImportsCode(w, r) + src, err, changed := s.goImportsCode(w, r) if err != nil { if goplay.IsCompileError(err) { return @@ -269,7 +301,7 @@ func (s *Service) HandleCompile(w http.ResponseWriter, r *http.Request) { return } - result, err := s.compiler.Build(ctx, data) + result, err := s.compiler.Build(ctx, src) if err != nil { if compileErr, ok := err.(*compiler.BuildError); ok { Errorf(http.StatusBadRequest, compileErr.Error()).Write(w) @@ -280,16 +312,11 @@ func (s *Service) HandleCompile(w http.ResponseWriter, r *http.Request) { return } - n, err := io.Copy(w, result.Data) - if err != nil { - s.log.Errorw("failed to send WASM response", - "file", result.FileName, - "err", err, - ) - NewErrorResponse(err).Write(w) - return + resp := BuildResponse{FileName: result.FileName} + if changed { + // Return formatted code if goimports had any effect + resp.Formatted = string(src) } - w.Header().Set("Content-Type", wasmMimeType) - w.Header().Set("Content-Length", strconv.FormatInt(n, 10)) + WriteJSON(w, resp) } diff --git a/web/src/services/api.ts b/web/src/services/api.ts index 74332be7..5795ff4e 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1,5 +1,5 @@ import * as axios from 'axios'; -import {AxiosInstance} from "axios"; +import {AxiosInstance} from 'axios'; import * as monaco from "monaco-editor"; import config from './config'; @@ -26,19 +26,32 @@ export interface EvalEvent { Delay: number } -export interface CompilerResponse { - formatted?: string|null +export interface RunResponse { + formatted?: string | null events: EvalEvent[] } +export interface BuildResponse { + formatted?: string | null + fileName: string +} + export interface IAPIClient { readonly axiosClient: AxiosInstance - getSuggestions(query: {packageName?: string, value?:string}): Promise - evaluateCode(code: string, format: boolean): Promise - formatCode(code: string): Promise + + getSuggestions(query: { packageName?: string, value?: string }): Promise + + evaluateCode(code: string, format: boolean): Promise + + formatCode(code: string): Promise + + build(code: string, format: boolean): Promise + + getArtifact(fileName: string): Promise + getSnippet(id: string): Promise + shareSnippet(code: string): Promise - compileToWasm(code: string, format: boolean): Promise } export const instantiateStreaming = async (resp, importObject) => { @@ -55,33 +68,34 @@ class Client implements IAPIClient { return this.client; } - constructor(private client: axios.AxiosInstance) {} + constructor(private client: axios.AxiosInstance) { + } - async getSuggestions(query: {packageName?: string, value?:string}): Promise { + async getSuggestions(query: { packageName?: string, value?: string }): Promise { const queryParams = Object.keys(query).map(k => `${k}=${query[k]}`).join('&'); return this.get(`/suggest?${queryParams}`); } - async compileToWasm(code: string, format: boolean): Promise { - const resp = await fetch(`${apiAddress}/compile?format=${Boolean(format)}`, { - method: 'POST', - body: code, - }); + async build(code: string, format: boolean): Promise { + return this.post(`/compile?format=${Boolean(format)}`, code); + } + async getArtifact(fileName: string): Promise { + const resp = await fetch(`${apiAddress}/artifacts/${fileName}`); if (resp.status >= 300) { - const err = await resp.json(); - throw new Error(err.message ?? resp.statusText); + const err = await resp.json(); + throw new Error(err.message ?? resp.statusText); } return resp; } - async evaluateCode(code: string, format: boolean): Promise { - return this.post(`/run?format=${Boolean(format)}`, code); + async evaluateCode(code: string, format: boolean): Promise { + return this.post(`/run?format=${Boolean(format)}`, code); } - async formatCode(code: string): Promise { - return this.post('/format', code); + async formatCode(code: string): Promise { + return this.post('/format', code); } async getSnippet(id: string): Promise { @@ -96,7 +110,7 @@ class Client implements IAPIClient { try { const resp = await this.client.get(uri); return resp.data; - } catch(err) { + } catch (err) { throw Client.extractAPIError(err); } } @@ -105,7 +119,7 @@ class Client implements IAPIClient { try { const resp = await this.client.post(uri, data, cfg); return resp.data; - } catch(err) { + } catch (err) { throw Client.extractAPIError(err); } } diff --git a/web/src/store/actions.ts b/web/src/store/actions.ts index ade1d340..c52c1349 100644 --- a/web/src/store/actions.ts +++ b/web/src/store/actions.ts @@ -1,4 +1,4 @@ -import {CompilerResponse, EvalEvent} from "../services/api"; +import {RunResponse, EvalEvent} from "../services/api"; import {MonacoSettings, RuntimeType} from "../services/config"; export enum ActionType { @@ -50,7 +50,7 @@ export const newFileChangeAction = (contents: string) => payload: contents, }); -export const newBuildResultAction = (resp: CompilerResponse) => +export const newBuildResultAction = (resp: RunResponse) => ({ type: ActionType.COMPILE_RESULT, payload: resp, diff --git a/web/src/store/dispatch.ts b/web/src/store/dispatch.ts index 77ccf037..aa676ee5 100644 --- a/web/src/store/dispatch.ts +++ b/web/src/store/dispatch.ts @@ -112,9 +112,11 @@ export const runFileDispatcher: Dispatcher = dispatch(newBuildResultAction(res)); break; case RuntimeType.WebAssembly: - let resp = await client.compileToWasm(editor.code, settings.autoFormat); - let instance = await instantiateStreaming(resp, getImportObject()); + let resp = await client.build(editor.code, settings.autoFormat); + let wasmFile = await client.getArtifact(resp.fileName); + let instance = await instantiateStreaming(wasmFile, getImportObject()); dispatch({type: ActionType.EVAL_START}); + dispatch(newBuildResultAction({formatted: resp.formatted, events: []})); goRun(instance) .then(result => console.log('exit code: %d', result)) .catch(err => console.log('err', err)) diff --git a/web/src/store/reducers.ts b/web/src/store/reducers.ts index 17917698..800b2c7c 100644 --- a/web/src/store/reducers.ts +++ b/web/src/store/reducers.ts @@ -8,7 +8,7 @@ import { State, StatusState, } from './state'; -import {CompilerResponse, EvalEvent} from '../services/api'; +import {RunResponse, EvalEvent} from '../services/api'; import localConfig, {MonacoSettings, RuntimeType} from '../services/config' import {mapByAction} from './helpers'; import config from "../services/config"; @@ -27,7 +27,7 @@ const reducers = { fileName, }; }, - [ActionType.COMPILE_RESULT]: (s: EditorState, a: Action) => { + [ActionType.COMPILE_RESULT]: (s: EditorState, a: Action) => { if (a.payload.formatted) { s.code = a.payload.formatted; } @@ -36,7 +36,7 @@ const reducers = { }, }, {fileName: 'main.go', code: ''}), status: mapByAction({ - [ActionType.COMPILE_RESULT]: (s: StatusState, a: Action) => { + [ActionType.COMPILE_RESULT]: (s: StatusState, a: Action) => { return { loading: false, lastError: null, From dc7f456f38c015104ab560e473dea1e8190f730c Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 06:58:33 +0200 Subject: [PATCH 22/30] server: add garbage collector and graceful shutdown --- cmd/playground/main.go | 101 ++++++++++++++++++++++++++-------- go.mod | 1 + go.sum | 2 + pkg/compiler/storage/local.go | 5 +- 4 files changed, 85 insertions(+), 24 deletions(-) diff --git a/cmd/playground/main.go b/cmd/playground/main.go index d7bfeae4..d0bdbff6 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -1,28 +1,42 @@ package main import ( + "context" "flag" "fmt" + "net/http" + "os" + "sync" + "time" + "github.com/gorilla/mux" + "github.com/x1unix/foundation/app" "github.com/x1unix/go-playground/pkg/analyzer" "github.com/x1unix/go-playground/pkg/compiler" "github.com/x1unix/go-playground/pkg/compiler/storage" "github.com/x1unix/go-playground/pkg/langserver" "go.uber.org/zap" - "log" - "net/http" - "os" ) +type appArgs struct { + packagesFile string + addr string + debug bool + buildDir string + cleanupInterval string +} + +func (a appArgs) getCleanDuration() (time.Duration, error) { + return time.ParseDuration(a.cleanupInterval) +} + func main() { - var packagesFile string - var addr string - var debug bool - var buildDir string - flag.StringVar(&packagesFile, "f", "packages.json", "Path to packages index JSON file") - flag.StringVar(&addr, "addr", ":8080", "TCP Listen address") - flag.StringVar(&buildDir, "wasm-build-dir", os.TempDir(), "Directory for WASM builds") - flag.BoolVar(&debug, "debug", false, "Enable debug mode") + args := appArgs{} + flag.StringVar(&args.packagesFile, "f", "packages.json", "Path to packages index JSON file") + flag.StringVar(&args.addr, "addr", ":8080", "TCP Listen address") + flag.StringVar(&args.buildDir, "wasm-build-dir", os.TempDir(), "Directory for WASM builds") + flag.StringVar(&args.cleanupInterval, "clean-interval", "10m", "Build directory cleanup interval") + flag.BoolVar(&args.debug, "debug", false, "Enable debug mode") goRoot, ok := os.LookupEnv("GOROOT") if !ok { @@ -31,9 +45,9 @@ func main() { } flag.Parse() - l := getLogger(debug) + l := getLogger(args.debug) defer l.Sync() - if err := start(packagesFile, addr, goRoot, buildDir, debug); err != nil { + if err := start(goRoot, args); err != nil { l.Sugar().Fatal(err) } } @@ -55,37 +69,78 @@ func getLogger(debug bool) (l *zap.Logger) { return l } -func start(packagesFile, addr, goRoot, buildDir string, debug bool) error { +func start(goRoot string, args appArgs) error { + cleanInterval, err := args.getCleanDuration() + if err != nil { + return fmt.Errorf("invalid cleanup interval parameter: %s", err) + } + zap.S().Infof("GOROOT is %q", goRoot) - zap.S().Infof("Packages file is %q", packagesFile) + zap.S().Infof("Packages file is %q", args.packagesFile) + zap.S().Infof("Cleanup interval is %s", cleanInterval.String()) analyzer.SetRoot(goRoot) - packages, err := analyzer.ReadPackagesFile(packagesFile) + packages, err := analyzer.ReadPackagesFile(args.packagesFile) if err != nil { - return fmt.Errorf("failed to read packages file %q: %s", packagesFile, err) + return fmt.Errorf("failed to read packages file %q: %s", args.packagesFile, err) } - store, err := storage.NewLocalStorage(zap.S(), buildDir) + store, err := storage.NewLocalStorage(zap.S(), args.buildDir) if err != nil { return err } + ctx, _ := app.GetApplicationContext() + wg := &sync.WaitGroup{} + go store.StartCleaner(ctx, cleanInterval, wg) + r := mux.NewRouter() langserver.New(packages, compiler.NewBuildService(zap.S(), store)). Mount(r.PathPrefix("/api").Subrouter()) r.PathPrefix("/").Handler(langserver.SpaFileServer("./public")) - zap.S().Infof("Listening on %q", addr) - var handler http.Handler - if debug { + if args.debug { zap.S().Info("Debug mode enabled, CORS disabled") handler = langserver.NewCORSDisablerWrapper(r) } else { handler = r } - if err := http.ListenAndServe(addr, handler); err != nil { - log.Fatal(err) + server := &http.Server{ + Addr: args.addr, + Handler: handler, + ReadTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 15 * time.Second, + } + + if err := startHttpServer(ctx, wg, server); err != nil { + return err + } + + wg.Wait() + return nil +} + +func startHttpServer(ctx context.Context, wg *sync.WaitGroup, server *http.Server) error { + logger := zap.S() + go func() { + <-ctx.Done() + logger.Info("Shutting down server...") + shutdownCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + defer wg.Done() + server.SetKeepAlivesEnabled(false) + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Errorf("Could not gracefully shutdown the server: %v\n", err) + } + return + }() + + wg.Add(1) + logger.Infof("Listening on %q", server.Addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return fmt.Errorf("cannot start server on %q: %s", server.Addr, err) } return nil diff --git a/go.mod b/go.mod index a296394f..646027c3 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/pkg/errors v0.8.1 github.com/stretchr/testify v1.4.0 github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 + github.com/x1unix/foundation v1.0.0 go.uber.org/atomic v1.5.1 // indirect go.uber.org/multierr v1.4.0 // indirect go.uber.org/zap v1.13.0 diff --git a/go.sum b/go.sum index e6ec0a71..e5da46f8 100644 --- a/go.sum +++ b/go.sum @@ -23,6 +23,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5 h1:hNna6Fi0eP1f2sMBe/rJicDmaHmoXGe1Ta84FPYHLuE= github.com/tevino/abool v0.0.0-20170917061928-9b9efcf221b5/go.mod h1:f1SCnEOt6sc3fOJfPQDRDzHOtSXuTtnz0ImG9kPRDV0= +github.com/x1unix/foundation v1.0.0 h1:tG0dG1sbiF9TGrjwns+wtX5feBprRD5iTvpmgQDnacA= +github.com/x1unix/foundation v1.0.0/go.mod h1:y9E4igeUWi+njm4xCM48NItLhVH/Jj1KGE069I3J5Hc= go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.5.1 h1:rsqfU5vBkVknbhUGbAUwQKR2H4ItV8tjJ+6kJX4cxHM= diff --git a/pkg/compiler/storage/local.go b/pkg/compiler/storage/local.go index d8b19717..89be0849 100644 --- a/pkg/compiler/storage/local.go +++ b/pkg/compiler/storage/local.go @@ -164,7 +164,7 @@ func (s LocalStorage) clean() error { return nil } -func (s LocalStorage) StartCleaner(ctx context.Context, interval time.Duration) { +func (s LocalStorage) StartCleaner(ctx context.Context, interval time.Duration, wg *sync.WaitGroup) { s.gcRun.Set() s.log.Debug("cleaner worker starter") for { @@ -172,6 +172,9 @@ func (s LocalStorage) StartCleaner(ctx context.Context, interval time.Duration) case <-ctx.Done(): s.log.Debug("context done, cleaner worker stopped") s.gcRun.UnSet() + if wg != nil { + wg.Done() + } return default: } From 5bfbc52e1e2a5ebdbe975809e732b65a35283dcb Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:01:02 +0200 Subject: [PATCH 23/30] server: don't wait for GC to finish --- cmd/playground/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/playground/main.go b/cmd/playground/main.go index d0bdbff6..6e23c8f3 100644 --- a/cmd/playground/main.go +++ b/cmd/playground/main.go @@ -91,7 +91,7 @@ func start(goRoot string, args appArgs) error { ctx, _ := app.GetApplicationContext() wg := &sync.WaitGroup{} - go store.StartCleaner(ctx, cleanInterval, wg) + go store.StartCleaner(ctx, cleanInterval, nil) r := mux.NewRouter() langserver.New(packages, compiler.NewBuildService(zap.S(), store)). From c977d4a8b7c1625134d2621165eb99c8848ac36a Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:14:31 +0200 Subject: [PATCH 24/30] server: clean storage at init if dir is empty --- pkg/compiler/storage/local.go | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/pkg/compiler/storage/local.go b/pkg/compiler/storage/local.go index 89be0849..456b9162 100644 --- a/pkg/compiler/storage/local.go +++ b/pkg/compiler/storage/local.go @@ -44,25 +44,49 @@ type LocalStorage struct { binDir string } -func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (*LocalStorage, error) { +func NewLocalStorage(log *zap.SugaredLogger, baseDir string) (ls *LocalStorage, err error) { + logger := log.Named("storage") + isDirty := false workDir := filepath.Join(baseDir, workDirName) - if err := os.MkdirAll(workDir, perm); err != nil { + if err = os.MkdirAll(workDir, perm); err != nil { if !os.IsExist(err) { return nil, errors.Wrap(err, "failed to create temporary build directory for WASM compiler") } } + isDirty, err = isDirDirty(workDir) + if err != nil { + logger.Errorw("failed to check if work dir is dirty", "err", err) + } + + if isDirty { + logger.Info("storage directory is not empty and dirty") + } + return &LocalStorage{ + log: logger, workDir: workDir, useLock: &sync.Mutex{}, - dirty: abool.NewBool(false), + dirty: abool.NewBool(isDirty), gcRun: abool.NewBool(false), - log: log.Named("storage"), binDir: filepath.Join(workDir, binDirName), srcDir: filepath.Join(workDir, srcDirName), }, nil } +func isDirDirty(dir string) (bool, error) { + items, err := ioutil.ReadDir(dir) + if os.IsNotExist(err) { + return false, nil + } + + if err != nil { + return false, err + } + + return len(items) > 0, nil +} + func (s LocalStorage) getOutputLocation(id ArtifactID) string { return filepath.Join(s.binDir, id.Ext(ExtWasm)) } From c2f31585fec25eceea9bcf3ed4bf11d5f28f38a8 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:28:04 +0200 Subject: [PATCH 25/30] docker: pass version and clean interval --- build/Dockerfile | 7 +++++-- docker.mk | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/build/Dockerfile b/build/Dockerfile index 669b382c..4dda496b 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -1,7 +1,9 @@ FROM node:13-alpine as ui-build COPY web /tmp/web WORKDIR /tmp/web -RUN yarn install --silent && yarn build +ARG APP_VERSION="1.0.0" +ARG GITHUB_URL="https://github.com/x1unix/go-playground" +RUN REACT_APP_VERSION=${APP_VERSION} REACT_APP_GITHUB_URL=${GITHUB_URL} yarn install --silent && yarn build FROM golang:1.13-alpine as build WORKDIR /tmp/playground @@ -14,8 +16,9 @@ RUN go build -o server ./cmd/playground FROM golang:1.13-alpine as production WORKDIR /opt/playground ENV GOROOT /usr/local/go +ENV APP_CLEAN_INTERVAL=10m COPY data ./data COPY --from=ui-build /tmp/web/build ./public COPY --from=build /tmp/playground/server . EXPOSE 8000 -ENTRYPOINT ["/opt/playground/server", "-f=/opt/playground/data/packages.json", "-addr=:8000"] \ No newline at end of file +ENTRYPOINT /opt/playground/server -f=/opt/playground/data/packages.json -addr=:8000 -clean-interval=${APP_CLEAN_INTERVAL} \ No newline at end of file diff --git a/docker.mk b/docker.mk index b2921005..6acb9d27 100644 --- a/docker.mk +++ b/docker.mk @@ -23,4 +23,4 @@ docker-make-image: echo "required parameter TAG is undefined" && exit 1; \ fi; @echo "- Building '$(IMG_NAME):latest' $(TAG)..." - docker image build -t $(IMG_NAME):latest -t $(IMG_NAME):$(TAG) -f $(DOCKERFILE) . \ No newline at end of file + docker image build -t $(IMG_NAME):latest -t $(IMG_NAME):$(TAG) -f $(DOCKERFILE) --build-arg APP_VERSION=$(TAG) . \ No newline at end of file From 86c7586dcb21efeadc488f126ebfe60fcc034164 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:30:18 +0200 Subject: [PATCH 26/30] make: rename recipe make-docker-image --- docker.mk | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docker.mk b/docker.mk index 6acb9d27..1f68671a 100644 --- a/docker.mk +++ b/docker.mk @@ -2,7 +2,7 @@ DOCKERFILE ?= ./build/Dockerfile IMG_NAME ?= x1unix/go-playground .PHONY: docker -docker: docker-login docker-make-image +docker: docker-login docker-image @echo "- Pushing $(IMG_NAME):$(TAG) (as latest)..." docker push $(IMG_NAME):$(TAG) docker push $(IMG_NAME):latest @@ -17,8 +17,8 @@ docker-login: fi; @docker login -u $(DOCKER_USER) -p $(DOCKER_PASS) && echo "- Docker login success"; -.PHONY: docker-make-image -docker-make-image: +.PHONY: docker-image +docker-image: @if [ -z "$(TAG)" ]; then\ echo "required parameter TAG is undefined" && exit 1; \ fi; From 3b4269b4316eb0229ea27c67f147b0cddf1ab858 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:35:14 +0200 Subject: [PATCH 27/30] fix local_test.go --- pkg/compiler/storage/local_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/compiler/storage/local_test.go b/pkg/compiler/storage/local_test.go index 5a2d5457..362dbf81 100644 --- a/pkg/compiler/storage/local_test.go +++ b/pkg/compiler/storage/local_test.go @@ -30,7 +30,7 @@ func TestLocalStorage_GetItem(t *testing.T) { // Start trash collector in background ctx, cancelFunc := context.WithCancel(context.Background()) cleanInterval := time.Second * 2 - go s.StartCleaner(ctx, cleanInterval) + go s.StartCleaner(ctx, cleanInterval, nil) defer cancelFunc() runtime.Gosched() // Ask Go to switch to cleaner goroutine r.True(s.gcRun.IsSet(), "gc start flag not true") From cf4fd49d2ae5e6aef4910cc8a4e928fd6c9db350 Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:40:48 +0200 Subject: [PATCH 28/30] ui: set dev version --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8e577040..d9763cfb 100644 --- a/Makefile +++ b/Makefile @@ -16,5 +16,5 @@ run: .PHONY:ui ui: - @cd $(UI) && REACT_APP_LANG_SERVER=http://$(LISTEN_ADDR) yarn start + @cd $(UI) && REACT_APP_LANG_SERVER=http://$(LISTEN_ADDR) REACT_APP_VERSION=testing yarn start From 9e7b8cb1ed2a5db31d6686a74a699ef9893e33be Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 07:59:09 +0200 Subject: [PATCH 29/30] docker: pass version and debug params --- build/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/build/Dockerfile b/build/Dockerfile index 4dda496b..7764e051 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -3,7 +3,7 @@ COPY web /tmp/web WORKDIR /tmp/web ARG APP_VERSION="1.0.0" ARG GITHUB_URL="https://github.com/x1unix/go-playground" -RUN REACT_APP_VERSION=${APP_VERSION} REACT_APP_GITHUB_URL=${GITHUB_URL} yarn install --silent && yarn build +RUN yarn install --silent && REACT_APP_VERSION=$APP_VERSION REACT_APP_GITHUB_URL=$GITHUB_URL yarn build FROM golang:1.13-alpine as build WORKDIR /tmp/playground @@ -17,8 +17,9 @@ FROM golang:1.13-alpine as production WORKDIR /opt/playground ENV GOROOT /usr/local/go ENV APP_CLEAN_INTERVAL=10m +ENV APP_DEBUG=false COPY data ./data COPY --from=ui-build /tmp/web/build ./public COPY --from=build /tmp/playground/server . EXPOSE 8000 -ENTRYPOINT /opt/playground/server -f=/opt/playground/data/packages.json -addr=:8000 -clean-interval=${APP_CLEAN_INTERVAL} \ No newline at end of file +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 From 63994042ca75bcc1159e325f9b0520eee2a9c11f Mon Sep 17 00:00:00 2001 From: x1unix Date: Mon, 27 Jan 2020 08:03:50 +0200 Subject: [PATCH 30/30] update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 816910ac..5c4af206 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ Improved Go Playground powered by Monaco Editor and React * 💡 Code autocomplete * 💾 Load and save files +* 🛠 [WebAssembly](https://github.com/golang/go/wiki/WebAssembly) support * 🌚 Dark theme + And more ## Demo