├── .dockerignore ├── .github └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── editor │ ├── css │ │ └── css.go │ ├── dom │ │ ├── document.go │ │ ├── element.go │ │ ├── rect.go │ │ └── window.go │ ├── editor.go │ ├── ide │ │ ├── command.go │ │ ├── console.go │ │ ├── dropdown.css │ │ ├── dropdown.go │ │ ├── editor.go │ │ ├── settings.css │ │ ├── settings.go │ │ ├── settings.html │ │ ├── tab.go │ │ ├── tab_pane.go │ │ ├── window.css │ │ ├── window.go │ │ └── window.html │ ├── main.go │ ├── plaineditor │ │ ├── editor.go │ │ └── typer.go │ ├── taskconsole │ │ ├── command_ctx.go │ │ ├── console.go │ │ └── writer_elem.go │ └── terminal │ │ └── terminal.go └── sh │ └── main.go ├── go.mod ├── go.sum ├── http_get.go ├── install.go ├── internal ├── cmd │ └── gozip │ │ └── main.go ├── common │ ├── fid.go │ ├── js.go │ ├── path.go │ └── pid.go ├── console │ ├── console.go │ ├── js_console.go │ └── writer_elem.go ├── fs │ ├── attr.go │ ├── clearfs.go │ ├── download.go │ ├── file_descriptor.go │ ├── file_descriptors.go │ ├── file_descriptors_js.go │ ├── fs.go │ ├── fs_js.go │ ├── fs_other.go │ ├── null_file.go │ ├── pipe.go │ ├── read.go │ ├── stdout.go │ ├── unimplemented.go │ ├── wasm_cache.go │ ├── working_directory.go │ └── write.go ├── fsutil │ └── fsutil.go ├── global │ └── init.go ├── interop │ ├── download.go │ ├── error.go │ ├── error_js.go │ ├── event.go │ ├── funcs.go │ ├── load.go │ ├── once_func.go │ ├── profile.go │ ├── value_caches.go │ └── values.go ├── js │ ├── fs │ │ ├── chmod.go │ │ ├── chown.go │ │ ├── close.go │ │ ├── fchmod.go │ │ ├── flock.go │ │ ├── fs.go │ │ ├── fstat.go │ │ ├── fsync.go │ │ ├── ftruncate.go │ │ ├── lstat.go │ │ ├── mkdir.go │ │ ├── open.go │ │ ├── overlay.go │ │ ├── pipe.go │ │ ├── read.go │ │ ├── readdir.go │ │ ├── rename.go │ │ ├── rmdir.go │ │ ├── stat.go │ │ ├── unlink.go │ │ ├── utimes.go │ │ └── write.go │ └── process │ │ ├── dir.go │ │ ├── groups.go │ │ ├── process.go │ │ ├── spawn.go │ │ ├── umask.go │ │ └── wait.go ├── log │ ├── js_log.go │ ├── log.go │ └── other_log.go ├── process │ ├── attr.go │ ├── context.go │ ├── lookpath.go │ ├── process.go │ ├── process_js.go │ ├── process_other.go │ └── wasm.go ├── promise │ ├── chan.go │ ├── js.go │ ├── promise.go │ └── resolver.go └── terminal │ └── term.go ├── main.go └── server ├── .gitignore ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.css ├── App.js ├── App.test.js ├── ColorScheme.js ├── Compat.css ├── Compat.js ├── Editor.css ├── Editor.js ├── Hackpad.js ├── Loading.css ├── Loading.js ├── Tabs.css ├── Terminal.js ├── WebAssembly.js ├── index.css ├── index.js ├── logo.svg ├── serviceWorker.js └── setupTests.js /.dockerignore: -------------------------------------------------------------------------------- 1 | cache 2 | server/node_modules 3 | server/build 4 | server/public/wasm 5 | .git 6 | out 7 | Dockerfile 8 | .dockerignore 9 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | - name: Build 13 | run: make build 14 | - name: Publish 15 | run: | 16 | out="$(mktemp -d)/out" 17 | mv ./out "$out" 18 | git config --global user.email "" 19 | git config --global user.name "GitHub Pages Deploy" 20 | git checkout --orphan gh-pages 21 | git rm -rf . 22 | mv "$out"/* ./ 23 | echo hackpad.org > CNAME 24 | git add . 25 | git commit -am "Deploy to GitHub Pages" 26 | git push --force origin gh-pages 27 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-go@v3 14 | with: 15 | go-version: 1.20.x 16 | - name: Lint 17 | run: make lint 18 | 19 | build: 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v3 23 | - name: Build image 24 | run: make build 25 | 26 | test: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-go@v3 31 | with: 32 | go-version: 1.20.x 33 | - name: Test 34 | run: make test 35 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | # Default linters, plus these: 4 | - goconst 5 | - gocritic 6 | - gocognit 7 | - gofmt 8 | - gosec 9 | - misspell 10 | - scopelint 11 | - bodyclose 12 | 13 | run: 14 | build-tags: 15 | - js,wasm 16 | 17 | issues: 18 | exclude: 19 | # Disable scopelint errors on table driven tests 20 | - Using the variable on range scope `tc` in function literal 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.20 as go-builder 2 | WORKDIR /src 3 | # Cache go installation first 4 | COPY Makefile /src 5 | RUN make go 6 | # Build command binaries and static assets 7 | COPY . /src 8 | RUN make go-static 9 | 10 | FROM node:14 as node-builder 11 | WORKDIR /src 12 | COPY Makefile /src 13 | COPY ./server /src/server 14 | COPY --from=go-builder /src/server/public/wasm /src/server/public/wasm 15 | RUN make node-static 16 | 17 | FROM nginx:1 18 | 19 | RUN sed -i 's@}@application/wasm wasm;}@' /etc/nginx/mime.types 20 | 21 | COPY --from=node-builder /src/server/build/ /usr/share/nginx/html 22 | RUN test -f /usr/share/nginx/html/index.html 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /usr/bin/env bash 2 | GO_VERSION = 1.20 3 | GOROOT = 4 | PATH := ${PWD}/cache/go/bin:${PWD}/cache/go/misc/wasm:${PATH} 5 | GOOS = js 6 | GOARCH = wasm 7 | export 8 | LINT_VERSION=1.52.2 9 | 10 | .PHONY: serve 11 | serve: 12 | go run ./server 13 | 14 | .PHONY: lint-deps 15 | lint-deps: go 16 | @if ! which golangci-lint >/dev/null || [[ "$$(golangci-lint version 2>&1)" != *${LINT_VERSION}* ]]; then \ 17 | curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin v${LINT_VERSION}; \ 18 | fi 19 | 20 | .PHONY: lint 21 | lint: lint-deps 22 | golangci-lint run 23 | 24 | .PHONY: lint-fix 25 | lint-fix: lint-deps 26 | golangci-lint run --fix 27 | 28 | .PHONY: test-native 29 | test-native: 30 | GOARCH= GOOS= go test \ 31 | -race \ 32 | -coverprofile=cover.out \ 33 | ./... 34 | 35 | .PHONY: test-js 36 | test-js: go 37 | go test \ 38 | -coverprofile=cover_js.out \ 39 | ./... 40 | 41 | .PHONY: test 42 | test: test-native #test-js # TODO restore when this is resolved: https://travis-ci.community/t/goos-js-goarch-wasm-go-run-fails-panic-newosproc-not-implemented/1651 43 | 44 | .PHONY: go-static 45 | go-static: server/public/wasm/go.tar.gz commands 46 | 47 | server/public/wasm: 48 | mkdir -p server/public/wasm 49 | 50 | server/public/wasm/go.tar.gz: server/public/wasm go 51 | GOARCH=$$(go env GOHOSTARCH) GOOS=$$(go env GOHOSTOS) \ 52 | go run ./internal/cmd/gozip cache/go > server/public/wasm/go.tar.gz 53 | 54 | .PHONY: clean 55 | clean: 56 | rm -rf ./out ./server/public/wasm 57 | 58 | cache: 59 | mkdir -p cache 60 | 61 | .PHONY: commands 62 | commands: server/public/wasm/wasm_exec.js server/public/wasm/main.wasm $(patsubst cmd/%,server/public/wasm/%.wasm,$(wildcard cmd/*)) 63 | 64 | .PHONY: go 65 | go: cache/go${GO_VERSION} 66 | 67 | cache/go${GO_VERSION}: cache 68 | if [[ ! -e cache/go${GO_VERSION} ]]; then \ 69 | set -ex; \ 70 | TMP=$$(mktemp -d); trap 'rm -rf "$$TMP"' EXIT; \ 71 | git clone \ 72 | --depth 1 \ 73 | --single-branch \ 74 | --branch hackpad/release-branch.go${GO_VERSION} \ 75 | https://github.com/hack-pad/go.git \ 76 | "$$TMP"; \ 77 | pushd "$$TMP/src"; \ 78 | ./make.bash; \ 79 | export PATH="$$TMP/bin:$$PATH"; \ 80 | go version; \ 81 | mkdir -p ../bin/js_wasm; \ 82 | go build -o ../bin/js_wasm/ std cmd/go cmd/gofmt; \ 83 | go tool dist test -rebuild -list; \ 84 | go build -o ../pkg/tool/js_wasm/ std cmd/buildid cmd/pack cmd/cover cmd/vet; \ 85 | go install ./...; \ 86 | popd; \ 87 | mv "$$TMP" cache/go${GO_VERSION}; \ 88 | ln -sfn go${GO_VERSION} cache/go; \ 89 | fi 90 | touch cache/go${GO_VERSION} 91 | touch cache/go.mod # Makes it so linters will ignore this dir 92 | 93 | server/public/wasm/%.wasm: server/public/wasm go 94 | go build -o $@ ./cmd/$* 95 | 96 | server/public/wasm/main.wasm: server/public/wasm go 97 | go build -o server/public/wasm/main.wasm . 98 | 99 | server/public/wasm/wasm_exec.js: go 100 | cp cache/go/misc/wasm/wasm_exec.js server/public/wasm/wasm_exec.js 101 | 102 | .PHONY: node-static 103 | node-static: 104 | npm --prefix=server ci 105 | npm --prefix=server run build 106 | 107 | .PHONY: watch 108 | watch: 109 | @if [[ ! -d server/node_modules ]]; then \ 110 | npm --prefix=server ci; \ 111 | fi 112 | npm --prefix=server run start-go & \ 113 | npm --prefix=server start 114 | 115 | .PHONY: build 116 | build: build-docker 117 | rm -rf ./out 118 | docker cp $$(docker create --rm hackpad):/usr/share/nginx/html ./out 119 | 120 | .PHONY: build-docker 121 | build-docker: 122 | docker build -t hackpad . 123 | 124 | .PHONY: run-docker 125 | run-docker: build-docker 126 | docker run -it --rm \ 127 | --name hackpad \ 128 | -p 8080:80 \ 129 | hackpad:latest 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hackpad 2 | 3 | Hackpad is a [Go][] development environment with the essentials to write and run code entirely within the browser, using the power of [WebAssembly (Wasm)][wasm]. 4 | 5 | Check out the article announcement on [Medium][], and the site at https://hackpad.org 6 | 7 | 8 | [Go]: https://golang.org 9 | [wasm]: https://webassembly.org 10 | [Medium]: https://johnstarich.medium.com/how-to-compile-code-in-the-browser-with-webassembly-b59ffd452c2b 11 | 12 | ## Contributing 13 | 14 | Want to discuss an idea or a bug? Open up a new [issue][] and we can talk about it. Pull requests are welcome. 15 | 16 | [issue]: https://github.com/hack-pad/hackpad/issues 17 | 18 | 19 | ## Known issues 20 | * Slow compile times - Rewrite runtime to [parallelize with Web Workers](https://github.com/hack-pad/hackpad/issues/11) 21 | * Safari crashes - Regularly crashes due to Wasm memory bugs. [WebKit #222097](https://bugs.webkit.org/show_bug.cgi?id=222097), [#227421](https://bugs.webkit.org/show_bug.cgi?id=227421), [#220313](https://bugs.webkit.org/show_bug.cgi?id=220313) 22 | -------------------------------------------------------------------------------- /cmd/editor/css/css.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package css 5 | 6 | import ( 7 | "fmt" 8 | "syscall/js" 9 | 10 | "github.com/hack-pad/hackpad/cmd/editor/dom" 11 | ) 12 | 13 | var sheet = createStyleSheet() 14 | 15 | func createStyleSheet() js.Value { 16 | style := dom.New("style") 17 | style.SetAttribute("type", "text/css") 18 | dom.Head().AppendChild(style) 19 | return style.GetProperty("sheet") 20 | } 21 | 22 | func Add(css string) { 23 | sheet.Call("insertRule", fmt.Sprintf(`@media all { %s }`, css)) 24 | } 25 | -------------------------------------------------------------------------------- /cmd/editor/dom/document.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package dom 5 | 6 | import "syscall/js" 7 | 8 | var ( 9 | document = &Document{NewFromJS(js.Global().Get("document"))} 10 | body = NewFromJS(document.elem.Get("body")) 11 | head = NewFromJS(document.elem.Get("head")) 12 | ) 13 | 14 | type Document struct { 15 | *Element 16 | } 17 | 18 | func GetDocument() *Document { 19 | return document 20 | } 21 | 22 | func Body() *Element { 23 | return body 24 | } 25 | 26 | func Head() *Element { 27 | return head 28 | } 29 | 30 | func (d *Document) GetElementByID(id string) *Element { 31 | return NewFromJS(d.elem.Call("getElementById", id)) 32 | } 33 | 34 | func (d *Document) Body() *Element { 35 | return body 36 | } 37 | 38 | func (d *Document) Head() *Element { 39 | return head 40 | } 41 | 42 | func (d *Document) CreateElement(tag string) *Element { 43 | return NewFromJS(d.elem.Call("createElement", tag)) 44 | } 45 | -------------------------------------------------------------------------------- /cmd/editor/dom/element.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package dom 5 | 6 | import ( 7 | "runtime/debug" 8 | "syscall/js" 9 | 10 | "github.com/hack-pad/hackpad/internal/common" 11 | "github.com/hack-pad/hackpad/internal/interop" 12 | "github.com/hack-pad/hackpad/internal/log" 13 | ) 14 | 15 | type Element struct { 16 | elem js.Value 17 | } 18 | 19 | type EventListener = func(event js.Value) 20 | 21 | func New(tag string) *Element { 22 | return document.CreateElement(tag) 23 | } 24 | 25 | func NewFromJS(elem js.Value) *Element { 26 | if elem.IsNull() { 27 | return nil 28 | } 29 | return &Element{elem} 30 | } 31 | 32 | func (e *Element) JSValue() js.Value { 33 | return e.elem 34 | } 35 | 36 | func (e *Element) GetProperty(property string) js.Value { 37 | return e.elem.Get(property) 38 | } 39 | 40 | func (e *Element) SetProperty(property string, value js.Value) { 41 | e.elem.Set(property, value) 42 | } 43 | 44 | func (e *Element) AppendChild(child *Element) { 45 | e.elem.Call("appendChild", child.elem) 46 | } 47 | 48 | func (e *Element) InsertBefore(newChild, referenceNode *Element) { 49 | e.elem.Call("insertBefore", newChild.elem, referenceNode.elem) 50 | } 51 | 52 | func (e *Element) FirstChild() *Element { 53 | child := e.elem.Get("firstChild") 54 | if child.IsNull() { 55 | return nil 56 | } 57 | return NewFromJS(child) 58 | } 59 | 60 | func (e *Element) SetInnerHTML(contents string) { 61 | e.elem.Set("innerHTML", contents) 62 | } 63 | 64 | func (e *Element) SetInnerText(contents string) { 65 | e.elem.Set("innerText", contents) 66 | } 67 | 68 | func (e *Element) InnerText() string { 69 | return e.elem.Get("innerText").String() 70 | } 71 | 72 | func (e *Element) AddClass(class string) { 73 | e.elem.Get("classList").Call("add", class) 74 | } 75 | 76 | func (e *Element) RemoveClass(class string) { 77 | e.elem.Get("classList").Call("remove", class) 78 | } 79 | 80 | func (e *Element) ToggleClass(class string) { 81 | e.elem.Get("classList").Call("toggle", class) 82 | } 83 | 84 | func (e *Element) SetStyle(props map[string]interface{}) { 85 | style := e.elem.Get("style") 86 | for prop, value := range props { 87 | style.Set(prop, value) 88 | } 89 | } 90 | 91 | func (e *Element) SetAttribute(prop, value string) { 92 | e.elem.Set(prop, value) 93 | } 94 | 95 | func (e *Element) GetBoundingClientRect() *Rect { 96 | return newRect(e.elem.Call("getBoundingClientRect")) 97 | } 98 | 99 | func (e *Element) QuerySelector(query string) *Element { 100 | return NewFromJS(e.elem.Call("querySelector", query)) 101 | } 102 | 103 | func sliceFromArray(array js.Value) []*Element { 104 | var elements []*Element 105 | for _, node := range interop.SliceFromJSValue(array) { 106 | elements = append(elements, NewFromJS(node)) 107 | } 108 | return elements 109 | } 110 | 111 | func (e *Element) QuerySelectorAll(query string) []*Element { 112 | return sliceFromArray(e.elem.Call("querySelectorAll", query)) 113 | } 114 | 115 | func (e *Element) RemoveEventListener(name string, listener js.Func) { 116 | e.elem.Call("removeEventListener", name, listener) 117 | } 118 | 119 | func (e *Element) AddEventListener(name string, listener EventListener) js.Func { 120 | listenerFunc := js.FuncOf(func(_ js.Value, args []js.Value) interface{} { 121 | defer common.CatchExceptionHandler(func(err error) { 122 | log.Error("recovered from panic: ", err, "\n", string(debug.Stack())) 123 | }) 124 | listener(args[0]) 125 | return nil 126 | }) 127 | e.elem.Call("addEventListener", name, listenerFunc) 128 | return listenerFunc 129 | } 130 | 131 | func (e *Element) Focus() { 132 | e.elem.Call("focus") 133 | } 134 | 135 | func (e *Element) Children() []*Element { 136 | return sliceFromArray(e.elem.Get("children")) 137 | } 138 | 139 | func (e *Element) Remove() { 140 | e.elem.Call("remove") 141 | } 142 | 143 | func (e *Element) Value() string { 144 | return e.elem.Get("value").String() 145 | } 146 | 147 | func (e *Element) SetValue(value string) { 148 | e.elem.Set("value", value) 149 | } 150 | 151 | func (e *Element) SetScrollTop(pixels int) { 152 | e.elem.Set("scrollTop", pixels) 153 | } 154 | -------------------------------------------------------------------------------- /cmd/editor/dom/rect.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package dom 5 | 6 | import "syscall/js" 7 | 8 | type Rect struct { 9 | Left, Top, Right, Bottom float64 10 | Width, Height float64 11 | } 12 | 13 | func newRect(domRect js.Value) *Rect { 14 | return &Rect{ 15 | Left: domRect.Get("left").Float(), 16 | Top: domRect.Get("top").Float(), 17 | Right: domRect.Get("right").Float(), 18 | Bottom: domRect.Get("bottom").Float(), 19 | Width: domRect.Get("width").Float(), 20 | Height: domRect.Get("height").Float(), 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/editor/dom/window.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package dom 5 | 6 | import ( 7 | "syscall/js" 8 | "time" 9 | 10 | "github.com/hack-pad/hackpad/internal/interop" 11 | ) 12 | 13 | var ( 14 | window = NewFromJS(js.Global()) 15 | ) 16 | 17 | func SetTimeout(fn func(args []js.Value), delay time.Duration, args ...js.Value) int { 18 | intArgs := append([]interface{}{ 19 | interop.SingleUseFunc(func(_ js.Value, args []js.Value) interface{} { 20 | fn(args) 21 | return nil 22 | }), 23 | delay.Milliseconds(), 24 | }, interop.SliceFromJSValues(args)...) 25 | timeoutID := window.elem.Call("setTimeout", intArgs...) 26 | return timeoutID.Int() 27 | } 28 | 29 | func QueueMicrotask(fn func()) { 30 | queueMicrotask := window.GetProperty("queueMicrotask") 31 | if queueMicrotask.Truthy() { 32 | queueMicrotask.Invoke(interop.SingleUseFunc(func(this js.Value, args []js.Value) interface{} { 33 | fn() 34 | return nil 35 | })) 36 | } else { 37 | SetTimeout(func(args []js.Value) { 38 | fn() 39 | }, 0) 40 | } 41 | } 42 | 43 | func ViewportRect() *Rect { 44 | width, height := window.GetProperty("innerWidth").Float(), window.GetProperty("innerHeight").Float() 45 | return &Rect{ 46 | Left: 0, 47 | Top: 0, 48 | Right: width, 49 | Bottom: height, 50 | Width: width, 51 | Height: height, 52 | } 53 | } 54 | 55 | func Alert(message string) { 56 | window.elem.Call("alert", message) 57 | } 58 | 59 | func Confirm(prompt string) bool { 60 | return window.elem.Call("confirm", prompt).Bool() 61 | } 62 | 63 | func Reload() { 64 | window.GetProperty("location").Call("reload") 65 | } 66 | -------------------------------------------------------------------------------- /cmd/editor/editor.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "syscall/js" 9 | 10 | "github.com/hack-pad/hackpad/cmd/editor/dom" 11 | "github.com/hack-pad/hackpad/cmd/editor/ide" 12 | "github.com/hack-pad/hackpad/internal/log" 13 | ) 14 | 15 | // editorJSFunc is a JS function that opens on a JS element and returns a JS object with the following spec: 16 | // 17 | // { 18 | // getContents() string 19 | // setContents(string) 20 | // getCursorIndex() int 21 | // setCursorIndex(int) 22 | // } 23 | type editorJSFunc js.Value 24 | 25 | func (e editorJSFunc) New(elem *dom.Element) ide.Editor { 26 | editor := &jsEditor{ 27 | titleChan: make(chan string, 1), 28 | } 29 | editor.elem = js.Value(e).Invoke(elem.JSValue(), js.FuncOf(editor.onEdit)) 30 | return editor 31 | } 32 | 33 | type jsEditor struct { 34 | elem js.Value 35 | filePath string 36 | titleChan chan string 37 | } 38 | 39 | func (j *jsEditor) onEdit(js.Value, []js.Value) interface{} { 40 | go func() { 41 | contents := j.elem.Call("getContents").String() 42 | perm := os.FileMode(0700) 43 | info, err := os.Stat(j.filePath) 44 | if err == nil { 45 | perm = info.Mode() 46 | } 47 | err = os.WriteFile(j.filePath, []byte(contents), perm) 48 | if err != nil { 49 | log.Error("Failed to write file contents: ", err) 50 | } 51 | }() 52 | return nil 53 | } 54 | 55 | func (j *jsEditor) OpenFile(path string) error { 56 | j.filePath = path 57 | j.titleChan <- path 58 | return j.ReloadFile() 59 | } 60 | 61 | func (j *jsEditor) CurrentFile() string { 62 | return j.filePath 63 | } 64 | 65 | func (j *jsEditor) ReloadFile() error { 66 | contents, err := os.ReadFile(j.filePath) 67 | if err != nil { 68 | return err 69 | } 70 | j.elem.Call("setContents", string(contents)) 71 | return nil 72 | } 73 | 74 | func (j *jsEditor) GetCursor() int { 75 | return j.elem.Call("getCursorIndex").Int() 76 | } 77 | 78 | func (j *jsEditor) SetCursor(i int) error { 79 | j.elem.Call("setCursorIndex", i) 80 | return nil 81 | } 82 | 83 | func (j *jsEditor) Titles() <-chan string { 84 | return j.titleChan 85 | } 86 | -------------------------------------------------------------------------------- /cmd/editor/ide/command.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import ( 7 | "strings" 8 | "time" 9 | 10 | "github.com/hack-pad/hackpad/internal/log" 11 | "github.com/hack-pad/hackpad/internal/promise" 12 | ) 13 | 14 | const ( 15 | goBinaryPath = "/usr/local/go/bin/js_wasm/go" 16 | buildConsoleIndex = 0 17 | ) 18 | 19 | // runGoProcess optimizes runProcess by skipping the wait time for listing PATH directories on startup 20 | func (w *window) runGoProcess(console TaskConsole, args ...string) promise.JS { 21 | return w.runRawProcess(console, goBinaryPath, "go", args...) 22 | } 23 | 24 | func (w *window) runProcess(console TaskConsole, name string, args ...string) promise.JS { 25 | return w.runRawProcess(console, name, name, args...) 26 | } 27 | 28 | func (w *window) runRawProcess(console TaskConsole, fullPath, name string, args ...string) promise.JS { 29 | resolve, reject, prom := promise.New() 30 | go func() { 31 | var success bool 32 | var elapsed time.Duration 33 | defer func() { 34 | log.Printf("Process [%s %s] finished: %6.2fs", name, strings.Join(args, " "), elapsed.Seconds()) 35 | }() 36 | success, elapsed = w.startProcess(console, fullPath, name, args...) 37 | if success { 38 | resolve(nil) 39 | } else { 40 | reject(nil) 41 | } 42 | }() 43 | return prom 44 | } 45 | 46 | func (w *window) startProcess(console TaskConsole, rawPath, name string, args ...string) (success bool, elapsed time.Duration) { 47 | if !w.showLoading.CAS(false, true) { 48 | return false, 0 49 | } 50 | startTime := time.Now() 51 | w.loadingElem.AddClass("loading") 52 | defer func() { 53 | w.showLoading.Store(false) 54 | w.loadingElem.RemoveClass("loading") 55 | }() 56 | 57 | ctx, err := console.Start(rawPath, name, args...) 58 | if err != nil { 59 | log.Error("Failed to start process: " + err.Error() + "\n") 60 | return false, 0 61 | } 62 | <-ctx.Done() 63 | commandErr := ctx.Err() 64 | if commandErr != nil { 65 | log.Error(commandErr.Error()) 66 | } 67 | elapsed = time.Since(startTime) 68 | return commandErr == nil, elapsed 69 | } 70 | 71 | func (w *window) runPlayground(console TaskConsole) { 72 | w.runGoProcess(console, "build", "-v", ".").Then(func(_ interface{}) interface{} { 73 | return w.runProcess(console, "./playground").JSValue() 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /cmd/editor/ide/console.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import ( 7 | "context" 8 | 9 | "github.com/hack-pad/hackpad/cmd/editor/dom" 10 | ) 11 | 12 | type ConsoleBuilder interface { 13 | New(elem *dom.Element, rawName, name string, args ...string) (Console, error) 14 | } 15 | 16 | type Console interface { 17 | Tabber 18 | } 19 | 20 | type ConsoleWaiter interface { 21 | Wait() error 22 | } 23 | 24 | type TaskConsoleBuilder interface { 25 | New(elem *dom.Element) TaskConsole 26 | } 27 | 28 | type TaskConsole interface { 29 | Tabber 30 | Start(rawName, name string, args ...string) (context.Context, error) 31 | } 32 | -------------------------------------------------------------------------------- /cmd/editor/ide/dropdown.css: -------------------------------------------------------------------------------- 1 | .dropdown { 2 | visibility: hidden; 3 | z-index: 1000; 4 | position: absolute; 5 | } 6 | 7 | .dropdown-visible { 8 | visibility: visible; 9 | } 10 | -------------------------------------------------------------------------------- /cmd/editor/ide/dropdown.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import ( 7 | _ "embed" 8 | "fmt" 9 | "syscall/js" 10 | 11 | "github.com/hack-pad/hackpad/cmd/editor/css" 12 | "github.com/hack-pad/hackpad/cmd/editor/dom" 13 | ) 14 | 15 | var ( 16 | //go:embed dropdown.css 17 | dropdownCSS string 18 | ) 19 | 20 | func init() { 21 | css.Add(dropdownCSS) 22 | } 23 | 24 | type dropdown struct { 25 | *dom.Element 26 | 27 | attached *dom.Element 28 | opened bool 29 | } 30 | 31 | func newDropdown(attachTo, content *dom.Element) *dropdown { 32 | drop := &dropdown{ 33 | Element: dom.New("div"), 34 | attached: attachTo, 35 | } 36 | dom.GetDocument().AddEventListener("click", func(event js.Value) { 37 | if !event.Call("composedPath").Call("includes", drop.JSValue()).Bool() { 38 | drop.Close() 39 | } 40 | }) 41 | drop.AppendChild(content) 42 | drop.AddClass("dropdown") 43 | dom.Body().InsertBefore(drop.Element, dom.Body().FirstChild()) 44 | return drop 45 | } 46 | 47 | func (d *dropdown) Toggle() { 48 | if d.opened { 49 | d.Close() 50 | } else { 51 | d.Open() 52 | } 53 | } 54 | 55 | func (d *dropdown) Open() { 56 | if d.opened { 57 | return 58 | } 59 | d.opened = true 60 | rect := d.attached.GetBoundingClientRect() 61 | viewportRect := dom.ViewportRect() 62 | top := px(rect.Bottom) 63 | if rect.Left+rect.Width/2 > viewportRect.Left+viewportRect.Right/2 { 64 | // on the right half of the screen, align right 65 | d.SetStyle(map[string]interface{}{ 66 | "top": top, 67 | "right": px(viewportRect.Right - rect.Right), 68 | }) 69 | } else { 70 | // on the left half of the screen, align left 71 | d.SetStyle(map[string]interface{}{ 72 | "top": top, 73 | "left": px(rect.Left), 74 | }) 75 | } 76 | d.AddClass("dropdown-visible") 77 | } 78 | 79 | func px(f float64) string { 80 | return fmt.Sprintf("%fpx", f) 81 | } 82 | 83 | func (d *dropdown) Close() { 84 | if !d.opened { 85 | return 86 | } 87 | d.opened = false 88 | d.RemoveClass("dropdown-visible") 89 | } 90 | -------------------------------------------------------------------------------- /cmd/editor/ide/editor.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import "github.com/hack-pad/hackpad/cmd/editor/dom" 7 | 8 | type EditorBuilder interface { 9 | New(elem *dom.Element) Editor 10 | } 11 | 12 | type Editor interface { 13 | OpenFile(path string) error 14 | CurrentFile() string 15 | ReloadFile() error 16 | GetCursor() int 17 | SetCursor(i int) error 18 | Titles() <-chan string 19 | } 20 | -------------------------------------------------------------------------------- /cmd/editor/ide/settings.css: -------------------------------------------------------------------------------- 1 | .settings-dropdown { 2 | color: var(--app-controls-color); 3 | background-color: var(--app-controls-background); 4 | text-align: left; 5 | width: 20em; 6 | padding: 0.5em 1em; 7 | border-left: 2px solid; 8 | border-bottom: 2px solid; 9 | border-color: var(--app-controls-background-light); 10 | border-bottom-left-radius: 0.5em; 11 | } 12 | 13 | .settings-dropdown button { 14 | cursor: pointer; 15 | } 16 | 17 | .settings-dropdown ul { 18 | padding: 0; 19 | margin: 0; 20 | } 21 | 22 | .settings-dropdown ul li { 23 | list-style: none; 24 | margin: 0.5em; 25 | } 26 | 27 | .settings-dropdown .danger { 28 | color: var(--color-danger); 29 | } 30 | -------------------------------------------------------------------------------- /cmd/editor/ide/settings.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import ( 7 | _ "embed" 8 | "fmt" 9 | "os" 10 | "syscall/js" 11 | 12 | "github.com/hack-pad/hackpad/cmd/editor/css" 13 | "github.com/hack-pad/hackpad/cmd/editor/dom" 14 | "github.com/hack-pad/hackpad/internal/global" 15 | "github.com/hack-pad/hackpad/internal/interop" 16 | "github.com/hack-pad/hackpad/internal/promise" 17 | ) 18 | 19 | var ( 20 | //go:embed settings.html 21 | settingsHTML string 22 | //go:embed settings.css 23 | settingsCSS string 24 | ) 25 | 26 | const ( 27 | goInstallPath = "/usr/local/go" 28 | ) 29 | 30 | func init() { 31 | css.Add(settingsCSS) 32 | } 33 | 34 | func newSettings() *dom.Element { 35 | button := dom.New("button") 36 | button.SetInnerHTML(``) 37 | button.SetAttribute("className", "control") 38 | button.SetAttribute("title", "Settings") 39 | 40 | drop := newSettingsDropdown(button) 41 | button.AddEventListener("click", func(event js.Value) { 42 | event.Call("stopPropagation") 43 | drop.Toggle() 44 | }) 45 | return button 46 | } 47 | 48 | func newSettingsDropdown(attachTo *dom.Element) *dropdown { 49 | elem := dom.New("div") 50 | elem.SetInnerHTML(settingsHTML) 51 | elem.AddClass("settings-dropdown") 52 | drop := newDropdown(attachTo, elem) 53 | 54 | listenButton := func(name, prompt string, fn func()) { 55 | elem. 56 | QuerySelector(fmt.Sprintf("button[title=%q]", name)). 57 | AddEventListener("click", func(event js.Value) { 58 | if prompt == "" || dom.Confirm(prompt) { 59 | go fn() 60 | } 61 | }) 62 | } 63 | 64 | destroyMount := func(path string) promise.Promise { 65 | return promise.From(global.Get("destroyMount").Invoke(path)) 66 | } 67 | listenButton("reset", "Erase all data and reload?", func() { 68 | mounts := interop.StringsFromJSValue(global.Get("getMounts").Invoke()) 69 | var promises []promise.Promise 70 | for _, mount := range mounts { 71 | promises = append(promises, destroyMount(mount)) 72 | } 73 | for _, p := range promises { 74 | _, _ = p.Await() 75 | } 76 | dom.Reload() 77 | }) 78 | listenButton("clean build cache", "", func() { 79 | cache, err := os.UserCacheDir() 80 | if err == nil { 81 | destroyMount(cache) 82 | } 83 | }) 84 | listenButton("reload programs", "Reinstall programs and reload?", func() { 85 | _, _ = destroyMount("/bin").Await() 86 | _, _ = destroyMount(goInstallPath).Await() 87 | dom.Reload() 88 | }) 89 | return drop 90 | } 91 | -------------------------------------------------------------------------------- /cmd/editor/ide/settings.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /cmd/editor/ide/tab.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import ( 7 | "context" 8 | "syscall/js" 9 | 10 | "github.com/hack-pad/hackpad/cmd/editor/dom" 11 | ) 12 | 13 | type Tab struct { 14 | id int 15 | button *dom.Element 16 | buttonListener dom.EventListener 17 | contents *dom.Element 18 | title *dom.Element 19 | stopTitlesLoop context.CancelFunc 20 | } 21 | 22 | func newTab(id int, button, contents, title *dom.Element, tabber Tabber, focus func(id int)) *Tab { 23 | ctx, cancel := context.WithCancel(context.Background()) 24 | t := &Tab{ 25 | id: id, 26 | button: button, 27 | contents: contents, 28 | title: title, 29 | stopTitlesLoop: cancel, 30 | } 31 | go t.watchTitles(ctx, tabber) 32 | 33 | t.buttonListener = func(event js.Value) { 34 | formElem := t.title.QuerySelector("input") 35 | if formElem == nil { 36 | focus(t.id) 37 | } 38 | } 39 | button.AddEventListener("click", t.buttonListener) 40 | 41 | return t 42 | } 43 | 44 | func (t *Tab) Focus() { 45 | t.contents.AddClass("active") 46 | t.button.AddClass("active") 47 | firstInput := t.contents.QuerySelector("input, select, textarea") 48 | if firstInput != nil { 49 | firstInput.Focus() 50 | } 51 | } 52 | 53 | func (t *Tab) Unfocus() { 54 | t.contents.RemoveClass("active") 55 | t.button.RemoveClass("active") 56 | } 57 | 58 | func (t *Tab) Close() { 59 | t.stopTitlesLoop() 60 | } 61 | 62 | func (t *Tab) watchTitles(ctx context.Context, tabber Tabber) { 63 | titles := tabber.Titles() 64 | for { 65 | select { 66 | case <-ctx.Done(): 67 | return 68 | case title, ok := <-titles: 69 | if ok { 70 | t.title.SetInnerText(title) 71 | } else { 72 | t.Close() 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /cmd/editor/ide/tab_pane.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package ide 5 | 6 | import ( 7 | "syscall/js" 8 | 9 | "github.com/hack-pad/hackpad/cmd/editor/dom" 10 | ) 11 | 12 | type Tabber interface { 13 | Titles() <-chan string 14 | } 15 | 16 | type TabPane struct { 17 | *dom.Element 18 | lastTabID int 19 | tabButtonsParent *dom.Element 20 | tabsParent *dom.Element 21 | tabs []*Tab 22 | currentTab int 23 | makeDefaultTab TabBuilder 24 | newTabOptions TabOptions 25 | closedTabListener func(index int) 26 | } 27 | 28 | type TabOptions struct { 29 | NoFocus bool // skips focusing after creating the tab 30 | NoClose bool // disables the close button 31 | } 32 | 33 | type TabBuilder func(id int, title, contents *dom.Element) Tabber 34 | 35 | func NewTabPane(newTabOptions TabOptions, makeDefaultTab TabBuilder, closedTab func(index int)) *TabPane { 36 | elem := dom.New("div") 37 | elem.AddClass("pane") 38 | elem.SetInnerHTML(` 39 | 43 |
44 | `) 45 | p := &TabPane{ 46 | Element: elem, 47 | tabButtonsParent: elem.QuerySelector(".tab-buttons"), 48 | tabsParent: elem.QuerySelector(".tabs"), 49 | tabs: nil, 50 | currentTab: -1, 51 | makeDefaultTab: makeDefaultTab, 52 | newTabOptions: newTabOptions, 53 | closedTabListener: closedTab, 54 | } 55 | elem.QuerySelector(".tab-new").AddEventListener("click", func(js.Value) { 56 | p.NewTab(newTabOptions, p.makeDefaultTab) 57 | }) 58 | return p 59 | } 60 | 61 | func (p *TabPane) NewDefaultTab(options TabOptions) Tabber { 62 | return p.NewTab(options, p.makeDefaultTab) 63 | } 64 | 65 | func (p *TabPane) NewTab(options TabOptions, makeTab TabBuilder) Tabber { 66 | contents := dom.New("div") 67 | contents.AddClass("tab") 68 | p.tabsParent.AppendChild(contents) 69 | 70 | tabItem := dom.New("li") 71 | tabItem.AddClass("tab-button") 72 | buttonTemplate := ` 73 | New file 74 | ` 75 | if !options.NoClose { 76 | buttonTemplate += `` 77 | } 78 | tabItem.SetInnerHTML(buttonTemplate) 79 | title := tabItem.QuerySelector(".tab-title") 80 | p.tabButtonsParent.AppendChild(tabItem) 81 | 82 | id := p.lastTabID 83 | p.lastTabID++ 84 | tabber := makeTab(id, title, contents) 85 | tab := newTab(id, tabItem, contents, title, tabber, p.focusID) 86 | p.tabs = append(p.tabs, tab) 87 | 88 | if !options.NoClose { 89 | closeButton := tabItem.QuerySelector(".tab-close") 90 | closeButton.AddEventListener("click", func(event js.Value) { 91 | event.Call("stopPropagation") 92 | p.closeTabID(tab.id) 93 | }) 94 | } 95 | 96 | if !options.NoFocus { 97 | p.focusID(tab.id) 98 | } 99 | return tabber 100 | } 101 | 102 | func (p *TabPane) Focus(index int) { 103 | if index >= 0 { 104 | p.focusID(p.tabs[index].id) 105 | } 106 | } 107 | 108 | func (p *TabPane) focusID(id int) { 109 | if p.currentTab >= 0 { 110 | p.tabs[p.currentTab].Unfocus() 111 | } 112 | for i, tab := range p.tabs { 113 | if tab.id == id { 114 | p.currentTab = i 115 | tab.Focus() 116 | return 117 | } 118 | } 119 | } 120 | 121 | func (p *TabPane) Close() { 122 | } 123 | 124 | func (p *TabPane) CloseTab(index int) { 125 | if index >= 0 { 126 | p.closeTabID(p.tabs[index].id) 127 | } 128 | } 129 | 130 | func (p *TabPane) closeTabID(id int) { 131 | var tabIndex int 132 | var tab *Tab 133 | for i, t := range p.tabs { 134 | if t.id == id { 135 | tabIndex = i 136 | tab = t 137 | break 138 | } 139 | } 140 | if tab == nil { 141 | return 142 | } 143 | 144 | tab.Close() 145 | p.tabButtonsParent.Children()[tabIndex].Remove() 146 | p.tabsParent.Children()[tabIndex].Remove() 147 | 148 | var newTabs []*Tab 149 | newTabs = append(newTabs, p.tabs[:tabIndex]...) 150 | newTabs = append(newTabs, p.tabs[tabIndex+1:]...) 151 | p.tabs = newTabs 152 | if p.currentTab == len(p.tabs) { 153 | p.currentTab = len(p.tabs) - 1 154 | } 155 | p.Focus(p.currentTab) 156 | 157 | p.closedTabListener(tabIndex) 158 | } 159 | -------------------------------------------------------------------------------- /cmd/editor/ide/window.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-danger: #af1a1a; 3 | 4 | } 5 | 6 | @media screen and (prefers-color-scheme: dark) { 7 | :root { 8 | --color-danger: #ff8989; 9 | } 10 | } 11 | 12 | .ide { 13 | display: flex; 14 | flex-direction: column; 15 | } 16 | 17 | .ide header, .ide header .controls { 18 | height: var(--controls-height); 19 | overflow: hidden; 20 | display: flex; 21 | flex-direction: row; 22 | align-items: center; 23 | background-color: var(--app-controls-background); 24 | } 25 | 26 | .ide header { 27 | padding: 0 1em; 28 | } 29 | 30 | .ide .panes { 31 | overflow: hidden; 32 | max-height: var(--panes-height); 33 | height: var(--panes-height); 34 | display: flex; 35 | flex-direction: column; 36 | } 37 | 38 | .ide .pane { 39 | overflow: hidden; 40 | border-top: var(--console-border); 41 | display: flex; 42 | flex-direction: column; 43 | flex-grow: 1; 44 | flex-basis: 0; 45 | font-size: 0.9em; 46 | } 47 | 48 | .ide .editors { 49 | flex-grow: 10; 50 | overflow: hidden; 51 | max-height: 60vh; 52 | } 53 | 54 | .ide .pane:first-child .tab-bar { 55 | font-family: var(--font-mono); 56 | } 57 | 58 | .ide .controls { 59 | flex-grow: 1; 60 | } 61 | 62 | .ide .controls .control { 63 | padding: 0; 64 | margin: 0.1rem; 65 | height: 2rem; 66 | width: 2rem; 67 | border: none; 68 | border-radius: 0.25em; 69 | background-color: transparent; 70 | color: var(--app-controls-color); 71 | outline: none; 72 | cursor: pointer; 73 | } 74 | 75 | .ide .controls .control:hover { 76 | color: var(--app-accent); 77 | background-color: var(--app-controls-highlight); 78 | } 79 | 80 | .ide .controls .spacer { 81 | flex-grow: 1; 82 | } 83 | 84 | .ide .controls .control > a { 85 | display: block; 86 | height: inherit; 87 | width: inherit; 88 | color: inherit; 89 | text-decoration: none; 90 | } 91 | 92 | .ide .controls .control > a span { 93 | display: block; 94 | position: relative; 95 | top: 50%; 96 | left: 50%; 97 | transform: translate(-50%, -50%); 98 | width: 1em; 99 | height: 1em; 100 | } 101 | 102 | .ide .controls .loading-indicator { 103 | margin: 0.6em; 104 | height: 1em; 105 | width: 1em; 106 | border: 3px solid transparent; 107 | box-sizing: border-box; 108 | } 109 | 110 | .ide .controls .loading-indicator.loading { 111 | border-color: rgba(255, 255, 255, 0.1); 112 | border-top-color: var(--app-accent-alternate); 113 | border-radius: 50%; 114 | animation: spin 1s linear infinite; 115 | } 116 | 117 | @keyframes spin { 118 | 0% { 119 | transform: rotate(0deg); 120 | } 121 | 100% { 122 | transform: rotate(360deg); 123 | } 124 | } 125 | 126 | .dialog { 127 | visibility: hidden; 128 | z-index: 1000; 129 | position: absolute; 130 | top: var(--controls-height); 131 | right: 0; 132 | color: var(--app-controls-color); 133 | background-color: var(--app-controls-background); 134 | text-align: left; 135 | width: 20em; 136 | padding: 0 1em; 137 | border-left: 2px solid; 138 | border-bottom: 2px solid; 139 | border-color: var(--app-controls-background-light); 140 | border-bottom-left-radius: 0.5em; 141 | } 142 | 143 | .dialog.dialog-show { 144 | visibility: visible; 145 | } 146 | -------------------------------------------------------------------------------- /cmd/editor/ide/window.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | hackpad 4 |

5 |
6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 | 14 | 15 |
16 |
17 |
18 | 19 |
20 |
21 | -------------------------------------------------------------------------------- /cmd/editor/main.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package main 5 | 6 | import ( 7 | "flag" 8 | "os" 9 | "syscall/js" 10 | 11 | "github.com/hack-pad/hackpad/cmd/editor/dom" 12 | "github.com/hack-pad/hackpad/cmd/editor/ide" 13 | "github.com/hack-pad/hackpad/cmd/editor/plaineditor" 14 | "github.com/hack-pad/hackpad/cmd/editor/taskconsole" 15 | "github.com/hack-pad/hackpad/cmd/editor/terminal" 16 | "github.com/hack-pad/hackpad/internal/interop" 17 | "github.com/hack-pad/hackpad/internal/log" 18 | ) 19 | 20 | const ( 21 | goBinaryPath = "/usr/local/go/bin/js_wasm/go" 22 | ) 23 | 24 | func main() { 25 | editorID := flag.String("editor", "", "Editor element ID to attach") 26 | flag.Parse() 27 | 28 | if *editorID == "" { 29 | flag.Usage() 30 | os.Exit(2) 31 | } 32 | 33 | app := dom.GetDocument().GetElementByID(*editorID) 34 | app.AddClass("ide") 35 | globalEditorProps := js.Global().Get("editor") 36 | globalEditorProps.Set("profile", js.FuncOf(interop.ProfileJS)) 37 | newEditor := globalEditorProps.Get("newEditor") 38 | var editorBuilder ide.EditorBuilder = editorJSFunc(newEditor) 39 | if !newEditor.Truthy() { 40 | editorBuilder = plaineditor.New() 41 | } 42 | newXTermFunc := globalEditorProps.Get("newTerminal") 43 | if !newXTermFunc.Truthy() { 44 | panic("window.editor.newTerminal must be set") 45 | } 46 | 47 | consoleBuilder := terminal.New(newXTermFunc) 48 | taskConsoleBuilder := taskconsole.New() 49 | win, tasks := ide.New(app, editorBuilder, consoleBuilder, taskConsoleBuilder) 50 | 51 | if _, err := tasks.Start(goBinaryPath, "go", "version"); err != nil { 52 | log.Error("Failed to start go version: ", err) 53 | return 54 | } 55 | 56 | if err := os.MkdirAll("playground", 0700); err != nil { 57 | log.Error("Failed to make playground dir", err) 58 | return 59 | } 60 | if err := os.Chdir("playground"); err != nil { 61 | log.Error("Failed to switch to playground dir", err) 62 | return 63 | } 64 | 65 | _, err := os.Stat("go.mod") 66 | makeNewModule := os.IsNotExist(err) 67 | if makeNewModule { 68 | _, err := tasks.Start(goBinaryPath, "go", "mod", "init", "playground") 69 | if err != nil { 70 | log.Error("Failed to start module init: ", err) 71 | return 72 | } 73 | } 74 | 75 | if _, err := os.Stat("main.go"); os.IsNotExist(err) { 76 | mainGoContents := `package main 77 | 78 | import ( 79 | "fmt" 80 | 81 | "github.com/johnstarich/go/datasize" 82 | ) 83 | 84 | func main() { 85 | fmt.Println("Hello from Wasm!", datasize.Gigabytes(4)) 86 | } 87 | ` 88 | err := os.WriteFile("main.go", []byte(mainGoContents), 0600) 89 | if err != nil { 90 | log.Error("Failed to write to main.go: ", err) 91 | return 92 | } 93 | } 94 | 95 | if makeNewModule { 96 | _, err := tasks.Start(goBinaryPath, "go", "mod", "tidy") 97 | if err != nil { 98 | log.Error("Failed to start go mod tidy: ", err) 99 | return 100 | } 101 | } 102 | 103 | win.NewConsole() 104 | editor := win.NewEditor() 105 | err = editor.OpenFile("main.go") 106 | if err != nil { 107 | log.Error("Failed to open main.go in editor: ", err) 108 | } 109 | 110 | select {} 111 | } 112 | -------------------------------------------------------------------------------- /cmd/editor/plaineditor/editor.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package plaineditor 5 | 6 | import ( 7 | "os" 8 | "syscall/js" 9 | 10 | "github.com/hack-pad/hackpad/cmd/editor/dom" 11 | "github.com/hack-pad/hackpad/cmd/editor/ide" 12 | "github.com/hack-pad/hackpad/internal/log" 13 | ) 14 | 15 | type textAreaBuilder struct { 16 | } 17 | 18 | func New() ide.EditorBuilder { 19 | return &textAreaBuilder{} 20 | } 21 | 22 | type textAreaEditor struct { 23 | elem *dom.Element 24 | textarea *dom.Element 25 | filePath string 26 | titleChan chan string 27 | } 28 | 29 | func (b *textAreaBuilder) New(elem *dom.Element) ide.Editor { 30 | elem.SetInnerHTML(``) 31 | e := &textAreaEditor{ 32 | elem: elem, 33 | textarea: elem.QuerySelector("textarea"), 34 | titleChan: make(chan string, 1), 35 | } 36 | e.textarea.AddEventListener("keydown", codeTyper) 37 | e.textarea.AddEventListener("input", func(event js.Value) { 38 | go e.edited(e.textarea.Value) 39 | }) 40 | return e 41 | } 42 | 43 | func (e *textAreaEditor) OpenFile(path string) error { 44 | e.filePath = path 45 | e.titleChan <- path 46 | return e.ReloadFile() 47 | } 48 | 49 | func (e *textAreaEditor) CurrentFile() string { 50 | return e.filePath 51 | } 52 | 53 | func (e *textAreaEditor) ReloadFile() error { 54 | contents, err := os.ReadFile(e.filePath) 55 | if err != nil { 56 | return err 57 | } 58 | e.textarea.SetValue(string(contents)) 59 | return nil 60 | } 61 | 62 | func (e *textAreaEditor) edited(newContents func() string) { 63 | err := os.WriteFile(e.filePath, []byte(newContents()), 0600) 64 | if err != nil { 65 | log.Errorf("Failed to write %s: %s", e.filePath, err.Error()) 66 | return 67 | } 68 | } 69 | 70 | func (e *textAreaEditor) GetCursor() int { 71 | return e.textarea.GetProperty("selectionStart").Int() 72 | } 73 | 74 | func (e *textAreaEditor) SetCursor(i int) error { 75 | v := js.ValueOf(i) 76 | e.textarea.SetProperty("selectionStart", v) 77 | e.textarea.SetProperty("selectionEnd", v) 78 | return nil 79 | } 80 | 81 | func (e *textAreaEditor) Titles() <-chan string { 82 | return e.titleChan 83 | } 84 | -------------------------------------------------------------------------------- /cmd/editor/plaineditor/typer.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package plaineditor 5 | 6 | import ( 7 | "runtime/debug" 8 | "strings" 9 | "syscall/js" 10 | "unicode" 11 | 12 | "github.com/hack-pad/hackpad/internal/log" 13 | ) 14 | 15 | var ( 16 | document = js.Global().Get("document") 17 | ) 18 | 19 | // codeTyper is fired on keydown event 20 | func codeTyper(event js.Value) { 21 | defer func() { 22 | r := recover() 23 | if r == nil { 24 | return 25 | } 26 | 27 | log.Error("Failed to handle keydown:", r, "\n"+string(debug.Stack())) 28 | }() 29 | 30 | handleKeydown(event) 31 | } 32 | 33 | const ( 34 | KeyTab = "Tab" 35 | KeyEnter = "Enter" 36 | KeyBackspace = "Backspace" 37 | ) 38 | 39 | func handleKeydown(event js.Value) { 40 | target := event.Get("target") 41 | text := target.Get("value").String() 42 | selectionStart := target.Get("selectionStart").Int() 43 | selectionEnd := target.Get("selectionEnd").Int() 44 | key := event.Get("key").String() 45 | code := event.Get("code").String() 46 | // metaKey := event.Get("metaKey").Bool() 47 | 48 | preventDefault := func() { 49 | event.Call("preventDefault") 50 | } 51 | insertText := func(s string) { 52 | document.Call("execCommand", "insertText", false, s) 53 | } 54 | 55 | if code == KeyTab { 56 | preventDefault() 57 | insertText("\t") 58 | return 59 | } 60 | 61 | if code == KeyEnter { 62 | // TODO restore cmd+enter triggering run button 63 | // if metaKey { 64 | // preventDefault() 65 | // runPlayground() 66 | // return 67 | // } 68 | 69 | lastNewLine := strings.LastIndexByte(slice(text, 0, selectionStart), '\n') 70 | if lastNewLine != -1 { 71 | leadingChars := slice(text, lastNewLine+1, selectionStart) 72 | leadingSpace := slice(leadingChars, 0, len(leadingChars)-len(strings.TrimLeftFunc(leadingChars, unicode.IsSpace))) 73 | prevChar := slice(leadingChars, -1, 0) 74 | nextChar := slice(text, selectionStart, selectionStart+1) 75 | 76 | newLinePrefix := "\n" + leadingSpace 77 | newLineSuffix := "" 78 | prevBracket := parseBracket(prevChar) 79 | nextBracket := parseBracket(nextChar) 80 | if prevBracket.opener { 81 | newLinePrefix += "\t" 82 | if nextBracket.closer { 83 | newLineSuffix += "\n" + leadingSpace 84 | } 85 | } 86 | insertText(newLinePrefix + newLineSuffix) 87 | target.Set("selectionStart", selectionStart+len(newLinePrefix)) 88 | target.Set("selectionEnd", selectionStart+len(newLinePrefix)) 89 | preventDefault() 90 | } 91 | return 92 | } 93 | 94 | if code == KeyBackspace { 95 | prevChar := slice(text, selectionStart-1, selectionStart) 96 | nextChar := slice(text, selectionStart, selectionStart+1) 97 | if parseBracket(prevChar).next == nextChar { 98 | document.Call("execCommand", "forwardDelete", false) 99 | } 100 | return 101 | } 102 | 103 | if selectionStart != selectionEnd { 104 | return 105 | } 106 | 107 | closer := parseBracket(key).next 108 | afterSelection := slice(text, selectionStart, selectionStart+1) 109 | if closer != "" && afterSelection != closer { 110 | preventDefault() 111 | insertText(key + closer) 112 | target.Set("selectionStart", selectionStart+1) 113 | target.Set("selectionEnd", selectionStart+1) 114 | return 115 | } 116 | 117 | nextChar := slice(text, selectionStart, selectionStart+1) 118 | if key == nextChar && parseBracket(nextChar).closer { 119 | preventDefault() 120 | target.Set("selectionStart", selectionStart+1) 121 | target.Set("selectionEnd", selectionStart+1) 122 | return 123 | } 124 | } 125 | 126 | type Bracket struct { 127 | opener bool 128 | closer bool 129 | next string 130 | } 131 | 132 | func parseBracket(s string) Bracket { 133 | switch s { 134 | case "{": 135 | return Bracket{opener: true, closer: false, next: "}"} 136 | case "}": 137 | return Bracket{opener: false, closer: true, next: ""} 138 | case "[": 139 | return Bracket{opener: true, closer: false, next: "]"} 140 | case "]": 141 | return Bracket{opener: false, closer: true, next: ""} 142 | case "(": 143 | return Bracket{opener: true, closer: false, next: ")"} 144 | case ")": 145 | return Bracket{opener: false, closer: true, next: ""} 146 | case `"`: 147 | return Bracket{opener: true, closer: true, next: `"`} 148 | case "'": 149 | return Bracket{opener: true, closer: true, next: "'"} 150 | default: 151 | return Bracket{next: ""} 152 | } 153 | } 154 | 155 | func slice(s string, start, end int) string { 156 | if start < 0 { 157 | start += len(s) 158 | if start < 0 { 159 | start = 0 160 | } 161 | } 162 | if start > len(s) { 163 | start = len(s) - 1 164 | } 165 | if end < start || end > len(s) { 166 | end = len(s) 167 | } 168 | return s[start:end] 169 | } 170 | -------------------------------------------------------------------------------- /cmd/editor/taskconsole/command_ctx.go: -------------------------------------------------------------------------------- 1 | package taskconsole 2 | 3 | import "context" 4 | 5 | type CancelFunc func(err error) 6 | 7 | type commandContext struct { 8 | context.Context 9 | lastErr error 10 | } 11 | 12 | func (c *commandContext) Err() error { 13 | return c.lastErr 14 | } 15 | 16 | func newCommandContext() (context.Context, CancelFunc) { 17 | innerCtx, cancel := context.WithCancel(context.Background()) 18 | ctx := &commandContext{ 19 | Context: innerCtx, 20 | } 21 | return ctx, func(err error) { 22 | ctx.lastErr = err 23 | cancel() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/editor/taskconsole/console.go: -------------------------------------------------------------------------------- 1 | //go:build js 2 | // +build js 3 | 4 | package taskconsole 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "io" 10 | "os/exec" 11 | "strings" 12 | "time" 13 | 14 | "github.com/hack-pad/hackpad/cmd/editor/dom" 15 | "github.com/hack-pad/hackpad/cmd/editor/ide" 16 | ) 17 | 18 | type builder struct{} 19 | 20 | func New() ide.TaskConsoleBuilder { 21 | return &builder{} 22 | } 23 | 24 | type console struct { 25 | stdout, stderr, note io.Writer 26 | commands chan *exec.Cmd 27 | cancelFuncs chan CancelFunc 28 | titleChan chan string 29 | } 30 | 31 | func (b *builder) New(elem *dom.Element) ide.TaskConsole { 32 | elem.SetInnerHTML(` 33 |

 34 | `)
 35 | 	elem.AddClass("console")
 36 | 	outputElem := elem.QuerySelector(".console-output")
 37 | 	c := &console{
 38 | 		stdout:      newElementWriter(outputElem, ""),
 39 | 		stderr:      newElementWriter(outputElem, "stderr"),
 40 | 		note:        newElementWriter(outputElem, "note"),
 41 | 		commands:    make(chan *exec.Cmd, 10),
 42 | 		cancelFuncs: make(chan CancelFunc, 10),
 43 | 		titleChan:   make(chan string, 1),
 44 | 	}
 45 | 	go c.runLoop()
 46 | 	return c
 47 | }
 48 | 
 49 | func (c *console) Stdout() io.Writer { return c.stdout }
 50 | func (c *console) Stderr() io.Writer { return c.stderr }
 51 | func (c *console) Note() io.Writer   { return c.note }
 52 | 
 53 | func (c *console) Start(rawName, name string, args ...string) (context.Context, error) {
 54 | 	if rawName == "" {
 55 | 		rawName = name
 56 | 	}
 57 | 	cmd := exec.Command(name, args...)
 58 | 	cmd.Path = rawName
 59 | 	cmd.Stdout = c.stdout
 60 | 	cmd.Stderr = c.stderr
 61 | 	c.commands <- cmd
 62 | 	ctx, cancel := newCommandContext()
 63 | 	c.cancelFuncs <- cancel
 64 | 	return ctx, nil
 65 | }
 66 | 
 67 | func (c *console) runLoop() {
 68 | 	c.titleChan <- "Build"
 69 | 	for {
 70 | 		c.runLoopIter()
 71 | 	}
 72 | }
 73 | 
 74 | func (c *console) runLoopIter() {
 75 | 	cmd := <-c.commands
 76 | 	cancel := <-c.cancelFuncs
 77 | 	startTime := time.Now()
 78 | 	commandErr := c.runCmd(cmd)
 79 | 	defer cancel(commandErr)
 80 | 	elapsed := time.Since(startTime)
 81 | 	if commandErr != nil {
 82 | 		_, _ = c.stderr.Write([]byte(commandErr.Error()))
 83 | 	}
 84 | 
 85 | 	exitCode := 0
 86 | 	if commandErr != nil {
 87 | 		exitCode = 1
 88 | 		exitCoder, ok := commandErr.(interface{ ExitCode() int })
 89 | 		if ok {
 90 | 			exitCode = exitCoder.ExitCode()
 91 | 		}
 92 | 	}
 93 | 
 94 | 	_, _ = io.WriteString(c.Note(), fmt.Sprintf("%s (%.2fs)\n",
 95 | 		exitStatus(exitCode),
 96 | 		elapsed.Seconds(),
 97 | 	))
 98 | }
 99 | 
100 | func (c *console) runCmd(cmd *exec.Cmd) error {
101 | 	_, err := c.stdout.Write([]byte(fmt.Sprintf("$ %s\n", strings.Join(cmd.Args, " "))))
102 | 	if err != nil {
103 | 		return err
104 | 	}
105 | 	return cmd.Run()
106 | }
107 | 
108 | func exitStatus(exitCode int) string {
109 | 	if exitCode == 0 {
110 | 		return "✔"
111 | 	}
112 | 	return "✘"
113 | }
114 | 
115 | func (c *console) Titles() <-chan string {
116 | 	return c.titleChan
117 | }
118 | 


--------------------------------------------------------------------------------
/cmd/editor/taskconsole/writer_elem.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package taskconsole
 5 | 
 6 | import (
 7 | 	"io"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/cmd/editor/dom"
10 | )
11 | 
12 | const (
13 | 	maxJSInt = (1 << 53) - 1
14 | )
15 | 
16 | type elementWriter struct {
17 | 	element *dom.Element
18 | 	class   string
19 | }
20 | 
21 | func newElementWriter(elem *dom.Element, class string) interface {
22 | 	io.Writer
23 | 	io.StringWriter
24 | } {
25 | 	return &elementWriter{
26 | 		element: elem,
27 | 		class:   class,
28 | 	}
29 | }
30 | 
31 | func (w *elementWriter) Write(p []byte) (n int, err error) {
32 | 	return w.WriteString(string(p))
33 | }
34 | 
35 | func (w *elementWriter) WriteString(s string) (n int, err error) {
36 | 	textNode := dom.New("span")
37 | 	w.element.AppendChild(textNode)
38 | 	if w.class != "" {
39 | 		textNode.AddClass(w.class)
40 | 	}
41 | 	textNode.SetInnerText(s)
42 | 	w.element.SetScrollTop(maxJSInt)
43 | 	return len(s), nil
44 | }
45 | 


--------------------------------------------------------------------------------
/cmd/editor/terminal/terminal.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package terminal
  5 | 
  6 | import (
  7 | 	"io"
  8 | 	"os/exec"
  9 | 	"syscall/js"
 10 | 
 11 | 	"github.com/hack-pad/hackpad/cmd/editor/dom"
 12 | 	"github.com/hack-pad/hackpad/cmd/editor/ide"
 13 | 	"github.com/hack-pad/hackpad/internal/common"
 14 | 	"github.com/hack-pad/hackpad/internal/log"
 15 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
 16 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
 17 | )
 18 | 
 19 | type terminalBuilder struct {
 20 | 	newXTermFunc js.Value
 21 | }
 22 | 
 23 | func New(xtermFunc js.Value) ide.ConsoleBuilder {
 24 | 	return &terminalBuilder{
 25 | 		newXTermFunc: xtermFunc,
 26 | 	}
 27 | }
 28 | 
 29 | type terminal struct {
 30 | 	xterm     js.Value
 31 | 	closables []func() error
 32 | 	cmd       *exec.Cmd
 33 | 	titleChan chan string
 34 | 	closed    bool
 35 | }
 36 | 
 37 | func (b *terminalBuilder) New(elem *dom.Element, rawName, name string, args ...string) (ide.Console, error) {
 38 | 	term := &terminal{
 39 | 		xterm:     b.newXTermFunc.Invoke(elem.JSValue()),
 40 | 		titleChan: make(chan string, 1),
 41 | 	}
 42 | 	go func() {
 43 | 		err := term.start(rawName, name, args...)
 44 | 		if err != nil {
 45 | 			log.Error("Failed to start terminal:", err)
 46 | 		}
 47 | 	}()
 48 | 	return term, nil
 49 | }
 50 | 
 51 | func (t *terminal) start(rawName, name string, args ...string) error {
 52 | 	t.titleChan <- "Terminal"
 53 | 
 54 | 	if rawName == "" {
 55 | 		rawName = name
 56 | 	}
 57 | 	t.cmd = exec.Command(name, args...)
 58 | 	t.cmd.Path = rawName
 59 | 	stdin, err := t.cmd.StdinPipe()
 60 | 	if err != nil {
 61 | 		return err
 62 | 	}
 63 | 	stdout, err := t.cmd.StdoutPipe()
 64 | 	if err != nil {
 65 | 		return err
 66 | 	}
 67 | 	stderr, err := t.cmd.StderrPipe()
 68 | 	if err != nil {
 69 | 		return err
 70 | 	}
 71 | 	t.closables = append(t.closables, stdin.Close, stdout.Close, stderr.Close)
 72 | 
 73 | 	err = t.cmd.Start()
 74 | 	if err != nil {
 75 | 		return err
 76 | 	}
 77 | 
 78 | 	f := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 79 | 		chunk := []byte(args[0].String())
 80 | 		_, err := stdin.Write(chunk)
 81 | 		if err == io.EOF {
 82 | 			err = t.Close()
 83 | 		}
 84 | 		if err != nil {
 85 | 			log.Error("Failed to write to terminal:", err)
 86 | 		}
 87 | 		return nil
 88 | 	})
 89 | 	go func() {
 90 | 		_ = t.cmd.Wait()
 91 | 		f.Release()
 92 | 	}()
 93 | 	dataListener := t.xterm.Call("onData", f)
 94 | 	t.closables = append(t.closables, func() (err error) {
 95 | 		defer common.CatchException(&err)
 96 | 		dataListener.Call("dispose")
 97 | 		log.Print("disposed of data listener")
 98 | 		return nil
 99 | 	})
100 | 
101 | 	go t.readOutputPipes(stdout)
102 | 	go t.readOutputPipes(stderr)
103 | 	return nil
104 | }
105 | 
106 | func (t *terminal) Wait() error {
107 | 	return t.cmd.Wait()
108 | }
109 | 
110 | func (t *terminal) Close() error {
111 | 	if t.closed {
112 | 		return nil
113 | 	}
114 | 	t.closed = true
115 | 	const colorRed = "\033[1;31m"
116 | 	t.xterm.Call("write", idbblob.FromBlob(blob.NewBytes([]byte("\n\r"+colorRed+"[exited]\n\r"))).JSValue())
117 | 	var err error
118 | 	for _, closer := range t.closables {
119 | 		cErr := closer()
120 | 		if cErr != nil {
121 | 			err = cErr
122 | 		}
123 | 	}
124 | 	close(t.titleChan)
125 | 	return err
126 | }
127 | 
128 | func (t *terminal) readOutputPipes(r io.Reader) {
129 | 	buf := make([]byte, 1)
130 | 	for {
131 | 		_, err := r.Read(buf)
132 | 		switch err {
133 | 		case nil:
134 | 			t.xterm.Call("write", idbblob.FromBlob(blob.NewBytes(buf)).JSValue())
135 | 		case io.EOF:
136 | 			t.Close()
137 | 			return
138 | 		default:
139 | 			log.Error("Failed to write to terminal:", err)
140 | 		}
141 | 	}
142 | }
143 | 
144 | func (t *terminal) Titles() <-chan string {
145 | 	return t.titleChan
146 | }
147 | 


--------------------------------------------------------------------------------
/cmd/sh/main.go:
--------------------------------------------------------------------------------
 1 | package main
 2 | 
 3 | import (
 4 | 	"io"
 5 | 	"log"
 6 | 	"os"
 7 | 
 8 | 	"github.com/hack-pad/hush"
 9 | )
10 | 
11 | func main() {
12 | 	log.SetOutput(io.Discard)
13 | 	exitCode := hush.Run()
14 | 	os.Exit(exitCode)
15 | }
16 | 


--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
 1 | module github.com/hack-pad/hackpad
 2 | 
 3 | go 1.20
 4 | 
 5 | require (
 6 | 	github.com/avct/uasurfer v0.0.0-20191028135549-26b5daa857f1
 7 | 	github.com/hack-pad/go-indexeddb v0.3.2
 8 | 	github.com/hack-pad/hackpadfs v0.2.1
 9 | 	github.com/hack-pad/hush v0.1.0
10 | 	github.com/johnstarich/go/datasize v0.0.1
11 | 	github.com/machinebox/progress v0.2.0
12 | 	github.com/pkg/errors v0.9.1
13 | 	go.uber.org/atomic v1.6.0
14 | )
15 | 
16 | require (
17 | 	github.com/davecgh/go-spew v1.1.1 // indirect
18 | 	github.com/fatih/color v1.12.0 // indirect
19 | 	github.com/hack-pad/safejs v0.1.1 // indirect
20 | 	github.com/matryer/is v1.4.1 // indirect
21 | 	github.com/mattn/go-colorable v0.1.8 // indirect
22 | 	github.com/mattn/go-isatty v0.0.12 // indirect
23 | 	github.com/mattn/go-tty v0.0.3 // indirect
24 | 	github.com/pmezard/go-difflib v1.0.0 // indirect
25 | 	github.com/stretchr/objx v0.5.0 // indirect
26 | 	github.com/stretchr/testify v1.8.2 // indirect
27 | 	golang.org/x/sys v0.4.0 // indirect
28 | 	gopkg.in/yaml.v3 v3.0.1 // indirect
29 | 	mvdan.cc/sh/v3 v3.3.0 // indirect
30 | )
31 | 


--------------------------------------------------------------------------------
/http_get.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package main
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/common"
10 | 	"github.com/hack-pad/hackpad/internal/promise"
11 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
12 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
13 | 	"github.com/pkg/errors"
14 | )
15 | 
16 | var jsFetch = js.Global().Get("fetch")
17 | var uint8Array = js.Global().Get("Uint8Array")
18 | 
19 | // httpGetFetch sticks to simple calls to the fetch API, then keeps the data inside a JS ArrayBuffer. Memory usage is lower than the "native" http package
20 | func httpGetFetch(path string) (_ blob.Blob, err error) {
21 | 	defer common.CatchException(&err)
22 | 	prom := jsFetch.Invoke(path)
23 | 	resultInt, err := promise.From(prom).Await()
24 | 	if err != nil {
25 | 		return nil, err
26 | 	}
27 | 	result := resultInt.(js.Value)
28 | 
29 | 	jsContentType := result.Get("headers").Call("get", "Content-Type")
30 | 	if jsContentType.Type() != js.TypeString || jsContentType.String() != "application/wasm" {
31 | 		return nil, errors.Errorf("Invalid content type for Wasm: %v", jsContentType)
32 | 	}
33 | 	body, err := promise.From(result.Call("arrayBuffer")).Await()
34 | 	if err != nil {
35 | 		return nil, err
36 | 	}
37 | 	buf := uint8Array.New(body.(js.Value))
38 | 	return idbblob.New(buf)
39 | }
40 | 


--------------------------------------------------------------------------------
/install.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package main
 5 | 
 6 | import (
 7 | 	"errors"
 8 | 	"os"
 9 | 	"path/filepath"
10 | 	"runtime"
11 | 	"syscall/js"
12 | 
13 | 	"github.com/hack-pad/hackpad/internal/interop"
14 | 	"github.com/hack-pad/hackpad/internal/log"
15 | 	"github.com/hack-pad/hackpad/internal/process"
16 | 	"github.com/hack-pad/hackpad/internal/promise"
17 | )
18 | 
19 | func installFunc(this js.Value, args []js.Value) interface{} {
20 | 	resolve, reject, prom := promise.New()
21 | 	go func() {
22 | 		err := install(args)
23 | 		if err != nil {
24 | 			reject(interop.WrapAsJSError(err, "Failed to install binary"))
25 | 			return
26 | 		}
27 | 		resolve(nil)
28 | 	}()
29 | 	return prom.JSValue()
30 | }
31 | 
32 | func install(args []js.Value) error {
33 | 	if len(args) != 1 {
34 | 		return errors.New("Expected command name to install")
35 | 	}
36 | 	command := args[0].String()
37 | 	command = filepath.Base(command) // ensure no path chars are present
38 | 
39 | 	if err := os.MkdirAll("/bin", 0644); err != nil {
40 | 		return err
41 | 	}
42 | 
43 | 	body, err := httpGetFetch("wasm/" + command + ".wasm")
44 | 	if err != nil {
45 | 		return err
46 | 	}
47 | 	defer runtime.GC()
48 | 	fs := process.Current().Files()
49 | 	fd, err := fs.Open("/bin/"+command, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0750)
50 | 	if err != nil {
51 | 		return err
52 | 	}
53 | 	defer fs.Close(fd)
54 | 	if _, err := fs.Write(fd, body, 0, body.Len(), nil); err != nil {
55 | 		return err
56 | 	}
57 | 	log.Print("Install completed: ", command)
58 | 	return nil
59 | }
60 | 


--------------------------------------------------------------------------------
/internal/cmd/gozip/main.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"archive/tar"
  5 | 	"compress/gzip"
  6 | 	"fmt"
  7 | 	"io"
  8 | 	"os"
  9 | 	"path/filepath"
 10 | 	"sort"
 11 | 	"strings"
 12 | 
 13 | 	"github.com/johnstarich/go/datasize"
 14 | )
 15 | 
 16 | func main() {
 17 | 	if len(os.Args) != 2 {
 18 | 		fmt.Fprintln(os.Stderr, "Path to Go source is required")
 19 | 		os.Exit(1)
 20 | 		return
 21 | 	}
 22 | 	err := archiveGo(os.Args[1], os.Stdout)
 23 | 	if err != nil {
 24 | 		fmt.Fprintln(os.Stderr, err)
 25 | 		os.Exit(1)
 26 | 	}
 27 | }
 28 | 
 29 | func archiveGo(goRoot string, w io.Writer) error {
 30 | 	compressor, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
 31 | 	if err != nil {
 32 | 		return err
 33 | 	}
 34 | 	archive := tar.NewWriter(compressor)
 35 | 
 36 | 	goRoot, err = filepath.Abs(goRoot)
 37 | 	if err != nil {
 38 | 		return err
 39 | 	}
 40 | 
 41 | 	doFile := func(path string, info os.FileInfo) error {
 42 | 		header, err := tar.FileInfoHeader(info, "")
 43 | 		if err != nil {
 44 | 			return err
 45 | 		}
 46 | 		header.Name = strings.TrimPrefix(path, goRoot)
 47 | 		err = archive.WriteHeader(header)
 48 | 		if err != nil {
 49 | 			return err
 50 | 		}
 51 | 		if info.IsDir() {
 52 | 			return nil
 53 | 		}
 54 | 		f, err := os.Open(path)
 55 | 		if err != nil {
 56 | 			return err
 57 | 		}
 58 | 		defer f.Close()
 59 | 		_, err = io.Copy(archive, f)
 60 | 		return err
 61 | 	}
 62 | 
 63 | 	goBinary := filepath.Join(goRoot, "bin", "js_wasm", "go")
 64 | 	goBinaryInfo, err := os.Stat(goBinary)
 65 | 	if err != nil {
 66 | 		return err
 67 | 	}
 68 | 	err = doFile(goBinary, goBinaryInfo)
 69 | 	if err != nil {
 70 | 		return err
 71 | 	}
 72 | 
 73 | 	stats, err := walkGo(goRoot, doFile)
 74 | 	fmt.Fprintf(os.Stderr, "Stats: %s\n", stats)
 75 | 	if err != nil {
 76 | 		return err
 77 | 	}
 78 | 
 79 | 	err = archive.Close()
 80 | 	if err != nil {
 81 | 		return err
 82 | 	}
 83 | 	return compressor.Close()
 84 | }
 85 | 
 86 | type Int64Slice []int64
 87 | 
 88 | func (p Int64Slice) Len() int           { return len(p) }
 89 | func (p Int64Slice) Less(i, j int) bool { return p[i] < p[j] }
 90 | func (p Int64Slice) Swap(i, j int)      { p[i], p[j] = p[j], p[i] }
 91 | 
 92 | type Stats struct {
 93 | 	Visited      int
 94 | 	SkippedDirs  int
 95 | 	IgnoredFiles int
 96 | 
 97 | 	sizes []int64
 98 | }
 99 | 
100 | func (s Stats) SizeMetrics() (mean, median, max float64) {
101 | 	sort.Sort(Int64Slice(s.sizes))
102 | 	var total float64
103 | 	for _, num := range s.sizes {
104 | 		total += float64(num)
105 | 		if n := float64(num); n > max {
106 | 			max = n
107 | 		}
108 | 	}
109 | 	return total / float64(len(s.sizes)), float64(s.sizes[len(s.sizes)/2]), max
110 | }
111 | 
112 | func (s Stats) String() string {
113 | 	mean, median, max := s.SizeMetrics()
114 | 	size := func(i int64) string {
115 | 		return datasize.Bytes(int64(i)).String()
116 | 	}
117 | 	return fmt.Sprintf("mean=%v, median=%v, max=%v, 90th%%=%v, 99th%%=%v, visited=%v, skipped dirs=%v ignored files=%v", size(int64(mean)), size(int64(median)), size(int64(max)), size(s.sizes[len(s.sizes)*90/100]), size(s.sizes[len(s.sizes)*99/100]), s.Visited, s.SkippedDirs, s.IgnoredFiles)
118 | }
119 | 
120 | // walkGo walks through a Go sources directory root and runs 'do' on files to archive.
121 | func walkGo(goRoot string, do func(string, os.FileInfo) error) (Stats, error) {
122 | 	var stats Stats
123 | 	walkPath := goRoot + string(filepath.Separator) // ensures symlink dir is followed
124 | 	return stats, filepath.Walk(walkPath, func(path string, info os.FileInfo, err error) error {
125 | 		if err != nil {
126 | 			return err
127 | 		}
128 | 
129 | 		stats.sizes = append(stats.sizes, info.Size())
130 | 
131 | 		switch {
132 | 		case path == ".":
133 | 			return nil // "skip" top-level dir, don't record in stats
134 | 		case matchPath(path, goRoot, ".git"),
135 | 			matchPath(path, goRoot, "api"),
136 | 			matchPath(path, goRoot, "doc"),
137 | 			matchPath(path, goRoot, "src", "cmd"),
138 | 			matchPath(path, goRoot, "src", "runtime", "cgo"),
139 | 			matchPath(path, goRoot, "src", "runtime", "race"),
140 | 			strings.HasSuffix(path, string(filepath.Separator)+"testdata"),
141 | 			matchPath(path, goRoot, "test"):
142 | 			stats.SkippedDirs++
143 | 			return filepath.SkipDir // explicitly skip all of these contents
144 | 		case matchPath(path, goRoot, "pkg", "tool", "js_wasm", "cgo"),
145 | 			matchPath(path, goRoot, "bin", "js_wasm", "go"), // handled specially above
146 | 			strings.HasSuffix(path, ".a"),
147 | 			strings.HasSuffix(path, "_test.go"):
148 | 			return nil // skip specific files
149 | 		case matchPathPrefix(path, goRoot, "bin", "js_wasm"),
150 | 			matchPathPrefix(path, goRoot, "pkg", "js_wasm"),
151 | 			matchPathPrefix(path, goRoot, "pkg", "include"),
152 | 			matchPathPrefix(path, goRoot, "pkg", "tool", "js_wasm"):
153 | 			stats.Visited++
154 | 			return do(path, info)
155 | 		case matchPathPrefix(path, goRoot, "bin"),
156 | 			matchPathPrefix(path, goRoot, "pkg"):
157 | 			stats.IgnoredFiles++
158 | 			return nil // skip things not explicitly matched here
159 | 		default:
160 | 			stats.Visited++
161 | 			return do(path, info)
162 | 		}
163 | 	})
164 | }
165 | 
166 | func matchPath(match string, paths ...string) bool {
167 | 	return match == filepath.Join(paths...)
168 | }
169 | 
170 | // matchPathPrefix returns true if joining paths forms a prefix for or is equal to 'match'
171 | func matchPathPrefix(match string, paths ...string) bool {
172 | 	path := filepath.Join(paths...)
173 | 	return match == path || strings.HasPrefix(match, path+string(filepath.Separator))
174 | }
175 | 


--------------------------------------------------------------------------------
/internal/common/fid.go:
--------------------------------------------------------------------------------
 1 | package common
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | )
 6 | 
 7 | type FID uint64
 8 | 
 9 | func (f *FID) String() string {
10 | 	if f == nil {
11 | 		return ""
12 | 	}
13 | 	return fmt.Sprintf("%d", *f)
14 | }
15 | 


--------------------------------------------------------------------------------
/internal/common/js.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package common
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/pkg/errors"
10 | )
11 | 
12 | func (f FID) JSValue() js.Value {
13 | 	return js.ValueOf(uint64(f))
14 | }
15 | 
16 | func (p PID) JSValue() js.Value {
17 | 	return js.ValueOf(uint64(p))
18 | }
19 | 
20 | func CatchException(err *error) {
21 | 	recoverErr := handleRecovery(recover())
22 | 	if recoverErr != nil {
23 | 		*err = recoverErr
24 | 	}
25 | }
26 | 
27 | func CatchExceptionHandler(fn func(err error)) {
28 | 	err := handleRecovery(recover())
29 | 	if err != nil {
30 | 		fn(err)
31 | 	}
32 | }
33 | 
34 | func handleRecovery(r interface{}) error {
35 | 	if r == nil {
36 | 		return nil
37 | 	}
38 | 	switch val := r.(type) {
39 | 	case error:
40 | 		return val
41 | 	case js.Value:
42 | 		return js.Error{Value: val}
43 | 	default:
44 | 		return errors.Errorf("%+v", val)
45 | 	}
46 | }
47 | 


--------------------------------------------------------------------------------
/internal/common/path.go:
--------------------------------------------------------------------------------
 1 | package common
 2 | 
 3 | import (
 4 | 	"path"
 5 | 	"strings"
 6 | )
 7 | 
 8 | func ResolvePath(wd, p string) string {
 9 | 	if path.IsAbs(p) {
10 | 		p = path.Clean(p)
11 | 	} else {
12 | 		p = path.Join(wd, p)
13 | 	}
14 | 	p = strings.TrimPrefix(p, "/")
15 | 	if p == "" {
16 | 		return "."
17 | 	}
18 | 	return p
19 | }
20 | 


--------------------------------------------------------------------------------
/internal/common/pid.go:
--------------------------------------------------------------------------------
 1 | package common
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | )
 6 | 
 7 | type PID uint64
 8 | 
 9 | func (p PID) String() string {
10 | 	return fmt.Sprintf("%d", p)
11 | }
12 | 


--------------------------------------------------------------------------------
/internal/console/console.go:
--------------------------------------------------------------------------------
 1 | package console
 2 | 
 3 | import (
 4 | 	"io"
 5 | )
 6 | 
 7 | type Console interface {
 8 | 	Stdout() io.Writer
 9 | 	Stderr() io.Writer
10 | 	Note() io.Writer
11 | }
12 | 


--------------------------------------------------------------------------------
/internal/console/js_console.go:
--------------------------------------------------------------------------------
 1 | //go:build js && wasm
 2 | // +build js,wasm
 3 | 
 4 | package console
 5 | 
 6 | import (
 7 | 	"io"
 8 | 	"syscall/js"
 9 | )
10 | 
11 | type console struct {
12 | 	stdout, stderr, note io.Writer
13 | }
14 | 
15 | func New(element js.Value) Console {
16 | 	element.Set("innerHTML", `
17 | 

18 | `)
19 | 	element.Get("classList").Call("add", "console")
20 | 	outputElem := element.Call("querySelector", ".console-output")
21 | 	return &console{
22 | 		stdout: newElementWriter(outputElem, ""),
23 | 		stderr: newElementWriter(outputElem, "stderr"),
24 | 		note:   newElementWriter(outputElem, "note"),
25 | 	}
26 | }
27 | 
28 | func (c *console) Stdout() io.Writer { return c.stdout }
29 | func (c *console) Stderr() io.Writer { return c.stderr }
30 | func (c *console) Note() io.Writer   { return c.note }
31 | 


--------------------------------------------------------------------------------
/internal/console/writer_elem.go:
--------------------------------------------------------------------------------
 1 | //go:build js && wasm
 2 | // +build js,wasm
 3 | 
 4 | package console
 5 | 
 6 | import (
 7 | 	"io"
 8 | 	"syscall/js"
 9 | )
10 | 
11 | var (
12 | 	document = js.Global().Get("document")
13 | )
14 | 
15 | const (
16 | 	maxJSInt = (1 << 53) - 1
17 | )
18 | 
19 | type elementWriter struct {
20 | 	element js.Value
21 | 	class   string
22 | }
23 | 
24 | func newElementWriter(elem js.Value, class string) interface {
25 | 	io.Writer
26 | 	io.StringWriter
27 | } {
28 | 	return &elementWriter{
29 | 		element: elem,
30 | 		class:   class,
31 | 	}
32 | }
33 | 
34 | func (w *elementWriter) Write(p []byte) (n int, err error) {
35 | 	return w.WriteString(string(p))
36 | }
37 | 
38 | func (w *elementWriter) WriteString(s string) (n int, err error) {
39 | 	textNode := document.Call("createElement", "span")
40 | 	w.element.Call("appendChild", textNode)
41 | 	if w.class != "" {
42 | 		textNode.Get("classList").Call("add", w.class)
43 | 	}
44 | 	textNode.Set("innerText", s)
45 | 	w.element.Set("scrollTop", maxJSInt)
46 | 	return len(s), nil
47 | }
48 | 


--------------------------------------------------------------------------------
/internal/fs/attr.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | // Attr defines file descriptor inheritance rules for a new set of descriptors
 4 | // Ignore is unsupported.
 5 | // Pipe will create a new pipe and attach it to the child process.
 6 | // FID will inherit that descriptor in the child process.
 7 | type Attr struct {
 8 | 	Ignore bool
 9 | 	Pipe   bool
10 | 	FID    FID
11 | }
12 | 


--------------------------------------------------------------------------------
/internal/fs/clearfs.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"context"
 5 | 
 6 | 	"github.com/hack-pad/hackpadfs"
 7 | )
 8 | 
 9 | type clearFS interface {
10 | 	hackpadfs.FS
11 | 	Clear(ctx context.Context) error
12 | }
13 | 
14 | type clearUnderlyingFS struct {
15 | 	hackpadfs.FS
16 | 	underlyingFS clearFS
17 | }
18 | 
19 | func newClearUnderlyingFS(fs hackpadfs.FS, underlyingFS clearFS) clearFS {
20 | 	return &clearUnderlyingFS{
21 | 		FS:           fs,
22 | 		underlyingFS: underlyingFS,
23 | 	}
24 | }
25 | 
26 | func (c *clearUnderlyingFS) Clear(ctx context.Context) error {
27 | 	return c.underlyingFS.Clear(ctx)
28 | }
29 | 


--------------------------------------------------------------------------------
/internal/fs/download.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"archive/zip"
 8 | 	"bytes"
 9 | 	"io"
10 | 	"path/filepath"
11 | 	"strings"
12 | 
13 | 	"github.com/hack-pad/hackpad/internal/interop"
14 | 	"github.com/hack-pad/hackpadfs"
15 | )
16 | 
17 | // DumpZip starts a zip download of everything in the given directory
18 | func DumpZip(path string) error {
19 | 	var buf bytes.Buffer
20 | 	z := zip.NewWriter(&buf)
21 | 	err := hackpadfs.WalkDir(filesystem, path, func(path string, dirEntry hackpadfs.DirEntry, err error) error {
22 | 		if err != nil {
23 | 			return err
24 | 		}
25 | 		info, err := dirEntry.Info()
26 | 		if err != nil {
27 | 			return err
28 | 		}
29 | 		zipInfo, err := zip.FileInfoHeader(info)
30 | 		if err != nil {
31 | 			return err
32 | 		}
33 | 		zipInfo.Name = path
34 | 		if info.IsDir() {
35 | 			zipInfo.Name += "/"
36 | 		}
37 | 		w, err := z.CreateHeader(zipInfo)
38 | 		if err != nil {
39 | 			return err
40 | 		}
41 | 		r, err := filesystem.Open(path)
42 | 		if err != nil {
43 | 			return err
44 | 		}
45 | 		_, err = io.Copy(w, r)
46 | 		r.Close()
47 | 		return err
48 | 	})
49 | 	if err != nil {
50 | 		return err
51 | 	}
52 | 	if err := z.Close(); err != nil {
53 | 		return err
54 | 	}
55 | 	interop.StartDownload("application/zip", strings.ReplaceAll(path, string(filepath.Separator), "-")+".zip", buf.Bytes())
56 | 	return nil
57 | }
58 | 


--------------------------------------------------------------------------------
/internal/fs/file_descriptor.go:
--------------------------------------------------------------------------------
  1 | package fs
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"os"
  6 | 	"path"
  7 | 	"strings"
  8 | 	"sync"
  9 | 
 10 | 	"github.com/hack-pad/hackpad/internal/common"
 11 | 	"github.com/hack-pad/hackpad/internal/log"
 12 | 	"github.com/hack-pad/hackpadfs"
 13 | 	"go.uber.org/atomic"
 14 | )
 15 | 
 16 | type FID = common.FID
 17 | 
 18 | type fileDescriptor struct {
 19 | 	id FID
 20 | 	*fileCore
 21 | }
 22 | 
 23 | type fileCore struct {
 24 | 	file hackpadfs.File
 25 | 	mode os.FileMode
 26 | 
 27 | 	openMu     sync.Mutex
 28 | 	openCounts map[common.PID]*atomic.Uint64
 29 | 	openedName string // used for debugging
 30 | }
 31 | 
 32 | func NewFileDescriptor(fid FID, absPath string, flags int, mode os.FileMode) (*fileDescriptor, error) {
 33 | 	file, err := getFile(absPath, flags, mode)
 34 | 	descriptor := newIrregularFileDescriptor(fid, path.Base(absPath), file, mode)
 35 | 	return descriptor, err
 36 | }
 37 | 
 38 | func newIrregularFileDescriptor(fid FID, name string, file hackpadfs.File, mode hackpadfs.FileMode) *fileDescriptor {
 39 | 	return &fileDescriptor{
 40 | 		id: fid,
 41 | 		fileCore: &fileCore{
 42 | 			file:       file,
 43 | 			mode:       mode,
 44 | 			openCounts: make(map[common.PID]*atomic.Uint64),
 45 | 			openedName: name,
 46 | 		},
 47 | 	}
 48 | }
 49 | 
 50 | func (fd *fileDescriptor) Dup(fid FID) *fileDescriptor {
 51 | 	fdCopy := *fd
 52 | 	fdCopy.id = fid
 53 | 	return &fdCopy
 54 | }
 55 | 
 56 | func (fd *fileDescriptor) FileName() string {
 57 | 	return fd.openedName
 58 | }
 59 | 
 60 | func (fd *fileDescriptor) String() string {
 61 | 	return fmt.Sprintf("%15s [%d] open=%v", fd.openedName, fd.id, openCountToString(fd.openCounts))
 62 | }
 63 | 
 64 | func (fd *fileDescriptor) Open(pid common.PID) {
 65 | 	count, ok := fd.openCounts[pid]
 66 | 	if ok {
 67 | 		count.Inc()
 68 | 		return
 69 | 	}
 70 | 	fd.fileCore.openMu.Lock()
 71 | 	if count, ok := fd.openCounts[pid]; ok {
 72 | 		count.Inc()
 73 | 	} else {
 74 | 		fd.openCounts[pid] = atomic.NewUint64(1)
 75 | 	}
 76 | 	fd.fileCore.openMu.Unlock()
 77 | }
 78 | 
 79 | // Close decrements this process's open count. If the open count is 0, then it locks and runs cleanup.
 80 | // If the open count is zero for all processes, then the internal file is closed.
 81 | func (fd *fileDescriptor) Close(pid common.PID, locker sync.Locker, cleanUpFile func()) error {
 82 | 	count := fd.openCounts[pid]
 83 | 	if count == nil || count.Load() <= 0 {
 84 | 		return nil
 85 | 	}
 86 | 
 87 | 	if count.Dec() > 0 {
 88 | 		return nil
 89 | 	}
 90 | 	// if this process's open count is 0, then use 'locker' and 'cleanUpFile' to remove it from the parent
 91 | 	locker.Lock()
 92 | 	fd.openMu.Lock()
 93 | 	cleanedUp, err := fd.unsafeClose(pid)
 94 | 	if cleanedUp {
 95 | 		cleanUpFile()
 96 | 	}
 97 | 	fd.openMu.Unlock()
 98 | 	locker.Unlock()
 99 | 	return err
100 | }
101 | 
102 | func (fd *fileDescriptor) unsafeClose(pid common.PID) (cleanUpFile bool, err error) {
103 | 	count, ok := fd.openCounts[pid]
104 | 	if !ok {
105 | 		return
106 | 	}
107 | 	if count.Load() == 0 {
108 | 		delete(fd.openCounts, pid)
109 | 		cleanUpFile = true
110 | 	}
111 | 
112 | 	if len(fd.openCounts) == 0 {
113 | 		// if this fd is closed everywhere, then close the file
114 | 		err = fd.file.Close()
115 | 	}
116 | 	return
117 | }
118 | 
119 | func openCountToString(openCounts map[common.PID]*atomic.Uint64) string {
120 | 	var s strings.Builder
121 | 	s.WriteString("{")
122 | 	for pid, count := range openCounts {
123 | 		s.WriteString(fmt.Sprintf(" %d:%d", pid, count.Load()))
124 | 	}
125 | 	s.WriteString(" }")
126 | 	return s.String()
127 | }
128 | 
129 | func (fd *fileDescriptor) closeAll(pid common.PID) error {
130 | 	fd.openMu.Lock()
131 | 	defer fd.openMu.Unlock()
132 | 
133 | 	count := fd.openCounts[pid]
134 | 	if count == nil {
135 | 		return nil
136 | 	}
137 | 	var firstErr error
138 | 	for count.Load() > 0 {
139 | 		count.Dec()
140 | 		_, err := fd.unsafeClose(pid)
141 | 		if firstErr == nil && err != nil {
142 | 			log.Errorf("Failed to close file for PID %d %q: %s", pid, fd.FileName(), err.Error())
143 | 			firstErr = err
144 | 		}
145 | 	}
146 | 	return firstErr
147 | }
148 | 


--------------------------------------------------------------------------------
/internal/fs/file_descriptors_js.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | )
 9 | 
10 | type wasmInstancer interface {
11 | 	WasmInstance(path string, importObject js.Value) (js.Value, error)
12 | }
13 | 
14 | func (f *FileDescriptors) WasmInstance(path string, importObject js.Value) (js.Value, error) {
15 | 	if instancer, ok := filesystem.(wasmInstancer); ok {
16 | 		return instancer.WasmInstance(f.resolvePath(path), importObject)
17 | 	}
18 | 	panic("Wasm Cache not initialized")
19 | }
20 | 


--------------------------------------------------------------------------------
/internal/fs/fs.go:
--------------------------------------------------------------------------------
  1 | package fs
  2 | 
  3 | import (
  4 | 	"compress/gzip"
  5 | 	"context"
  6 | 	"io"
  7 | 	"path"
  8 | 
  9 | 	"github.com/hack-pad/hackpad/internal/common"
 10 | 	"github.com/hack-pad/hackpad/internal/log"
 11 | 	"github.com/hack-pad/hackpadfs"
 12 | 	"github.com/hack-pad/hackpadfs/cache"
 13 | 	"github.com/hack-pad/hackpadfs/mem"
 14 | 	"github.com/hack-pad/hackpadfs/mount"
 15 | 	"github.com/hack-pad/hackpadfs/tar"
 16 | 	"github.com/johnstarich/go/datasize"
 17 | )
 18 | 
 19 | var (
 20 | 	filesystem = func() rootFs {
 21 | 		memFS, err := mem.NewFS()
 22 | 		if err != nil {
 23 | 			panic(err)
 24 | 		}
 25 | 		fs, err := mount.NewFS(memFS)
 26 | 		if err != nil {
 27 | 			panic(err)
 28 | 		}
 29 | 		return fs
 30 | 	}()
 31 | )
 32 | 
 33 | type rootFs interface {
 34 | 	hackpadfs.MountFS
 35 | 	AddMount(path string, mount hackpadfs.FS) error
 36 | 	MountPoints() []mount.Point
 37 | }
 38 | 
 39 | func Mounts() []mount.Point {
 40 | 	return filesystem.MountPoints()
 41 | }
 42 | 
 43 | func DestroyMount(path string) error {
 44 | 	mount, _ := filesystem.Mount(path)
 45 | 	if clearFs, ok := mount.(clearFS); ok {
 46 | 		return clearFs.Clear(context.Background())
 47 | 	}
 48 | 	return &hackpadfs.PathError{Op: "clear", Path: path, Err: hackpadfs.ErrNotImplemented}
 49 | }
 50 | 
 51 | func Overlay(mountPath string, fs hackpadfs.FS) error {
 52 | 	mountPath = common.ResolvePath(".", mountPath)
 53 | 	return filesystem.AddMount(mountPath, fs)
 54 | }
 55 | 
 56 | type ShouldCacher func(name string, info hackpadfs.FileInfo) bool
 57 | 
 58 | func OverlayTarGzip(mountPath string, gzipReader io.ReadCloser, persist bool, shouldCache ShouldCacher) error {
 59 | 	r, err := gzip.NewReader(gzipReader)
 60 | 	if err != nil {
 61 | 		return err
 62 | 	}
 63 | 
 64 | 	mountPath = common.ResolvePath(".", mountPath)
 65 | 	if !persist {
 66 | 		underlyingFS, err := mem.NewFS()
 67 | 		if err != nil {
 68 | 			return err
 69 | 		}
 70 | 		fs, err := tar.NewReaderFS(context.Background(), r, tar.ReaderFSOptions{
 71 | 			UnarchiveFS: underlyingFS,
 72 | 		})
 73 | 		if err != nil {
 74 | 			return err
 75 | 		}
 76 | 		return filesystem.AddMount(mountPath, fs)
 77 | 	}
 78 | 
 79 | 	const tarfsDoneMarker = ".tarfs-complete"
 80 | 
 81 | 	underlyingFS, err := newPersistDB(mountPath, true, shouldCache)
 82 | 	if err != nil {
 83 | 		return err
 84 | 	}
 85 | 
 86 | 	cacheOptions := cache.ReadOnlyOptions{
 87 | 		RetainData: func(name string, info hackpadfs.FileInfo) bool {
 88 | 			return shouldCache(path.Join(mountPath, name), info)
 89 | 		},
 90 | 	}
 91 | 	newCacheFS := func(underlyingFS clearFS) (clearFS, error) {
 92 | 		memFS, err := mem.NewFS()
 93 | 		if err != nil {
 94 | 			return nil, err
 95 | 		}
 96 | 		fs, err := cache.NewReadOnlyFS(underlyingFS, memFS, cacheOptions)
 97 | 		if err != nil {
 98 | 			return nil, err
 99 | 		}
100 | 		return newClearUnderlyingFS(fs, underlyingFS), nil
101 | 	}
102 | 
103 | 	_, err = hackpadfs.Stat(underlyingFS, tarfsDoneMarker)
104 | 	if err == nil {
105 | 		// tarfs already completed successfully and is persisted,
106 | 		// so close top-level reader and mount the existing files
107 | 		gzipReader.Close()
108 | 
109 | 		cacheFS, err := newCacheFS(underlyingFS)
110 | 		if err != nil {
111 | 			return err
112 | 		}
113 | 		return filesystem.AddMount(mountPath, cacheFS)
114 | 	} else {
115 | 		// either never untar'd or did not finish untaring, so start again
116 | 		// should be idempotent, but rewriting buffers from JS is expensive, so just delete everything
117 | 		err := underlyingFS.Clear(context.Background())
118 | 		if err != nil {
119 | 			return err
120 | 		}
121 | 	}
122 | 
123 | 	readCtx, readCancel := context.WithCancel(context.Background())
124 | 	tarFS, err := tar.NewReaderFS(readCtx, r, tar.ReaderFSOptions{
125 | 		UnarchiveFS: underlyingFS,
126 | 	})
127 | 	if err != nil {
128 | 		readCancel()
129 | 		return err
130 | 	}
131 | 	tarClearFS := newClearCtxFS(underlyingFS, readCancel, tarFS.Done())
132 | 	cacheFS, err := newCacheFS(tarClearFS)
133 | 	if err != nil {
134 | 		return err
135 | 	}
136 | 	go func() {
137 | 		<-tarFS.Done()
138 | 		err := tarFS.UnarchiveErr()
139 | 		if err != nil {
140 | 			log.Errorf("Failed to initialize mount %q: %v", mountPath, err)
141 | 			return
142 | 		}
143 | 		f, err := hackpadfs.Create(underlyingFS, tarfsDoneMarker)
144 | 		if err != nil {
145 | 			log.Errorf("Failed to mark tarfs overlay %q complete: %v", mountPath, err)
146 | 			return
147 | 		}
148 | 		f.Close()
149 | 	}()
150 | 	return filesystem.AddMount(mountPath, cacheFS)
151 | }
152 | 
153 | type clearCtxFS struct {
154 | 	cancel context.CancelFunc
155 | 	wait   <-chan struct{}
156 | 	fs     clearFS
157 | }
158 | 
159 | func newClearCtxFS(fs clearFS, cancel context.CancelFunc, wait <-chan struct{}) *clearCtxFS {
160 | 	return &clearCtxFS{
161 | 		cancel: cancel,
162 | 		wait:   wait,
163 | 		fs:     fs,
164 | 	}
165 | }
166 | 
167 | func (c *clearCtxFS) Open(name string) (hackpadfs.File, error) {
168 | 	return c.fs.Open(name)
169 | }
170 | 
171 | func (c *clearCtxFS) Clear(ctx context.Context) error {
172 | 	c.cancel()
173 | 	select {
174 | 	case <-c.wait:
175 | 		return c.fs.Clear(ctx)
176 | 	case <-ctx.Done():
177 | 		return ctx.Err()
178 | 	}
179 | }
180 | 
181 | // Dump prints out file system statistics
182 | func Dump(basePath string) interface{} {
183 | 	var total int64
184 | 	err := hackpadfs.WalkDir(filesystem, basePath, func(path string, dirEntry hackpadfs.DirEntry, err error) error {
185 | 		if err != nil {
186 | 			return err
187 | 		}
188 | 		info, err := dirEntry.Info()
189 | 		if err != nil {
190 | 			return err
191 | 		}
192 | 		total += info.Size()
193 | 		return nil
194 | 	})
195 | 	if err != nil {
196 | 		return err
197 | 	}
198 | 	return datasize.Bytes(total).String()
199 | }
200 | 


--------------------------------------------------------------------------------
/internal/fs/fs_js.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"context"
 8 | 
 9 | 	"github.com/hack-pad/go-indexeddb/idb"
10 | 	"github.com/hack-pad/hackpadfs/indexeddb"
11 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
12 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
13 | )
14 | 
15 | type persistFs struct {
16 | 	*indexeddb.FS
17 | }
18 | 
19 | func newPersistDB(name string, relaxedDurability bool, shouldCache ShouldCacher) (*persistFs, error) {
20 | 	durability := idb.DurabilityDefault
21 | 	if relaxedDurability {
22 | 		durability = idb.DurabilityRelaxed
23 | 	}
24 | 	fs, err := indexeddb.NewFS(context.Background(), name, indexeddb.Options{
25 | 		TransactionDurability: durability,
26 | 	})
27 | 	return &persistFs{fs}, err
28 | }
29 | 
30 | func newBlobLength(i int) (blob.Blob, error) {
31 | 	return idbblob.NewLength(i)
32 | }
33 | 


--------------------------------------------------------------------------------
/internal/fs/fs_other.go:
--------------------------------------------------------------------------------
 1 | //go:build !js
 2 | // +build !js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"context"
 8 | 
 9 | 	"github.com/hack-pad/hackpadfs"
10 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
11 | )
12 | 
13 | type persistFsInterface interface {
14 | 	hackpadfs.FS
15 | 	hackpadfs.ChmodFS
16 | 	hackpadfs.MkdirFS
17 | 	hackpadfs.OpenFileFS
18 | }
19 | 
20 | type persistFs struct {
21 | 	persistFsInterface
22 | }
23 | 
24 | func newPersistDB(name string, relaxedDurability bool, shouldCache ShouldCacher) (*persistFs, error) {
25 | 	panic("not implemented")
26 | }
27 | 
28 | func (p *persistFs) Clear(context.Context) error {
29 | 	panic("not implemented")
30 | }
31 | 
32 | func newBlobLength(i int) (blob.Blob, error) {
33 | 	return blob.NewBytesLength(i), nil
34 | }
35 | 


--------------------------------------------------------------------------------
/internal/fs/null_file.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"io"
 5 | 	"os"
 6 | 	"time"
 7 | 
 8 | 	"github.com/hack-pad/hackpadfs"
 9 | )
10 | 
11 | type nullFile struct {
12 | 	name string
13 | }
14 | 
15 | func newNullFile(name string) hackpadfs.File {
16 | 	return nullFile{name: name}
17 | }
18 | 
19 | func (f nullFile) Close() error                                   { return nil }
20 | func (f nullFile) Read(p []byte) (n int, err error)               { return 0, io.EOF }
21 | func (f nullFile) ReadAt(p []byte, off int64) (n int, err error)  { return 0, io.EOF }
22 | func (f nullFile) Seek(offset int64, whence int) (int64, error)   { return 0, nil }
23 | func (f nullFile) Write(p []byte) (n int, err error)              { return len(p), nil }
24 | func (f nullFile) WriteAt(p []byte, off int64) (n int, err error) { return len(p), nil }
25 | func (f nullFile) Stat() (os.FileInfo, error)                     { return nullStat{f}, nil }
26 | func (f nullFile) Truncate(size int64) error                      { return nil }
27 | 
28 | type nullStat struct {
29 | 	f nullFile
30 | }
31 | 
32 | func (s nullStat) Name() string       { return s.f.name }
33 | func (s nullStat) Size() int64        { return 0 }
34 | func (s nullStat) Mode() os.FileMode  { return 0 }
35 | func (s nullStat) ModTime() time.Time { return time.Time{} }
36 | func (s nullStat) IsDir() bool        { return false }
37 | func (s nullStat) Sys() interface{}   { return nil }
38 | 


--------------------------------------------------------------------------------
/internal/fs/pipe.go:
--------------------------------------------------------------------------------
  1 | package fs
  2 | 
  3 | import (
  4 | 	"io"
  5 | 	"os"
  6 | 	"time"
  7 | 
  8 | 	"github.com/hack-pad/hackpad/internal/interop"
  9 | )
 10 | 
 11 | func (f *FileDescriptors) Pipe() [2]FID {
 12 | 	r, w := newPipe(f.newFID)
 13 | 	f.addFileDescriptor(r)
 14 | 	f.addFileDescriptor(w)
 15 | 	r.Open(f.parentPID)
 16 | 	w.Open(f.parentPID)
 17 | 	return [2]FID{r.id, w.id}
 18 | }
 19 | 
 20 | func newPipe(newFID func() FID) (r, w *fileDescriptor) {
 21 | 	readerFID, writerFID := newFID(), newFID()
 22 | 	pipeC := newPipeChan(readerFID, writerFID)
 23 | 	rPipe := &namedPipe{pipeChan: pipeC, fid: readerFID}
 24 | 	r = newIrregularFileDescriptor(
 25 | 		readerFID,
 26 | 		rPipe.Name(),
 27 | 		&pipeReadOnly{rPipe},
 28 | 		os.ModeNamedPipe,
 29 | 	)
 30 | 	wPipe := &namedPipe{pipeChan: pipeC, fid: writerFID}
 31 | 	w = newIrregularFileDescriptor(
 32 | 		writerFID,
 33 | 		wPipe.Name(),
 34 | 		&pipeWriteOnly{wPipe},
 35 | 		os.ModeNamedPipe,
 36 | 	)
 37 | 	return
 38 | }
 39 | 
 40 | type pipeChan struct {
 41 | 	buf            chan byte
 42 | 	done           chan struct{}
 43 | 	reader, writer FID
 44 | }
 45 | 
 46 | func newPipeChan(reader, writer FID) *pipeChan {
 47 | 	const maxPipeBuffer = 32 << 10 // 32KiB
 48 | 	return &pipeChan{
 49 | 		buf:    make(chan byte, maxPipeBuffer),
 50 | 		done:   make(chan struct{}),
 51 | 		reader: reader,
 52 | 		writer: writer,
 53 | 	}
 54 | }
 55 | 
 56 | type pipeStat struct {
 57 | 	name string
 58 | 	size int64
 59 | 	mode os.FileMode
 60 | }
 61 | 
 62 | func (p pipeStat) Name() string       { return p.name }
 63 | func (p pipeStat) Size() int64        { return p.size }
 64 | func (p pipeStat) Mode() os.FileMode  { return p.mode }
 65 | func (p pipeStat) ModTime() time.Time { return time.Time{} }
 66 | func (p pipeStat) IsDir() bool        { return false }
 67 | func (p pipeStat) Sys() interface{}   { return nil }
 68 | 
 69 | func (p *pipeChan) Stat() (os.FileInfo, error) {
 70 | 	return &pipeStat{
 71 | 		name: "",
 72 | 		size: int64(len(p.buf)),
 73 | 		mode: os.ModeNamedPipe,
 74 | 	}, nil
 75 | }
 76 | 
 77 | func (p *pipeChan) Sync() error {
 78 | 	timer := time.NewTimer(time.Second)
 79 | 	defer timer.Stop()
 80 | 	select {
 81 | 	case <-p.done:
 82 | 		return nil
 83 | 	case <-timer.C:
 84 | 		return io.ErrNoProgress
 85 | 	}
 86 | }
 87 | 
 88 | func (p *pipeChan) Read(buf []byte) (n int, err error) {
 89 | 	for n < len(buf) {
 90 | 		// Read should always block if the pipe is not closed
 91 | 		b, ok := <-p.buf
 92 | 		if !ok {
 93 | 			err = io.EOF
 94 | 			return
 95 | 		}
 96 | 		buf[n] = b
 97 | 		n++
 98 | 	}
 99 | 	if n == 0 {
100 | 		err = io.EOF
101 | 	}
102 | 	return
103 | }
104 | 
105 | func (p *pipeChan) Write(buf []byte) (n int, err error) {
106 | 	for _, b := range buf {
107 | 		select {
108 | 		case <-p.done:
109 | 			// do not allow writes to a closed pipe
110 | 			return 0, interop.BadFileNumber(p.writer)
111 | 		case p.buf <- b:
112 | 			n++
113 | 			// no default case allowed, Write should always return immediately if the pipe buffer has space, otherwise it should block
114 | 		}
115 | 	}
116 | 	if n < len(buf) {
117 | 		err = io.ErrShortWrite
118 | 	}
119 | 	return
120 | }
121 | 
122 | func (p *pipeChan) Close() error {
123 | 	select {
124 | 	case <-p.done:
125 | 		return interop.BadFileNumber(p.writer)
126 | 	default:
127 | 		close(p.done)
128 | 		close(p.buf)
129 | 		return nil
130 | 	}
131 | }
132 | 
133 | type namedPipe struct {
134 | 	*pipeChan
135 | 	fid FID
136 | }
137 | 
138 | func (n *namedPipe) Name() string {
139 | 	return "pipe" + n.fid.String()
140 | }
141 | 
142 | type pipeReadOnly struct {
143 | 	*namedPipe
144 | }
145 | 
146 | func (r *pipeReadOnly) ReadAt(buf []byte, off int64) (n int, err error) {
147 | 	if off == 0 {
148 | 		return r.Read(buf)
149 | 	}
150 | 	return 0, interop.ErrNotImplemented
151 | }
152 | 
153 | func (r *pipeReadOnly) Write(buf []byte) (n int, err error) {
154 | 	return 0, interop.ErrNotImplemented
155 | }
156 | 
157 | func (r *pipeReadOnly) Close() error {
158 | 	// only write side of pipe should close the buffer
159 | 	return nil
160 | }
161 | 
162 | type pipeWriteOnly struct {
163 | 	*namedPipe
164 | }
165 | 
166 | func (w *pipeWriteOnly) Read(buf []byte) (n int, err error) {
167 | 	return 0, interop.ErrNotImplemented
168 | }
169 | 
170 | func (w *pipeWriteOnly) WriteAt(buf []byte, off int64) (n int, err error) {
171 | 	if off == 0 {
172 | 		return w.Write(buf)
173 | 	}
174 | 	return 0, interop.ErrNotImplemented
175 | }
176 | 


--------------------------------------------------------------------------------
/internal/fs/read.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"io"
 5 | 
 6 | 	"github.com/hack-pad/hackpad/internal/interop"
 7 | 	"github.com/hack-pad/hackpadfs"
 8 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
 9 | )
10 | 
11 | func (f *FileDescriptors) Read(fd FID, buffer blob.Blob, offset, length int, position *int64) (n int, err error) {
12 | 	fileDescriptor := f.files[fd]
13 | 	if fileDescriptor == nil {
14 | 		return 0, interop.BadFileNumber(fd)
15 | 	}
16 | 	// 'offset' in Node.js's read is the offset in the buffer to start writing at,
17 | 	// and 'position' is where to begin reading from in the file.
18 | 	var readBuf blob.Blob
19 | 	if position == nil {
20 | 		readBuf, n, err = blob.Read(fileDescriptor.file, length)
21 | 	} else {
22 | 		readerAt, ok := fileDescriptor.file.(io.ReaderAt)
23 | 		if ok {
24 | 			readBuf, n, err = blob.ReadAt(readerAt, length, *position)
25 | 		} else {
26 | 			err = &hackpadfs.PathError{Op: "read", Path: fileDescriptor.openedName, Err: hackpadfs.ErrNotImplemented}
27 | 		}
28 | 	}
29 | 	if err == io.EOF {
30 | 		err = nil
31 | 	}
32 | 	if readBuf != nil {
33 | 		_, setErr := blob.Set(buffer, readBuf, int64(offset))
34 | 		if err == nil && setErr != nil {
35 | 			err = &hackpadfs.PathError{Op: "read", Path: fileDescriptor.openedName, Err: setErr}
36 | 		}
37 | 	}
38 | 	return
39 | }
40 | 
41 | func (f *FileDescriptors) ReadFile(path string) (blob.Blob, error) {
42 | 	fd, err := f.Open(path, 0, 0)
43 | 	if err != nil {
44 | 		return nil, err
45 | 	}
46 | 	defer f.Close(fd)
47 | 
48 | 	info, err := f.Fstat(fd)
49 | 	if err != nil {
50 | 		return nil, err
51 | 	}
52 | 
53 | 	buf, err := newBlobLength(int(info.Size()))
54 | 	if err != nil {
55 | 		return nil, err
56 | 	}
57 | 	_, err = f.Read(fd, buf, 0, buf.Len(), nil)
58 | 	return buf, err
59 | }
60 | 


--------------------------------------------------------------------------------
/internal/fs/stdout.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"bytes"
 5 | 	"sync"
 6 | 	"time"
 7 | 
 8 | 	"github.com/hack-pad/hackpad/internal/log"
 9 | 	"github.com/hack-pad/hackpadfs"
10 | )
11 | 
12 | var (
13 | 	stdout hackpadfs.File = &bufferedLogger{name: "dev/stdout", printFn: log.Print}
14 | 	stderr hackpadfs.File = &bufferedLogger{name: "dev/stderr", printFn: log.Error}
15 | )
16 | 
17 | type bufferedLogger struct {
18 | 	unimplementedFile
19 | 
20 | 	name      string
21 | 	printFn   func(...interface{}) int
22 | 	mu        sync.Mutex
23 | 	buf       bytes.Buffer
24 | 	timerOnce sync.Once
25 | }
26 | 
27 | func (b *bufferedLogger) flush() {
28 | 	if b.buf.Len() == 0 {
29 | 		return
30 | 	}
31 | 
32 | 	const maxBufLen = 4096
33 | 
34 | 	b.mu.Lock()
35 | 	i := bytes.LastIndexByte(b.buf.Bytes(), '\n')
36 | 	var buf []byte
37 | 	if i == -1 || b.buf.Len() > maxBufLen {
38 | 		buf = b.buf.Bytes()
39 | 		b.buf.Reset()
40 | 	} else {
41 | 		i++ // include newline char if present
42 | 		buf = make([]byte, i)
43 | 		n, _ := b.buf.Read(buf) // at time of writing, only io.EOF can be returned -- which we don't need
44 | 		buf = buf[:n]
45 | 	}
46 | 	b.mu.Unlock()
47 | 	if len(buf) != 0 {
48 | 		b.printFn(string(buf))
49 | 	}
50 | }
51 | 
52 | func (b *bufferedLogger) Print(s string) int {
53 | 	n, _ := b.Write([]byte(s))
54 | 	return n
55 | }
56 | 
57 | func (b *bufferedLogger) Write(p []byte) (n int, err error) {
58 | 	b.timerOnce.Do(func() {
59 | 		const waitTime = time.Second / 2
60 | 		go func() {
61 | 			ticker := time.NewTicker(waitTime)
62 | 			for range ticker.C {
63 | 				b.flush()
64 | 			}
65 | 		}()
66 | 	})
67 | 
68 | 	b.mu.Lock()
69 | 	_, _ = b.buf.Write(p) // at time of writing, bytes.Buffer.Write cannot return an error
70 | 	b.mu.Unlock()
71 | 	return len(p), nil
72 | }
73 | 
74 | func (b *bufferedLogger) Name() string {
75 | 	return b.name
76 | }
77 | 
78 | func (b *bufferedLogger) Close() error {
79 | 	// TODO prevent writes and return os.ErrClosed
80 | 	return nil
81 | }
82 | 


--------------------------------------------------------------------------------
/internal/fs/unimplemented.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"os"
 5 | 
 6 | 	"github.com/hack-pad/hackpad/internal/interop"
 7 | 	"github.com/hack-pad/hackpadfs"
 8 | )
 9 | 
10 | var _ hackpadfs.File = &unimplementedFile{}
11 | 
12 | // unimplementedFile can be embedded in special files like /dev/null to provide a default unimplemented hackpadfs.File interface
13 | type unimplementedFile struct{}
14 | 
15 | func (f unimplementedFile) Close() error                     { return interop.ErrNotImplemented }
16 | func (f unimplementedFile) Read(p []byte) (n int, err error) { return 0, interop.ErrNotImplemented }
17 | func (f unimplementedFile) Stat() (os.FileInfo, error)       { return nil, interop.ErrNotImplemented }
18 | 


--------------------------------------------------------------------------------
/internal/fs/wasm_cache.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package fs
  5 | 
  6 | import (
  7 | 	"io"
  8 | 	"os"
  9 | 	"strings"
 10 | 	"syscall/js"
 11 | 	"time"
 12 | 
 13 | 	"github.com/hack-pad/hackpad/internal/fsutil"
 14 | 	"github.com/hack-pad/hackpad/internal/log"
 15 | 	"github.com/hack-pad/hackpad/internal/promise"
 16 | 	"github.com/hack-pad/hackpadfs"
 17 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
 18 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
 19 | )
 20 | 
 21 | var jsWasm = js.Global().Get("WebAssembly")
 22 | 
 23 | type wasmCacheFs struct {
 24 | 	rootFs
 25 | 	memCache map[string]js.Value
 26 | }
 27 | 
 28 | func init() {
 29 | 	initWasmCache()
 30 | }
 31 | 
 32 | func initWasmCache() {
 33 | 	fs, err := newWasmCacheFs(filesystem)
 34 | 	if err != nil {
 35 | 		log.Error("Failed to enable Wasm Module cache: ", err)
 36 | 	} else {
 37 | 		filesystem = fs
 38 | 	}
 39 | }
 40 | 
 41 | func shouldCache(path string) bool {
 42 | 	return strings.HasPrefix(path, "usr/local/go/")
 43 | }
 44 | 
 45 | func newWasmCacheFs(underlying rootFs) (*wasmCacheFs, error) {
 46 | 	return &wasmCacheFs{
 47 | 		rootFs:   underlying,
 48 | 		memCache: make(map[string]js.Value),
 49 | 	}, nil
 50 | }
 51 | 
 52 | func (w *wasmCacheFs) readFile(path string) (blob.Blob, error) {
 53 | 	f, err := w.Open(path)
 54 | 	if err != nil {
 55 | 		return nil, err
 56 | 	}
 57 | 	defer f.Close()
 58 | 
 59 | 	info, err := f.Stat()
 60 | 	if err != nil {
 61 | 		return nil, err
 62 | 	}
 63 | 
 64 | 	buf, _, err := blob.Read(f, int(info.Size()))
 65 | 	if err == io.EOF {
 66 | 		err = nil
 67 | 	}
 68 | 	return buf, err
 69 | }
 70 | 
 71 | func (w *wasmCacheFs) WasmInstance(path string, importObject js.Value) (js.Value, error) {
 72 | 	log.Debug("Checking wasm instance cache")
 73 | 	module, memCacheHit := w.memCache[path]
 74 | 	if memCacheHit {
 75 | 		log.Debug("memCache hit: ", path)
 76 | 	} else {
 77 | 		log.Debug("memCache miss: ", path)
 78 | 		moduleBlob, err := w.readFile(path)
 79 | 		if err != nil {
 80 | 			log.Debug("reading file failed: ", path)
 81 | 			return js.Value{}, err
 82 | 		}
 83 | 		module = idbblob.FromBlob(moduleBlob).JSValue()
 84 | 		if !module.Truthy() {
 85 | 			log.Debug("fs miss: ", path, module.Length())
 86 | 		}
 87 | 	}
 88 | 
 89 | 	instantiatePromise := promise.From(jsWasm.Call("instantiate", module, importObject))
 90 | 	instanceInterface, err := instantiatePromise.Await()
 91 | 	if err != nil {
 92 | 		return js.Value{}, err
 93 | 	}
 94 | 	result := instanceInterface.(js.Value)
 95 | 
 96 | 	log.Debug("successfully instantiated module: ", path)
 97 | 	if memCacheHit {
 98 | 		// if memCacheHit, then module is already compiled
 99 | 		// so return value is only an Instance, not a ResultObject
100 | 		return result, nil
101 | 	}
102 | 
103 | 	if shouldCache(path) {
104 | 		w.memCache[path] = result.Get("module") // save compiled module for reuse
105 | 	}
106 | 	return result.Get("instance"), nil
107 | }
108 | 
109 | func (w *wasmCacheFs) dropModuleCache(path string) error {
110 | 	path = fsutil.NormalizePath(path)
111 | 	delete(w.memCache, path)
112 | 	return nil
113 | }
114 | 
115 | func (w *wasmCacheFs) Create(name string) (hackpadfs.File, error) {
116 | 	if err := w.dropModuleCache(fsutil.NormalizePath(name)); err != nil {
117 | 		return nil, err
118 | 	}
119 | 	return hackpadfs.Create(w.rootFs, name)
120 | }
121 | 
122 | func (w *wasmCacheFs) OpenFile(name string, flag int, perm os.FileMode) (hackpadfs.File, error) {
123 | 	if flag != os.O_RDONLY {
124 | 		err := w.dropModuleCache(fsutil.NormalizePath(name))
125 | 		if err != nil {
126 | 			return nil, err
127 | 		}
128 | 	}
129 | 	return hackpadfs.OpenFile(w.rootFs, name, flag, perm)
130 | }
131 | 
132 | func (w *wasmCacheFs) Remove(name string) error {
133 | 	if err := w.dropModuleCache(fsutil.NormalizePath(name)); err != nil {
134 | 		return err
135 | 	}
136 | 	return hackpadfs.Remove(w.rootFs, name)
137 | }
138 | 
139 | func (w *wasmCacheFs) RemoveAll(path string) error {
140 | 	// TODO is there a performant way to remove modules recursively?
141 | 	if err := w.dropModuleCache(fsutil.NormalizePath(path)); err != nil {
142 | 		return err
143 | 	}
144 | 	return hackpadfs.RemoveAll(w.rootFs, path)
145 | }
146 | 
147 | func (w *wasmCacheFs) Rename(oldname, newname string) error {
148 | 	// TODO maybe preserve oldname's module somehow?
149 | 	if err := w.dropModuleCache(fsutil.NormalizePath(oldname)); err != nil {
150 | 		return err
151 | 	}
152 | 	if err := w.dropModuleCache(fsutil.NormalizePath(newname)); err != nil {
153 | 		return err
154 | 	}
155 | 	return hackpadfs.Rename(w.rootFs, oldname, newname)
156 | }
157 | 
158 | func (w *wasmCacheFs) Chtimes(name string, atime time.Time, mtime time.Time) error {
159 | 	return hackpadfs.Chtimes(w.rootFs, name, atime, mtime)
160 | }
161 | 


--------------------------------------------------------------------------------
/internal/fs/working_directory.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"time"
 5 | 
 6 | 	"github.com/hack-pad/hackpad/internal/log"
 7 | 	"github.com/hack-pad/hackpadfs"
 8 | 	"go.uber.org/atomic"
 9 | )
10 | 
11 | type workingDirectory struct {
12 | 	path     atomic.String
13 | 	updating atomic.Bool
14 | }
15 | 
16 | func newWorkingDirectory(path string) *workingDirectory {
17 | 	w := &workingDirectory{}
18 | 	w.path.Store(path)
19 | 	return w
20 | }
21 | 
22 | func (w *workingDirectory) Set(wd string) error {
23 | 	// must be async to support IDB FS
24 | 	w.updating.Store(true)
25 | 	go func() {
26 | 		defer w.updating.Store(false)
27 | 		info, err := hackpadfs.Stat(filesystem, wd)
28 | 		if err != nil {
29 | 			log.Error("Cannot chdir to ", wd, ": ", err)
30 | 			return
31 | 		}
32 | 		if !info.IsDir() {
33 | 			log.Error("Cannot chdir to ", wd, ": ", ErrNotDir)
34 | 			return
35 | 		}
36 | 		w.path.Store(wd)
37 | 	}()
38 | 	return nil
39 | }
40 | 
41 | func (w *workingDirectory) Get() (string, error) {
42 | 	for i := 0; i < 10 && w.updating.Load(); i++ {
43 | 		time.Sleep(10 * time.Millisecond)
44 | 	}
45 | 	return w.path.Load(), nil
46 | }
47 | 


--------------------------------------------------------------------------------
/internal/fs/write.go:
--------------------------------------------------------------------------------
 1 | package fs
 2 | 
 3 | import (
 4 | 	"io"
 5 | 
 6 | 	"github.com/hack-pad/hackpad/internal/interop"
 7 | 	"github.com/hack-pad/hackpadfs"
 8 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
 9 | )
10 | 
11 | func (f *FileDescriptors) Write(fd FID, buffer blob.Blob, offset, length int, position *int64) (n int, err error) {
12 | 	fileDescriptor := f.files[fd]
13 | 	if fileDescriptor == nil {
14 | 		return 0, interop.BadFileNumber(fd)
15 | 	}
16 | 	file, ok := fileDescriptor.file.(io.Writer)
17 | 	if !ok {
18 | 		return 0, hackpadfs.ErrNotImplemented
19 | 	}
20 | 	// 'offset' in Node.js's read is the offset in the buffer to start writing at,
21 | 	// and 'position' is where to begin reading from in the file.
22 | 	if position != nil {
23 | 		_, err := hackpadfs.SeekFile(fileDescriptor.file, *position, io.SeekStart)
24 | 		if err != nil {
25 | 			return 0, err
26 | 		}
27 | 	}
28 | 	dataToCopy, err := blob.View(buffer, int64(offset), int64(offset+length))
29 | 	if err != nil {
30 | 		return 0, &hackpadfs.PathError{Op: "write", Path: fileDescriptor.openedName, Err: err}
31 | 	}
32 | 	n, err = blob.Write(file, dataToCopy)
33 | 	if err == io.EOF {
34 | 		err = nil
35 | 	}
36 | 	return
37 | }
38 | 


--------------------------------------------------------------------------------
/internal/fsutil/fsutil.go:
--------------------------------------------------------------------------------
1 | package fsutil
2 | 
3 | import "path"
4 | 
5 | func NormalizePath(p string) string {
6 | 	return path.Clean(p)
7 | }
8 | 


--------------------------------------------------------------------------------
/internal/global/init.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package global
 5 | 
 6 | import "syscall/js"
 7 | 
 8 | const globalKey = "hackpad"
 9 | 
10 | var globals js.Value
11 | 
12 | func init() {
13 | 	global := js.Global()
14 | 	if globals.IsUndefined() {
15 | 		globals = global.Get(globalKey)
16 | 	}
17 | 	if !globals.IsUndefined() {
18 | 		return
19 | 	}
20 | 	global.Set(globalKey, map[string]interface{}{})
21 | 	globals = global.Get(globalKey)
22 | }
23 | 
24 | func SetDefault(key string, value interface{}) {
25 | 	if globals.Get(key).IsUndefined() {
26 | 		globals.Set(key, value)
27 | 	}
28 | }
29 | 
30 | func Set(key string, value interface{}) {
31 | 	globals.Set(key, value)
32 | }
33 | 
34 | func Get(key string) js.Value {
35 | 	return globals.Get(key)
36 | }
37 | 


--------------------------------------------------------------------------------
/internal/interop/download.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
10 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
11 | )
12 | 
13 | var (
14 | 	jsBlob     = js.Global().Get("Blob")
15 | 	jsDocument = js.Global().Get("document")
16 | 	jsURL      = js.Global().Get("URL")
17 | )
18 | 
19 | func StartDownload(contentType, fileName string, buf []byte) {
20 | 	if contentType == "" {
21 | 		contentType = "application/octet-stream"
22 | 	}
23 | 	b := idbblob.FromBlob(blob.NewBytes(buf))
24 | 	blobInstance := jsBlob.New([]interface{}{b}, map[string]interface{}{
25 | 		"type": contentType,
26 | 	})
27 | 	link := jsDocument.Call("createElement", "a")
28 | 	link.Set("href", jsURL.Call("createObjectURL", blobInstance))
29 | 	link.Set("download", fileName)
30 | 	link.Call("click")
31 | }
32 | 


--------------------------------------------------------------------------------
/internal/interop/error.go:
--------------------------------------------------------------------------------
 1 | package interop
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 	"io"
 6 | 	"os/exec"
 7 | 
 8 | 	"github.com/hack-pad/hackpad/internal/common"
 9 | 	"github.com/hack-pad/hackpad/internal/log"
10 | 	"github.com/hack-pad/hackpadfs"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | var (
15 | 	ErrNotImplemented = NewError("operation not supported", "ENOSYS")
16 | )
17 | 
18 | type Error interface {
19 | 	error
20 | 	Message() string
21 | 	Code() string
22 | }
23 | 
24 | type interopErr struct {
25 | 	error
26 | 	code string
27 | }
28 | 
29 | func NewError(message, code string) Error {
30 | 	return WrapErr(errors.New(message), code)
31 | }
32 | 
33 | func WrapErr(err error, code string) Error {
34 | 	return &interopErr{
35 | 		error: err,
36 | 		code:  code,
37 | 	}
38 | }
39 | 
40 | func (e *interopErr) Message() string {
41 | 	return e.Error()
42 | }
43 | 
44 | func (e *interopErr) Code() string {
45 | 	return e.code
46 | }
47 | 
48 | // errno names pulled from syscall/tables_js.go
49 | func mapToErrNo(err error, debugMessage string) string {
50 | 	if err, ok := err.(Error); ok {
51 | 		return err.Code()
52 | 	}
53 | 	if err, ok := err.(interface{ Unwrap() error }); ok {
54 | 		return mapToErrNo(err.Unwrap(), debugMessage)
55 | 	}
56 | 	switch err {
57 | 	case io.EOF, exec.ErrNotFound:
58 | 		return "ENOENT"
59 | 	}
60 | 	switch {
61 | 	case errors.Is(err, hackpadfs.ErrClosed):
62 | 		return "EBADF" // if it was already closed, then the file descriptor was invalid
63 | 	case errors.Is(err, hackpadfs.ErrNotExist):
64 | 		return "ENOENT"
65 | 	case errors.Is(err, hackpadfs.ErrExist):
66 | 		return "EEXIST"
67 | 	case errors.Is(err, hackpadfs.ErrIsDir):
68 | 		return "EISDIR"
69 | 	case errors.Is(err, hackpadfs.ErrPermission):
70 | 		return "EPERM"
71 | 	default:
72 | 		log.Errorf("Unknown error type: (%T) %+v\n\n%s", err, err, debugMessage)
73 | 		return "EPERM"
74 | 	}
75 | }
76 | 
77 | func BadFileNumber(fd common.FID) error {
78 | 	return NewError(fmt.Sprintf("Bad file number %d", fd), "EBADF")
79 | }
80 | 
81 | func BadFileErr(identifier string) error {
82 | 	return NewError(fmt.Sprintf("Bad file %q", identifier), "EBADF")
83 | }
84 | 


--------------------------------------------------------------------------------
/internal/interop/error_js.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import (
 7 | 	"fmt"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | func WrapAsJSError(err error, message string) js.Value {
14 | 	return wrapAsJSError(err, message)
15 | }
16 | 
17 | func wrapAsJSError(err error, message string, args ...js.Value) js.Value {
18 | 	if err == nil {
19 | 		return js.Null()
20 | 	}
21 | 
22 | 	errMessage := errors.Wrap(err, message).Error()
23 | 	for _, arg := range args {
24 | 		errMessage += fmt.Sprintf("\n%v", arg)
25 | 	}
26 | 
27 | 	return js.ValueOf(map[string]interface{}{
28 | 		"message": js.ValueOf(errMessage),
29 | 		"code":    js.ValueOf(mapToErrNo(err, errMessage)),
30 | 	})
31 | }
32 | 


--------------------------------------------------------------------------------
/internal/interop/event.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import (
 7 | 	"sync"
 8 | 	"syscall/js"
 9 | )
10 | 
11 | type EventCallback func(event Event, args ...interface{})
12 | 
13 | type EventTarget interface {
14 | 	Listen(eventName string, callback EventCallback)
15 | 	Emit(event Event, args ...interface{})
16 | }
17 | 
18 | func NewEventTarget() EventTarget {
19 | 	return &eventTarget{
20 | 		listeners: make(map[string][]EventCallback),
21 | 	}
22 | }
23 | 
24 | type Event struct {
25 | 	Target js.Value
26 | 	Type   string
27 | }
28 | 
29 | type eventTarget struct {
30 | 	mu        sync.Mutex
31 | 	listeners map[string][]EventCallback
32 | }
33 | 
34 | func (e *eventTarget) Listen(eventName string, callback EventCallback) {
35 | 	e.mu.Lock()
36 | 	e.listeners[eventName] = append(e.listeners[eventName], callback)
37 | 	e.mu.Unlock()
38 | }
39 | 
40 | func (e *eventTarget) Emit(event Event, args ...interface{}) {
41 | 	e.mu.Lock()
42 | 	listeners := e.listeners[event.Type]
43 | 	e.mu.Unlock()
44 | 	for _, l := range listeners {
45 | 		l(event, args...)
46 | 	}
47 | }
48 | 


--------------------------------------------------------------------------------
/internal/interop/funcs.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package interop
  5 | 
  6 | import (
  7 | 	"fmt"
  8 | 	"runtime/debug"
  9 | 	"strings"
 10 | 	"syscall/js"
 11 | 
 12 | 	"github.com/hack-pad/hackpad/internal/log"
 13 | 	"github.com/pkg/errors"
 14 | )
 15 | 
 16 | type Func = func(args []js.Value) (interface{}, error)
 17 | 
 18 | type CallbackFunc = func(args []js.Value) ([]interface{}, error)
 19 | 
 20 | func SetFunc(val js.Value, name string, fn interface{}) js.Func {
 21 | 	defer handlePanic(0)
 22 | 
 23 | 	switch fn.(type) {
 24 | 	case Func, CallbackFunc:
 25 | 	default:
 26 | 		panic(fmt.Sprintf("Invalid SetFunc type: %T", fn))
 27 | 	}
 28 | 
 29 | 	wrappedFn := func(_ js.Value, args []js.Value) interface{} {
 30 | 		return setFuncHandler(name, fn, args)
 31 | 	}
 32 | 	jsWrappedFn := js.FuncOf(wrappedFn)
 33 | 	val.Set(name, jsWrappedFn)
 34 | 	return jsWrappedFn
 35 | }
 36 | 
 37 | func setFuncHandler(name string, fn interface{}, args []js.Value) (returnedVal interface{}) {
 38 | 	logArgs := []interface{}{"running op: " + name}
 39 | 	for _, arg := range args {
 40 | 		logArgs = append(logArgs, arg)
 41 | 	}
 42 | 	log.DebugJSValues(logArgs...)
 43 | 
 44 | 	switch fn := fn.(type) {
 45 | 	case Func:
 46 | 		defer handlePanic(0)
 47 | 		defer log.DebugJSValues("completed sync op: "+name, returnedVal)
 48 | 
 49 | 		ret, err := fn(args)
 50 | 		if err != nil {
 51 | 			log.Error(errors.Wrap(err, name).Error())
 52 | 		}
 53 | 		return ret
 54 | 	case CallbackFunc:
 55 | 		// callback style detected, so pop callback arg and call it with the return values
 56 | 		// error always goes first
 57 | 		callback := args[len(args)-1]
 58 | 		args = args[:len(args)-1]
 59 | 		go func() {
 60 | 			var ret []interface{}
 61 | 			var err error
 62 | 			defer handlePanic(0)
 63 | 			defer func() {
 64 | 				if err != nil {
 65 | 					log.DebugJSValues("completed op failed: "+name, ret)
 66 | 				} else {
 67 | 					log.DebugJSValues("completed op: "+name, ret)
 68 | 				}
 69 | 			}()
 70 | 
 71 | 			ret, err = fn(args)
 72 | 			errValue := wrapAsJSError(err, name, args...)
 73 | 			ret = append([]interface{}{errValue}, ret...)
 74 | 			callback.Invoke(ret...)
 75 | 		}()
 76 | 		return nil
 77 | 	default:
 78 | 		panic("impossible case") // handled above
 79 | 	}
 80 | }
 81 | 
 82 | func handlePanic(skipPanicLines int) {
 83 | 	r := recover()
 84 | 	if r == nil {
 85 | 		return
 86 | 	}
 87 | 	defaultHandlePanic(skipPanicLines+1, r)
 88 | }
 89 | 
 90 | func defaultHandlePanic(skipPanicLines int, r interface{}) {
 91 | 	stack := string(debug.Stack())
 92 | 	for iter := 0; iter < skipPanicLines; iter++ {
 93 | 		ix := strings.IndexRune(stack, '\n')
 94 | 		if ix == -1 {
 95 | 			break
 96 | 		}
 97 | 		stack = stack[ix+1:]
 98 | 	}
 99 | 	switch r := r.(type) {
100 | 	case js.Value:
101 | 		log.ErrorJSValues(
102 | 			"panic:",
103 | 			r,
104 | 			"\n\n"+stack,
105 | 		)
106 | 	default:
107 | 		log.Errorf("panic: (%T) %+v\n\n%s", r, r, stack)
108 | 	}
109 | 	// TODO need to find a way to just throw the error instead of crashing
110 | }
111 | 
112 | func PanicLogger() {
113 | 	r := recover()
114 | 	if r == nil {
115 | 		return
116 | 	}
117 | 	defaultHandlePanic(1, r)
118 | 	panic(r)
119 | }
120 | 


--------------------------------------------------------------------------------
/internal/interop/load.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import "github.com/hack-pad/hackpad/internal/global"
 7 | 
 8 | func SetInitialized() {
 9 | 	global.Set("ready", true)
10 | }
11 | 


--------------------------------------------------------------------------------
/internal/interop/once_func.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import "syscall/js"
 7 | 
 8 | func SingleUseFunc(fn func(this js.Value, args []js.Value) interface{}) js.Func {
 9 | 	var wrapperFn js.Func
10 | 	wrapperFn = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
11 | 		wrapperFn.Release()
12 | 		return fn(this, args)
13 | 	})
14 | 	return wrapperFn
15 | }
16 | 


--------------------------------------------------------------------------------
/internal/interop/profile.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package interop
  5 | 
  6 | import (
  7 | 	"bytes"
  8 | 	"context"
  9 | 	"runtime"
 10 | 	"runtime/pprof"
 11 | 	"runtime/trace"
 12 | 	"syscall/js"
 13 | 	"time"
 14 | 
 15 | 	"github.com/hack-pad/hackpad/internal/log"
 16 | )
 17 | 
 18 | func ProfileJS(this js.Value, args []js.Value) interface{} {
 19 | 	go func() {
 20 | 		MemoryProfileJS(this, args)
 21 | 		// Re-enable once these profiles actually work in the browser. Currently produces 0 samples.
 22 | 		// TraceProfileJS(this, args)
 23 | 		// StartCPUProfileJS(this, args)
 24 | 	}()
 25 | 	return nil
 26 | }
 27 | 
 28 | func TraceProfile(d time.Duration) ([]byte, error) {
 29 | 	var buf bytes.Buffer
 30 | 	err := trace.Start(&buf)
 31 | 	if err != nil {
 32 | 		return nil, err
 33 | 	}
 34 | 	time.Sleep(d)
 35 | 	trace.Stop()
 36 | 	return buf.Bytes(), nil
 37 | }
 38 | 
 39 | func TraceProfileJS(this js.Value, args []js.Value) interface{} {
 40 | 	buf, err := TraceProfile(30 * time.Second)
 41 | 	if err != nil {
 42 | 		log.Error("Failed to create memory profile: ", err)
 43 | 		return nil
 44 | 	}
 45 | 	StartDownload("", "hackpad-trace.pprof", buf)
 46 | 	return nil
 47 | }
 48 | 
 49 | func MemoryProfile() ([]byte, error) {
 50 | 	var buf bytes.Buffer
 51 | 	runtime.GC()
 52 | 	err := pprof.WriteHeapProfile(&buf)
 53 | 	if err != nil {
 54 | 		return nil, err
 55 | 	}
 56 | 	return buf.Bytes(), nil
 57 | }
 58 | 
 59 | func MemoryProfileJS(this js.Value, args []js.Value) interface{} {
 60 | 	buf, err := MemoryProfile()
 61 | 	if err != nil {
 62 | 		log.Error("Failed to create memory profile: ", err)
 63 | 		return nil
 64 | 	}
 65 | 	StartDownload("", "hackpad-mem.pprof", buf)
 66 | 	return nil
 67 | }
 68 | 
 69 | func StartCPUProfile(ctx context.Context) error {
 70 | 	var buf bytes.Buffer
 71 | 	err := pprof.StartCPUProfile(&buf)
 72 | 	if err != nil {
 73 | 		return err
 74 | 	}
 75 | 
 76 | 	go func() {
 77 | 		<-ctx.Done()
 78 | 		pprof.StopCPUProfile()
 79 | 		StartDownload("", "hackpad-cpu.pprof", buf.Bytes())
 80 | 	}()
 81 | 	return nil
 82 | }
 83 | 
 84 | func StartCPUProfileDuration(d time.Duration) error {
 85 | 	ctx, cancel := context.WithTimeout(context.Background(), d)
 86 | 	go func() {
 87 | 		// NOTE: this is purely to satisfy linters. This func is only called while debugging. We don't want to cancel the context in this scope, so discard it.
 88 | 		<-ctx.Done()
 89 | 		cancel()
 90 | 	}()
 91 | 	return StartCPUProfile(ctx)
 92 | }
 93 | 
 94 | func StartCPUProfileJS(this js.Value, args []js.Value) interface{} {
 95 | 	duration := 30 * time.Second
 96 | 	if len(args) > 0 && args[0].Truthy() {
 97 | 		duration = time.Duration(args[0].Float() * float64(time.Second))
 98 | 	}
 99 | 	err := StartCPUProfileDuration(duration)
100 | 	if err != nil {
101 | 		log.Error("Failed to start CPU profile: ", err)
102 | 	}
103 | 	return nil
104 | }
105 | 


--------------------------------------------------------------------------------
/internal/interop/value_caches.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | )
 9 | 
10 | var (
11 | 	jsReflectGet = js.Global().Get("Reflect").Get("get")
12 | )
13 | 
14 | type cacher struct {
15 | 	cache map[string]js.Value
16 | }
17 | 
18 | func (c *cacher) value(key string, valueFn func() interface{}) js.Value {
19 | 	if val, ok := c.cache[key]; ok {
20 | 		return val
21 | 	}
22 | 	if c.cache == nil {
23 | 		c.cache = make(map[string]js.Value)
24 | 	}
25 | 	val := js.ValueOf(valueFn())
26 | 	c.cache[key] = val
27 | 	return val
28 | }
29 | 
30 | type StringCache struct {
31 | 	cacher
32 | }
33 | 
34 | func (c *StringCache) Value(s string) js.Value {
35 | 	return c.value(s, identityStringGetter{s}.value)
36 | }
37 | 
38 | func (c *StringCache) GetProperty(obj js.Value, key string) js.Value {
39 | 	jsKey := c.Value(key)
40 | 	return jsReflectGet.Invoke(obj, jsKey)
41 | }
42 | 
43 | // CallCache caches a member function by name, then runs Invoke instead of Call.
44 | // Has a slight performance boost, since it amortizes Reflect.get.
45 | type CallCache struct {
46 | 	cacher
47 | }
48 | 
49 | func (c *CallCache) Call(jsObj js.Value, s string, args ...interface{}) js.Value {
50 | 	valueFn := objGetter{key: s, obj: jsObj}.value
51 | 	return c.value(s, valueFn).Invoke(args...)
52 | }
53 | 
54 | type identityStringGetter struct {
55 | 	s string
56 | }
57 | 
58 | func (i identityStringGetter) value() interface{} {
59 | 	return i.s
60 | }
61 | 
62 | type objGetter struct {
63 | 	key string
64 | 	obj js.Value
65 | }
66 | 
67 | func (o objGetter) value() interface{} {
68 | 	return o.obj.Get(o.key).Call("bind", o.obj)
69 | }
70 | 


--------------------------------------------------------------------------------
/internal/interop/values.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package interop
 5 | 
 6 | import "syscall/js"
 7 | 
 8 | var jsObject = js.Global().Get("Object")
 9 | 
10 | func SliceFromStrings(strs []string) js.Value {
11 | 	var values []interface{}
12 | 	for _, s := range strs {
13 | 		values = append(values, s)
14 | 	}
15 | 	return js.ValueOf(values)
16 | }
17 | 
18 | func StringsFromJSValue(value js.Value) []string {
19 | 	var strs []string
20 | 	length := value.Length()
21 | 	for i := 0; i < length; i++ {
22 | 		strs = append(strs, value.Index(i).String())
23 | 	}
24 | 	return strs
25 | }
26 | 
27 | func SliceFromJSValue(value js.Value) []js.Value {
28 | 	var values []js.Value
29 | 	length := value.Length()
30 | 	for i := 0; i < length; i++ {
31 | 		values = append(values, value.Index(i))
32 | 	}
33 | 	return values
34 | }
35 | 
36 | func SliceFromJSValues(args []js.Value) []interface{} {
37 | 	var values []interface{}
38 | 	for _, arg := range args {
39 | 		values = append(values, arg)
40 | 	}
41 | 	return values
42 | }
43 | 
44 | func Keys(value js.Value) []string {
45 | 	jsKeys := jsObject.Call("keys", value)
46 | 	length := jsKeys.Length()
47 | 	var keys []string
48 | 	for i := 0; i < length; i++ {
49 | 		keys = append(keys, jsKeys.Index(i).String())
50 | 	}
51 | 	return keys
52 | }
53 | 
54 | func Entries(value js.Value) map[string]js.Value {
55 | 	entries := make(map[string]js.Value)
56 | 	for _, key := range Keys(value) {
57 | 		entries[key] = value.Get(key)
58 | 	}
59 | 	return entries
60 | }
61 | 
62 | func StringMap(m map[string]string) js.Value {
63 | 	jsValue := make(map[string]interface{})
64 | 	for key, value := range m {
65 | 		jsValue[key] = value
66 | 	}
67 | 	return js.ValueOf(jsValue)
68 | }
69 | 


--------------------------------------------------------------------------------
/internal/js/fs/chmod.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"os"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func chmod(args []js.Value) ([]interface{}, error) {
15 | 	_, err := chmodSync(args)
16 | 	return nil, err
17 | }
18 | 
19 | func chmodSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) != 2 {
21 | 		return nil, errors.Errorf("Invalid number of args, expected 2: %v", args)
22 | 	}
23 | 
24 | 	path := args[0].String()
25 | 	mode := os.FileMode(args[1].Int())
26 | 	p := process.Current()
27 | 	return nil, p.Files().Chmod(path, mode)
28 | }
29 | 


--------------------------------------------------------------------------------
/internal/js/fs/chown.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/pkg/errors"
10 | )
11 | 
12 | func chown(args []js.Value) ([]interface{}, error) {
13 | 	_, err := chownSync(args)
14 | 	return nil, err
15 | }
16 | 
17 | func chownSync(args []js.Value) (interface{}, error) {
18 | 	if len(args) != 3 {
19 | 		return nil, errors.Errorf("Invalid number of args, expected 3: %v", args)
20 | 	}
21 | 
22 | 	path := args[0].String()
23 | 	uid := args[1].Int()
24 | 	gid := args[2].Int()
25 | 	return nil, Chown(path, uid, gid)
26 | }
27 | 
28 | func Chown(path string, uid, gid int) error {
29 | 	// TODO no-op, consider adding user and group ID support to hackpadfs
30 | 	return nil
31 | }
32 | 


--------------------------------------------------------------------------------
/internal/js/fs/close.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func closeFn(args []js.Value) ([]interface{}, error) {
15 | 	ret, err := closeSync(args)
16 | 	return []interface{}{ret}, err
17 | }
18 | 
19 | func closeSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) != 1 {
21 | 		return nil, errors.Errorf("not enough args %d", len(args))
22 | 	}
23 | 
24 | 	fd := fs.FID(args[0].Int())
25 | 	p := process.Current()
26 | 	err := p.Files().Close(fd)
27 | 	return nil, err
28 | }
29 | 


--------------------------------------------------------------------------------
/internal/js/fs/fchmod.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"os"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/common"
11 | 	"github.com/hack-pad/hackpad/internal/process"
12 | 	"github.com/pkg/errors"
13 | )
14 | 
15 | func fchmod(args []js.Value) ([]interface{}, error) {
16 | 	_, err := fchmodSync(args)
17 | 	return nil, err
18 | }
19 | 
20 | func fchmodSync(args []js.Value) (interface{}, error) {
21 | 	if len(args) != 2 {
22 | 		return nil, errors.Errorf("Invalid number of args, expected 2: %v", args)
23 | 	}
24 | 
25 | 	fid := common.FID(args[0].Int())
26 | 	mode := os.FileMode(args[1].Int())
27 | 	p := process.Current()
28 | 	return nil, p.Files().Fchmod(fid, mode)
29 | }
30 | 


--------------------------------------------------------------------------------
/internal/js/fs/flock.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/common"
11 | 	"github.com/hack-pad/hackpad/internal/fs"
12 | 	"github.com/hack-pad/hackpad/internal/process"
13 | 	"github.com/pkg/errors"
14 | )
15 | 
16 | func flock(args []js.Value) ([]interface{}, error) {
17 | 	_, err := flockSync(args)
18 | 	return nil, err
19 | }
20 | 
21 | func flockSync(args []js.Value) (interface{}, error) {
22 | 	if len(args) != 2 {
23 | 		return nil, errors.Errorf("Invalid number of args, expected 2: %v", args)
24 | 	}
25 | 	fid := common.FID(args[0].Int())
26 | 	flag := args[1].Int()
27 | 	var action fs.LockAction
28 | 	shouldLock := true
29 | 	switch flag {
30 | 	case syscall.LOCK_EX:
31 | 		action = fs.LockExclusive
32 | 	case syscall.LOCK_SH:
33 | 		action = fs.LockShared
34 | 	case syscall.LOCK_UN:
35 | 		action = fs.Unlock
36 | 	}
37 | 
38 | 	return nil, Flock(fid, action, shouldLock)
39 | }
40 | 
41 | func Flock(fid common.FID, action fs.LockAction, shouldLock bool) error {
42 | 	p := process.Current()
43 | 	return p.Files().Flock(fid, action)
44 | }
45 | 


--------------------------------------------------------------------------------
/internal/js/fs/fs.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package fs
  5 | 
  6 | import (
  7 | 	"errors"
  8 | 	"os"
  9 | 	"syscall"
 10 | 	"syscall/js"
 11 | 
 12 | 	"github.com/hack-pad/hackpad/internal/common"
 13 | 	"github.com/hack-pad/hackpad/internal/fs"
 14 | 	"github.com/hack-pad/hackpad/internal/global"
 15 | 	"github.com/hack-pad/hackpad/internal/interop"
 16 | 	"github.com/hack-pad/hackpad/internal/process"
 17 | 	"github.com/hack-pad/hackpad/internal/promise"
 18 | )
 19 | 
 20 | /*
 21 | fchown(fd, uid, gid, callback) { callback(enosys()); },
 22 | lchown(path, uid, gid, callback) { callback(enosys()); },
 23 | link(path, link, callback) { callback(enosys()); },
 24 | readlink(path, callback) { callback(enosys()); },
 25 | symlink(path, link, callback) { callback(enosys()); },
 26 | truncate(path, length, callback) { callback(enosys()); },
 27 | */
 28 | 
 29 | func Init() {
 30 | 	fs := js.Global().Get("fs")
 31 | 	constants := fs.Get("constants")
 32 | 	constants.Set("O_RDONLY", syscall.O_RDONLY)
 33 | 	constants.Set("O_WRONLY", syscall.O_WRONLY)
 34 | 	constants.Set("O_RDWR", syscall.O_RDWR)
 35 | 	constants.Set("O_CREAT", syscall.O_CREATE)
 36 | 	constants.Set("O_TRUNC", syscall.O_TRUNC)
 37 | 	constants.Set("O_APPEND", syscall.O_APPEND)
 38 | 	constants.Set("O_EXCL", syscall.O_EXCL)
 39 | 	interop.SetFunc(fs, "chmod", chmod)
 40 | 	interop.SetFunc(fs, "chmodSync", chmodSync)
 41 | 	interop.SetFunc(fs, "chown", chown)
 42 | 	interop.SetFunc(fs, "chownSync", chownSync)
 43 | 	interop.SetFunc(fs, "close", closeFn)
 44 | 	interop.SetFunc(fs, "closeSync", closeSync)
 45 | 	interop.SetFunc(fs, "fchmod", fchmod)
 46 | 	interop.SetFunc(fs, "fchmodSync", fchmodSync)
 47 | 	interop.SetFunc(fs, "flock", flock)
 48 | 	interop.SetFunc(fs, "flockSync", flockSync)
 49 | 	interop.SetFunc(fs, "fstat", fstat)
 50 | 	interop.SetFunc(fs, "fstatSync", fstatSync)
 51 | 	interop.SetFunc(fs, "fsync", fsync)
 52 | 	interop.SetFunc(fs, "fsyncSync", fsyncSync)
 53 | 	interop.SetFunc(fs, "ftruncate", ftruncate)
 54 | 	interop.SetFunc(fs, "ftruncateSync", ftruncateSync)
 55 | 	interop.SetFunc(fs, "lstat", lstat)
 56 | 	interop.SetFunc(fs, "lstatSync", lstatSync)
 57 | 	interop.SetFunc(fs, "mkdir", mkdir)
 58 | 	interop.SetFunc(fs, "mkdirSync", mkdirSync)
 59 | 	interop.SetFunc(fs, "open", open)
 60 | 	interop.SetFunc(fs, "openSync", openSync)
 61 | 	interop.SetFunc(fs, "pipe", pipe)
 62 | 	interop.SetFunc(fs, "pipeSync", pipeSync)
 63 | 	interop.SetFunc(fs, "read", read)
 64 | 	interop.SetFunc(fs, "readSync", readSync)
 65 | 	interop.SetFunc(fs, "readdir", readdir)
 66 | 	interop.SetFunc(fs, "readdirSync", readdirSync)
 67 | 	interop.SetFunc(fs, "rename", rename)
 68 | 	interop.SetFunc(fs, "renameSync", renameSync)
 69 | 	interop.SetFunc(fs, "rmdir", rmdir)
 70 | 	interop.SetFunc(fs, "rmdirSync", rmdirSync)
 71 | 	interop.SetFunc(fs, "stat", stat)
 72 | 	interop.SetFunc(fs, "statSync", statSync)
 73 | 	interop.SetFunc(fs, "unlink", unlink)
 74 | 	interop.SetFunc(fs, "unlinkSync", unlinkSync)
 75 | 	interop.SetFunc(fs, "utimes", utimes)
 76 | 	interop.SetFunc(fs, "utimesSync", utimesSync)
 77 | 	interop.SetFunc(fs, "write", write)
 78 | 	interop.SetFunc(fs, "writeSync", writeSync)
 79 | 
 80 | 	global.Set("getMounts", js.FuncOf(getMounts))
 81 | 	global.Set("destroyMount", js.FuncOf(destroyMount))
 82 | 	global.Set("overlayTarGzip", js.FuncOf(overlayTarGzip))
 83 | 	global.Set("overlayIndexedDB", js.FuncOf(overlayIndexedDB))
 84 | 	global.Set("dumpZip", js.FuncOf(dumpZip))
 85 | 
 86 | 	// Set up system directories
 87 | 	files := process.Current().Files()
 88 | 	if err := files.MkdirAll(os.TempDir(), 0777); err != nil {
 89 | 		panic(err)
 90 | 	}
 91 | }
 92 | 
 93 | func Dump(basePath string) interface{} {
 94 | 	basePath = common.ResolvePath(process.Current().WorkingDirectory(), basePath)
 95 | 	return fs.Dump(basePath)
 96 | }
 97 | 
 98 | func dumpZip(this js.Value, args []js.Value) interface{} {
 99 | 	if len(args) != 1 {
100 | 		return interop.WrapAsJSError(errors.New("dumpZip: file path is required"), "EINVAL")
101 | 	}
102 | 	path := args[0].String()
103 | 	path = common.ResolvePath(process.Current().WorkingDirectory(), path)
104 | 	return interop.WrapAsJSError(fs.DumpZip(path), "dumpZip")
105 | }
106 | 
107 | func getMounts(this js.Value, args []js.Value) interface{} {
108 | 	var mounts []string
109 | 	for _, p := range fs.Mounts() {
110 | 		mounts = append(mounts, p.Path)
111 | 	}
112 | 	return interop.SliceFromStrings(mounts)
113 | }
114 | 
115 | func destroyMount(this js.Value, args []js.Value) interface{} {
116 | 	if len(args) < 1 {
117 | 		return interop.WrapAsJSError(errors.New("destroyMount: mount path is required"), "EINVAL")
118 | 	}
119 | 	resolve, reject, prom := promise.New()
120 | 	mountPath := args[0].String()
121 | 	go func() {
122 | 		jsErr := interop.WrapAsJSError(fs.DestroyMount(mountPath), "destroyMount")
123 | 		if jsErr.Type() != js.TypeNull {
124 | 			reject(jsErr)
125 | 		} else {
126 | 			resolve(nil)
127 | 		}
128 | 	}()
129 | 	return prom
130 | }
131 | 


--------------------------------------------------------------------------------
/internal/js/fs/fstat.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func fstat(args []js.Value) ([]interface{}, error) {
15 | 	info, err := fstatSync(args)
16 | 	return []interface{}{info}, err
17 | }
18 | 
19 | func fstatSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) != 1 {
21 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
22 | 	}
23 | 	fd := fs.FID(args[0].Int())
24 | 	p := process.Current()
25 | 	info, err := p.Files().Fstat(fd)
26 | 	return jsStat(info), err
27 | }
28 | 


--------------------------------------------------------------------------------
/internal/js/fs/fsync.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | // fsync(fd, callback) { callback(null); },
15 | 
16 | func fsync(args []js.Value) ([]interface{}, error) {
17 | 	_, err := fsyncSync(args)
18 | 	return nil, err
19 | }
20 | 
21 | func fsyncSync(args []js.Value) (interface{}, error) {
22 | 	if len(args) != 1 {
23 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
24 | 	}
25 | 	fd := fs.FID(args[0].Int())
26 | 	p := process.Current()
27 | 	return nil, p.Files().Fsync(fd)
28 | }
29 | 


--------------------------------------------------------------------------------
/internal/js/fs/ftruncate.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func ftruncateSync(args []js.Value) (interface{}, error) {
15 | 	_, err := ftruncate(args)
16 | 	return nil, err
17 | }
18 | 
19 | func ftruncate(args []js.Value) ([]interface{}, error) {
20 | 	// args: fd, len
21 | 	if len(args) == 0 {
22 | 		return nil, errors.Errorf("missing required args, expected fd: %+v", args)
23 | 	}
24 | 	fd := fs.FID(args[0].Int())
25 | 	length := 0
26 | 	if len(args) >= 2 {
27 | 		length = args[1].Int()
28 | 	}
29 | 
30 | 	p := process.Current()
31 | 	return nil, p.Files().Truncate(fd, int64(length))
32 | }
33 | 


--------------------------------------------------------------------------------
/internal/js/fs/lstat.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | func lstat(args []js.Value) ([]interface{}, error) {
14 | 	info, err := lstatSync(args)
15 | 	return []interface{}{info}, err
16 | }
17 | 
18 | func lstatSync(args []js.Value) (interface{}, error) {
19 | 	if len(args) != 1 {
20 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
21 | 	}
22 | 	path := args[0].String()
23 | 	p := process.Current()
24 | 	info, err := p.Files().Lstat(path)
25 | 	return jsStat(info), err
26 | }
27 | 


--------------------------------------------------------------------------------
/internal/js/fs/mkdir.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"os"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func mkdir(args []js.Value) ([]interface{}, error) {
15 | 	_, err := mkdirSync(args)
16 | 	return nil, err
17 | }
18 | 
19 | func mkdirSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) != 2 {
21 | 		return nil, errors.Errorf("Invalid number of args, expected 2: %v", args)
22 | 	}
23 | 	path := args[0].String()
24 | 	options := args[1]
25 | 	var mode os.FileMode
26 | 	switch {
27 | 	case options.Type() == js.TypeNumber:
28 | 		mode = os.FileMode(options.Int())
29 | 	case options.Type() == js.TypeObject && options.Get("mode").Truthy():
30 | 		mode = os.FileMode(options.Get("mode").Int())
31 | 	default:
32 | 		mode = 0777
33 | 	}
34 | 	recursive := false
35 | 	if options.Type() == js.TypeObject && options.Get("recursive").Truthy() {
36 | 		recursive = true
37 | 	}
38 | 
39 | 	p := process.Current()
40 | 	if recursive {
41 | 		return nil, p.Files().MkdirAll(path, mode)
42 | 	}
43 | 	return nil, p.Files().Mkdir(path, mode)
44 | }
45 | 


--------------------------------------------------------------------------------
/internal/js/fs/open.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"os"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func open(args []js.Value) ([]interface{}, error) {
15 | 	fd, err := openSync(args)
16 | 	return []interface{}{fd}, err
17 | }
18 | 
19 | func openSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) == 0 {
21 | 		return nil, errors.Errorf("Expected path, received: %v", args)
22 | 	}
23 | 	path := args[0].String()
24 | 	flags := os.O_RDONLY
25 | 	if len(args) >= 2 {
26 | 		flags = args[1].Int()
27 | 	}
28 | 	mode := os.FileMode(0666)
29 | 	if len(args) >= 3 {
30 | 		mode = os.FileMode(args[2].Int())
31 | 	}
32 | 
33 | 	p := process.Current()
34 | 	fd, err := p.Files().Open(path, flags, mode)
35 | 	return fd.JSValue(), err
36 | }
37 | 


--------------------------------------------------------------------------------
/internal/js/fs/overlay.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package fs
  5 | 
  6 | import (
  7 | 	"context"
  8 | 	"errors"
  9 | 	"io"
 10 | 	"net/http"
 11 | 	"net/url"
 12 | 	"path"
 13 | 	"syscall/js"
 14 | 	"time"
 15 | 
 16 | 	"github.com/hack-pad/go-indexeddb/idb"
 17 | 	"github.com/hack-pad/hackpadfs"
 18 | 	"github.com/hack-pad/hackpadfs/indexeddb"
 19 | 	"github.com/machinebox/progress"
 20 | 
 21 | 	"github.com/hack-pad/hackpad/internal/common"
 22 | 	"github.com/hack-pad/hackpad/internal/fs"
 23 | 	"github.com/hack-pad/hackpad/internal/interop"
 24 | 	"github.com/hack-pad/hackpad/internal/log"
 25 | 	"github.com/hack-pad/hackpad/internal/process"
 26 | 	"github.com/hack-pad/hackpad/internal/promise"
 27 | 	"github.com/johnstarich/go/datasize"
 28 | )
 29 | 
 30 | func overlayIndexedDB(this js.Value, args []js.Value) interface{} {
 31 | 	resolve, reject, prom := promise.New()
 32 | 	go func() {
 33 | 		err := OverlayIndexedDB(args)
 34 | 		if err != nil {
 35 | 			reject(interop.WrapAsJSError(err, "Failed overlaying IndexedDB FS"))
 36 | 		} else {
 37 | 			log.Debug("Successfully overlayed IndexedDB FS")
 38 | 			resolve(nil)
 39 | 		}
 40 | 	}()
 41 | 	return prom.JSValue()
 42 | }
 43 | 
 44 | func OverlayIndexedDB(args []js.Value) (err error) {
 45 | 	if len(args) == 0 {
 46 | 		return errors.New("overlayIndexedDB: mount path is required")
 47 | 	}
 48 | 	mountPath := args[0].String()
 49 | 	var options map[string]js.Value
 50 | 	if len(args) >= 2 && args[1].Type() == js.TypeObject {
 51 | 		options = interop.Entries(args[1])
 52 | 	}
 53 | 
 54 | 	durability := idb.DurabilityDefault
 55 | 	if cacheEnabled, ok := options["cache"]; ok && cacheEnabled.Bool() {
 56 | 		durability = idb.DurabilityRelaxed
 57 | 	}
 58 | 
 59 | 	idbFS, err := indexeddb.NewFS(context.Background(), mountPath, indexeddb.Options{
 60 | 		TransactionDurability: durability,
 61 | 	})
 62 | 	if err != nil {
 63 | 		return err
 64 | 	}
 65 | 	return fs.Overlay(mountPath, idbFS)
 66 | }
 67 | 
 68 | func overlayTarGzip(this js.Value, args []js.Value) interface{} {
 69 | 	resolve, reject, prom := promise.New()
 70 | 	log.Debug("Backgrounding overlay request")
 71 | 	go func() {
 72 | 		err := OverlayTarGzip(args)
 73 | 		if err != nil {
 74 | 			reject(interop.WrapAsJSError(err, "Failed overlaying .tar.gz FS"))
 75 | 		} else {
 76 | 			log.Debug("Successfully overlayed .tar.gz FS")
 77 | 			resolve(nil)
 78 | 		}
 79 | 	}()
 80 | 	return prom.JSValue()
 81 | }
 82 | 
 83 | func OverlayTarGzip(args []js.Value) error {
 84 | 	if len(args) < 2 {
 85 | 		return errors.New("overlayTarGzip: mount path and .tar.gz URL path is required")
 86 | 	}
 87 | 
 88 | 	mountPath := args[0].String()
 89 | 	downloadPath := args[1].String()
 90 | 	var options map[string]js.Value
 91 | 	if len(args) >= 3 && args[2].Type() == js.TypeObject {
 92 | 		options = interop.Entries(args[2])
 93 | 	}
 94 | 	log.Debug("Downloading overlay .tar.gz FS: ", downloadPath)
 95 | 	u, err := url.Parse(downloadPath)
 96 | 	if err != nil {
 97 | 		return err
 98 | 	}
 99 | 	// only download from current server, not just any URL
100 | 	resp, err := http.Get(u.Path) // nolint:bodyclose // Body is closed in OverlayTarGzip handler to keep this async
101 | 	if err != nil {
102 | 		return err
103 | 	}
104 | 	log.Debug("Download response received. Reading body...")
105 | 
106 | 	reader := resp.Body
107 | 	if progressCallback := options["progress"]; progressCallback.Type() == js.TypeFunction && resp.ContentLength > 0 {
108 | 		reader = wrapProgress(reader, resp.ContentLength, func(percentage float64) {
109 | 			progressCallback.Invoke(percentage)
110 | 		})
111 | 	}
112 | 	persist := options["persist"].Truthy()
113 | 	shouldCache := func(string, hackpadfs.FileInfo) bool { return true }
114 | 	if options["skipCacheDirs"].Type() == js.TypeObject {
115 | 		skipDirs := make(map[string]bool)
116 | 		for _, d := range interop.StringsFromJSValue(options["skipCacheDirs"]) {
117 | 			skipDirs[common.ResolvePath(process.Current().WorkingDirectory(), d)] = true
118 | 		}
119 | 		maxFileBytes := datasize.Kibibytes(100).Bytes()
120 | 		shouldCache = func(name string, info hackpadfs.FileInfo) bool {
121 | 			return !skipDirs[path.Dir(name)] && info.Size() < maxFileBytes
122 | 		}
123 | 	}
124 | 	return fs.OverlayTarGzip(mountPath, reader, persist, shouldCache)
125 | }
126 | 
127 | func wrapProgress(r io.ReadCloser, contentLength int64, setProgress func(float64)) io.ReadCloser {
128 | 	progressR := progress.NewReader(r)
129 | 	ctx, wrapper := newReadCloseWrapper(progressR, r)
130 | 	go func() {
131 | 		progressChan := progress.NewTicker(context.Background(), progressR, contentLength, 100*time.Millisecond)
132 | 		for p := range progressChan {
133 | 			select {
134 | 			case <-ctx.Done():
135 | 				setProgress(100)
136 | 				return
137 | 			default:
138 | 				setProgress(p.Percent())
139 | 			}
140 | 		}
141 | 	}()
142 | 	return wrapper
143 | }
144 | 
145 | type readCloseWrapper struct {
146 | 	io.Reader
147 | 	closer io.Closer
148 | 	cancel context.CancelFunc
149 | }
150 | 
151 | func newReadCloseWrapper(r io.Reader, closer io.Closer) (context.Context, io.ReadCloser) {
152 | 	ctx, cancel := context.WithCancel(context.Background())
153 | 	return ctx, &readCloseWrapper{
154 | 		Reader: r,
155 | 		closer: closer,
156 | 		cancel: cancel,
157 | 	}
158 | }
159 | 
160 | func (r *readCloseWrapper) Close() error {
161 | 	r.cancel()
162 | 	return r.closer.Close()
163 | }
164 | 


--------------------------------------------------------------------------------
/internal/js/fs/pipe.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | func pipe(args []js.Value) ([]interface{}, error) {
14 | 	fds, err := pipeSync(args)
15 | 	return []interface{}{fds}, err
16 | }
17 | 
18 | func pipeSync(args []js.Value) (interface{}, error) {
19 | 	if len(args) != 0 {
20 | 		return nil, errors.Errorf("Invalid number of args, expected 0: %v", args)
21 | 	}
22 | 	p := process.Current()
23 | 	fds := p.Files().Pipe()
24 | 	return []interface{}{fds[0].JSValue(), fds[1].JSValue()}, nil
25 | }
26 | 


--------------------------------------------------------------------------------
/internal/js/fs/read.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
12 | 	"github.com/pkg/errors"
13 | )
14 | 
15 | func read(args []js.Value) ([]interface{}, error) {
16 | 	n, buf, err := readSyncImpl(args)
17 | 	return []interface{}{n, buf}, err
18 | }
19 | 
20 | func readSync(args []js.Value) (interface{}, error) {
21 | 	n, _, err := readSyncImpl(args)
22 | 	return n, err
23 | }
24 | 
25 | func readSyncImpl(args []js.Value) (int, js.Value, error) {
26 | 	// args: fd, buffer, offset, length, position
27 | 	if len(args) != 5 {
28 | 		return 0, js.Null(), errors.Errorf("missing required args, expected 5: %+v", args)
29 | 	}
30 | 	fd := fs.FID(args[0].Int())
31 | 	buffer, err := idbblob.New(args[1])
32 | 	if err != nil {
33 | 		return 0, js.Null(), err
34 | 	}
35 | 	offset := args[2].Int()
36 | 	length := args[3].Int()
37 | 	var position *int64
38 | 	if args[4].Type() == js.TypeNumber {
39 | 		position = new(int64)
40 | 		*position = int64(args[4].Int())
41 | 	}
42 | 
43 | 	p := process.Current()
44 | 	n, err := p.Files().Read(fd, buffer, offset, length, position)
45 | 	return n, buffer.JSValue(), err
46 | }
47 | 


--------------------------------------------------------------------------------
/internal/js/fs/readdir.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | func readdir(args []js.Value) ([]interface{}, error) {
14 | 	fileNames, err := readdirSync(args)
15 | 	return []interface{}{fileNames}, err
16 | }
17 | 
18 | func readdirSync(args []js.Value) (interface{}, error) {
19 | 	if len(args) != 1 {
20 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
21 | 	}
22 | 	path := args[0].String()
23 | 	p := process.Current()
24 | 	dir, err := p.Files().ReadDir(path)
25 | 	if err != nil {
26 | 		return nil, err
27 | 	}
28 | 	var names []interface{}
29 | 	for _, f := range dir {
30 | 		names = append(names, f.Name())
31 | 	}
32 | 	return names, err
33 | }
34 | 


--------------------------------------------------------------------------------
/internal/js/fs/rename.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | // rename(from, to, callback) { callback(enosys()); },
14 | 
15 | func rename(args []js.Value) ([]interface{}, error) {
16 | 	_, err := renameSync(args)
17 | 	return nil, err
18 | }
19 | 
20 | func renameSync(args []js.Value) (interface{}, error) {
21 | 	if len(args) != 2 {
22 | 		return nil, errors.Errorf("Invalid number of args, expected 2: %v", args)
23 | 	}
24 | 	oldPath := args[0].String()
25 | 	newPath := args[1].String()
26 | 	p := process.Current()
27 | 	return nil, p.Files().Rename(oldPath, newPath)
28 | }
29 | 


--------------------------------------------------------------------------------
/internal/js/fs/rmdir.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | func rmdir(args []js.Value) ([]interface{}, error) {
14 | 	_, err := rmdirSync(args)
15 | 	return nil, err
16 | }
17 | 
18 | func rmdirSync(args []js.Value) (interface{}, error) {
19 | 	if len(args) != 1 {
20 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
21 | 	}
22 | 	path := args[0].String()
23 | 	p := process.Current()
24 | 	return nil, p.Files().RemoveDir(path)
25 | }
26 | 


--------------------------------------------------------------------------------
/internal/js/fs/stat.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package fs
  5 | 
  6 | import (
  7 | 	"os"
  8 | 	"syscall"
  9 | 	"syscall/js"
 10 | 
 11 | 	"github.com/hack-pad/hackpad/internal/process"
 12 | 	"github.com/pkg/errors"
 13 | )
 14 | 
 15 | func stat(args []js.Value) ([]interface{}, error) {
 16 | 	info, err := statSync(args)
 17 | 	return []interface{}{info}, err
 18 | }
 19 | 
 20 | func statSync(args []js.Value) (interface{}, error) {
 21 | 	if len(args) != 1 {
 22 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
 23 | 	}
 24 | 	path := args[0].String()
 25 | 	p := process.Current()
 26 | 	info, err := p.Files().Stat(path)
 27 | 	return jsStat(info), err
 28 | }
 29 | 
 30 | var (
 31 | 	funcTrue = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 32 | 		return true
 33 | 	})
 34 | 	funcFalse = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 35 | 		return false
 36 | 	})
 37 | )
 38 | 
 39 | func jsStat(info os.FileInfo) js.Value {
 40 | 	if info == nil {
 41 | 		return js.Null()
 42 | 	}
 43 | 	const blockSize = 4096 // TODO find useful value for blksize
 44 | 	modTime := info.ModTime().UnixNano() / 1e6
 45 | 	return js.ValueOf(map[string]interface{}{
 46 | 		"dev":     0,
 47 | 		"ino":     0,
 48 | 		"mode":    jsMode(info.Mode()),
 49 | 		"nlink":   1,
 50 | 		"uid":     0, // TODO use real values for uid and gid
 51 | 		"gid":     0,
 52 | 		"rdev":    0,
 53 | 		"size":    info.Size(),
 54 | 		"blksize": blockSize,
 55 | 		"blocks":  blockCount(info.Size(), blockSize),
 56 | 		"atimeMs": modTime,
 57 | 		"mtimeMs": modTime,
 58 | 		"ctimeMs": modTime,
 59 | 
 60 | 		"isBlockDevice":     funcFalse,
 61 | 		"isCharacterDevice": funcFalse,
 62 | 		"isDirectory":       jsBoolFunc(info.IsDir()),
 63 | 		"isFIFO":            funcFalse,
 64 | 		"isFile":            jsBoolFunc(info.Mode().IsRegular()),
 65 | 		"isSocket":          funcFalse,
 66 | 		"isSymbolicLink":    jsBoolFunc(info.Mode()&os.ModeSymlink == os.ModeSymlink),
 67 | 	})
 68 | }
 69 | 
 70 | var modeBitTranslation = map[os.FileMode]uint32{
 71 | 	os.ModeDir:        syscall.S_IFDIR,
 72 | 	os.ModeCharDevice: syscall.S_IFCHR,
 73 | 	os.ModeNamedPipe:  syscall.S_IFIFO,
 74 | 	os.ModeSymlink:    syscall.S_IFLNK,
 75 | 	os.ModeSocket:     syscall.S_IFSOCK,
 76 | }
 77 | 
 78 | func jsMode(mode os.FileMode) uint32 {
 79 | 	for goBit, jsBit := range modeBitTranslation {
 80 | 		if mode&goBit == goBit {
 81 | 			mode = mode & ^goBit | os.FileMode(jsBit)
 82 | 		}
 83 | 	}
 84 | 	return uint32(mode)
 85 | }
 86 | 
 87 | func blockCount(size, blockSize int64) int64 {
 88 | 	blocks := size / blockSize
 89 | 	if size%blockSize > 0 {
 90 | 		return blocks + 1
 91 | 	}
 92 | 	return blocks
 93 | }
 94 | 
 95 | func jsBoolFunc(b bool) js.Func {
 96 | 	if b {
 97 | 		return funcTrue
 98 | 	}
 99 | 	return funcFalse
100 | }
101 | 


--------------------------------------------------------------------------------
/internal/js/fs/unlink.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | // unlink(path, callback) { callback(enosys()); },
14 | 
15 | func unlink(args []js.Value) ([]interface{}, error) {
16 | 	_, err := unlinkSync(args)
17 | 	return nil, err
18 | }
19 | 
20 | func unlinkSync(args []js.Value) (interface{}, error) {
21 | 	if len(args) != 1 {
22 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
23 | 	}
24 | 	path := args[0].String()
25 | 	p := process.Current()
26 | 	return nil, p.Files().Unlink(path)
27 | }
28 | 


--------------------------------------------------------------------------------
/internal/js/fs/utimes.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 	"time"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func utimes(args []js.Value) ([]interface{}, error) {
15 | 	_, err := utimesSync(args)
16 | 	return nil, err
17 | }
18 | 
19 | func utimesSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) != 3 {
21 | 		return nil, errors.Errorf("Invalid number of args, expected 3: %v", args)
22 | 	}
23 | 
24 | 	path := args[0].String()
25 | 	atime := time.Unix(int64(args[1].Int()), 0)
26 | 	mtime := time.Unix(int64(args[2].Int()), 0)
27 | 	p := process.Current()
28 | 	return nil, p.Files().Utimes(path, atime, mtime)
29 | }
30 | 


--------------------------------------------------------------------------------
/internal/js/fs/write.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package fs
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
12 | 	"github.com/pkg/errors"
13 | )
14 | 
15 | func writeSync(args []js.Value) (interface{}, error) {
16 | 	ret, err := write(args)
17 | 	if len(ret) > 1 {
18 | 		return ret[0], err
19 | 	}
20 | 	return ret, err
21 | }
22 | 
23 | func write(args []js.Value) ([]interface{}, error) {
24 | 	// args: fd, buffer, offset, length, position
25 | 	if len(args) < 2 {
26 | 		return nil, errors.Errorf("missing required args, expected fd and buffer: %+v", args)
27 | 	}
28 | 	fd := fs.FID(args[0].Int())
29 | 	buffer, err := idbblob.New(args[1])
30 | 	if err != nil {
31 | 		return nil, err
32 | 	}
33 | 	offset := 0
34 | 	if len(args) >= 3 {
35 | 		offset = args[2].Int()
36 | 	}
37 | 	length := buffer.Len()
38 | 	if len(args) >= 4 {
39 | 		length = args[3].Int()
40 | 	}
41 | 	var position *int64
42 | 	if len(args) >= 5 && args[4].Type() == js.TypeNumber {
43 | 		position = new(int64)
44 | 		*position = int64(args[4].Int())
45 | 	}
46 | 
47 | 	p := process.Current()
48 | 	n, err := p.Files().Write(fd, buffer, offset, length, position)
49 | 	return []interface{}{n, buffer.JSValue()}, err
50 | }
51 | 


--------------------------------------------------------------------------------
/internal/js/process/dir.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/process"
10 | 	"github.com/pkg/errors"
11 | )
12 | 
13 | func cwd(args []js.Value) (interface{}, error) {
14 | 	return process.Current().WorkingDirectory(), nil
15 | }
16 | 
17 | func chdir(args []js.Value) (interface{}, error) {
18 | 	if len(args) == 0 {
19 | 		return nil, errors.New("a new directory argument is required")
20 | 	}
21 | 	p := process.Current()
22 | 	return nil, p.SetWorkingDirectory(args[0].String())
23 | }
24 | 


--------------------------------------------------------------------------------
/internal/js/process/groups.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import "syscall/js"
 7 | 
 8 | const (
 9 | 	userID  = 0
10 | 	groupID = 0
11 | )
12 | 
13 | func geteuid(args []js.Value) (interface{}, error) {
14 | 	return userID, nil
15 | }
16 | 
17 | func getegid(args []js.Value) (interface{}, error) {
18 | 	return groupID, nil
19 | }
20 | 
21 | func getgroups(args []js.Value) (interface{}, error) {
22 | 	return groupID, nil
23 | }
24 | 


--------------------------------------------------------------------------------
/internal/js/process/process.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/interop"
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | )
12 | 
13 | var jsProcess = js.Global().Get("process")
14 | 
15 | func Init() {
16 | 	process.Init(switchedContext)
17 | 
18 | 	currentProcess := process.Current()
19 | 	err := currentProcess.Files().MkdirAll(currentProcess.WorkingDirectory(), 0750)
20 | 	if err != nil {
21 | 		panic(err)
22 | 	}
23 | 	globals := js.Global()
24 | 
25 | 	interop.SetFunc(jsProcess, "getuid", geteuid)
26 | 	interop.SetFunc(jsProcess, "geteuid", geteuid)
27 | 	interop.SetFunc(jsProcess, "getgid", getegid)
28 | 	interop.SetFunc(jsProcess, "getegid", getegid)
29 | 	interop.SetFunc(jsProcess, "getgroups", getgroups)
30 | 	jsProcess.Set("pid", currentProcess.PID().JSValue())
31 | 	jsProcess.Set("ppid", currentProcess.ParentPID().JSValue())
32 | 	interop.SetFunc(jsProcess, "umask", umask)
33 | 	interop.SetFunc(jsProcess, "cwd", cwd)
34 | 	interop.SetFunc(jsProcess, "chdir", chdir)
35 | 
36 | 	globals.Set("child_process", map[string]interface{}{})
37 | 	childProcess := globals.Get("child_process")
38 | 	interop.SetFunc(childProcess, "spawn", spawn)
39 | 	// interop.SetFunc(childProcess, "spawnSync", spawnSync) // TODO is there any way to run spawnSync so we don't hit deadlock?
40 | 	interop.SetFunc(childProcess, "wait", wait)
41 | 	interop.SetFunc(childProcess, "waitSync", waitSync)
42 | }
43 | 
44 | func switchedContext(pid, ppid process.PID) {
45 | 	jsProcess.Set("pid", pid.JSValue())
46 | 	jsProcess.Set("ppid", ppid.JSValue())
47 | }
48 | 
49 | func Dump() interface{} {
50 | 	return process.Dump()
51 | }
52 | 


--------------------------------------------------------------------------------
/internal/js/process/spawn.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/fs"
10 | 	"github.com/hack-pad/hackpad/internal/interop"
11 | 	"github.com/hack-pad/hackpad/internal/process"
12 | 	"github.com/pkg/errors"
13 | )
14 | 
15 | func spawn(args []js.Value) (interface{}, error) {
16 | 	if len(args) == 0 {
17 | 		return nil, errors.Errorf("Invalid number of args, expected command name: %v", args)
18 | 	}
19 | 
20 | 	command := args[0].String()
21 | 	argv := []string{command}
22 | 	if len(args) >= 2 {
23 | 		if args[1].Type() != js.TypeObject || args[1].Get("length").IsUndefined() {
24 | 			return nil, errors.New("Second arg must be an array of arguments")
25 | 		}
26 | 		length := args[1].Length()
27 | 		for i := 0; i < length; i++ {
28 | 			argv = append(argv, args[1].Index(i).String())
29 | 		}
30 | 	}
31 | 
32 | 	procAttr := &process.ProcAttr{}
33 | 	if len(args) >= 3 {
34 | 		argv[0], procAttr = parseProcAttr(command, args[2])
35 | 	}
36 | 	return Spawn(command, argv, procAttr)
37 | }
38 | 
39 | type jsWrapper interface {
40 | 	JSValue() js.Value
41 | }
42 | 
43 | func Spawn(command string, args []string, attr *process.ProcAttr) (js.Value, error) {
44 | 	p, err := process.New(command, args, attr)
45 | 	if err != nil {
46 | 		return js.Value{}, err
47 | 	}
48 | 	return p.(jsWrapper).JSValue(), p.Start()
49 | }
50 | 
51 | func parseProcAttr(defaultCommand string, value js.Value) (argv0 string, attr *process.ProcAttr) {
52 | 	argv0 = defaultCommand
53 | 	attr = &process.ProcAttr{}
54 | 	if dir := value.Get("cwd"); dir.Truthy() {
55 | 		attr.Dir = dir.String()
56 | 	}
57 | 	if env := value.Get("env"); env.Truthy() {
58 | 		attr.Env = make(map[string]string)
59 | 		for name, prop := range interop.Entries(env) {
60 | 			attr.Env[name] = prop.String()
61 | 		}
62 | 	}
63 | 
64 | 	if stdio := value.Get("stdio"); stdio.Truthy() {
65 | 		length := stdio.Length()
66 | 		for i := 0; i < length; i++ {
67 | 			file := stdio.Index(i)
68 | 			switch file.Type() {
69 | 			case js.TypeNumber:
70 | 				fd := fs.FID(file.Int())
71 | 				attr.Files = append(attr.Files, fs.Attr{FID: fd})
72 | 			case js.TypeString:
73 | 				switch file.String() {
74 | 				case "ignore":
75 | 					attr.Files = append(attr.Files, fs.Attr{Ignore: true})
76 | 				case "inherit":
77 | 					attr.Files = append(attr.Files, fs.Attr{FID: fs.FID(i)})
78 | 				case "pipe":
79 | 					attr.Files = append(attr.Files, fs.Attr{Pipe: true})
80 | 				}
81 | 			}
82 | 		}
83 | 	}
84 | 
85 | 	if jsArgv0 := value.Get("argv0"); jsArgv0.Truthy() {
86 | 		argv0 = jsArgv0.String()
87 | 	}
88 | 
89 | 	return
90 | }
91 | 


--------------------------------------------------------------------------------
/internal/js/process/umask.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import "syscall/js"
 7 | 
 8 | var currentUMask = 0755
 9 | 
10 | func umask(args []js.Value) (interface{}, error) {
11 | 	if len(args) == 0 {
12 | 		return currentUMask, nil
13 | 	}
14 | 	oldUMask := currentUMask
15 | 	currentUMask = args[0].Int()
16 | 	return oldUMask, nil
17 | }
18 | 


--------------------------------------------------------------------------------
/internal/js/process/wait.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import (
 7 | 	"syscall"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/process"
11 | 	"github.com/pkg/errors"
12 | )
13 | 
14 | func wait(args []js.Value) ([]interface{}, error) {
15 | 	ret, err := waitSync(args)
16 | 	return []interface{}{ret}, err
17 | }
18 | 
19 | func waitSync(args []js.Value) (interface{}, error) {
20 | 	if len(args) != 1 {
21 | 		return nil, errors.Errorf("Invalid number of args, expected 1: %v", args)
22 | 	}
23 | 	pid := process.PID(args[0].Int())
24 | 	waitStatus := new(syscall.WaitStatus)
25 | 	wpid, err := Wait(pid, waitStatus, 0, nil)
26 | 	return js.ValueOf(map[string]interface{}{
27 | 		"pid":      wpid.JSValue(),
28 | 		"exitCode": waitStatus.ExitStatus(),
29 | 	}), err
30 | }
31 | 
32 | func Wait(pid process.PID, wstatus *syscall.WaitStatus, options int, rusage *syscall.Rusage) (wpid process.PID, err error) {
33 | 	// TODO support options and rusage
34 | 	p, ok := process.Get(pid)
35 | 	if !ok {
36 | 		return 0, errors.Errorf("Unknown child process: %d", pid)
37 | 	}
38 | 
39 | 	exitCode, err := p.Wait()
40 | 	if wstatus != nil {
41 | 		const (
42 | 			// defined in syscall.WaitStatus
43 | 			exitCodeShift = 8
44 | 			exitedMask    = 0x7F
45 | 		)
46 | 		status := 0
47 | 		status |= exitCode << exitCodeShift // exit code
48 | 		status |= exitedMask                // exited
49 | 		*wstatus = syscall.WaitStatus(status)
50 | 	}
51 | 	return pid, err
52 | }
53 | 


--------------------------------------------------------------------------------
/internal/log/js_log.go:
--------------------------------------------------------------------------------
 1 | //go:build js && wasm
 2 | // +build js,wasm
 3 | 
 4 | package log
 5 | 
 6 | import (
 7 | 	"fmt"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/global"
11 | 	"github.com/hack-pad/safejs"
12 | )
13 | 
14 | var (
15 | 	console = safejs.MustGetGlobal("console")
16 | )
17 | 
18 | const logLevelKey = "logLevel"
19 | 
20 | func init() {
21 | 	global.SetDefault(logLevelKey, LevelLog.String())
22 | 	global.SetDefault("setLogLevel", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
23 | 		if len(args) < 1 {
24 | 			return nil
25 | 		}
26 | 		level := args[0].String()
27 | 		SetLevel(parseLevel(level))
28 | 		return logLevel.String()
29 | 	}))
30 | }
31 | 
32 | func SetLevel(level consoleType) {
33 | 	if level.Valid() {
34 | 		logLevel = level
35 | 		global.Set(logLevelKey, logLevel.String())
36 | 	}
37 | }
38 | 
39 | func DebugJSValues(args ...interface{}) int {
40 | 	return logJSValues(LevelDebug, args...)
41 | }
42 | 
43 | func PrintJSValues(args ...interface{}) int {
44 | 	return logJSValues(LevelLog, args...)
45 | }
46 | 
47 | func WarnJSValues(args ...interface{}) int {
48 | 	return logJSValues(LevelWarn, args...)
49 | }
50 | 
51 | func ErrorJSValues(args ...interface{}) int {
52 | 	return logJSValues(LevelError, args...)
53 | }
54 | 
55 | func logJSValues(kind consoleType, args ...interface{}) int {
56 | 	if kind < logLevel {
57 | 		return 0
58 | 	}
59 | 	var jsArgs []interface{}
60 | 	for _, arg := range args {
61 | 		jsArg, err := safejs.ValueOf(arg)
62 | 		if err != nil {
63 | 			jsArg = safejs.Safe(js.ValueOf(fmt.Sprintf("LOGERR(%s: %T %+v)", err, arg, arg)))
64 | 		}
65 | 		jsArgs = append(jsArgs, jsArg)
66 | 	}
67 | 	_, _ = console.Call(kind.String(), jsArgs...)
68 | 	return 0
69 | }
70 | 
71 | func writeLog(c consoleType, s string) {
72 | 	_, _ = console.Call(c.String(), s)
73 | }
74 | 


--------------------------------------------------------------------------------
/internal/log/log.go:
--------------------------------------------------------------------------------
  1 | package log
  2 | 
  3 | import "fmt"
  4 | 
  5 | type consoleType int
  6 | 
  7 | const (
  8 | 	LevelDebug consoleType = iota
  9 | 	LevelLog
 10 | 	LevelWarn
 11 | 	LevelError
 12 | )
 13 | 
 14 | var logLevel = LevelLog
 15 | 
 16 | func (c consoleType) Valid() bool {
 17 | 	switch c {
 18 | 	case LevelDebug, LevelLog, LevelWarn, LevelError:
 19 | 		return true
 20 | 	default:
 21 | 		return false
 22 | 	}
 23 | }
 24 | 
 25 | func (c consoleType) String() string {
 26 | 	switch c {
 27 | 	case LevelDebug:
 28 | 		return "debug"
 29 | 	case LevelWarn:
 30 | 		return "warn"
 31 | 	case LevelError:
 32 | 		return "error"
 33 | 	default:
 34 | 		return "log"
 35 | 	}
 36 | }
 37 | 
 38 | func parseLevel(level string) consoleType {
 39 | 	switch level {
 40 | 	case LevelDebug.String():
 41 | 		return LevelDebug
 42 | 	case LevelLog.String():
 43 | 		return LevelLog
 44 | 	case LevelWarn.String():
 45 | 		return LevelWarn
 46 | 	case LevelError.String():
 47 | 		return LevelError
 48 | 	default:
 49 | 		return -1
 50 | 	}
 51 | }
 52 | 
 53 | func Debugf(format string, args ...interface{}) int {
 54 | 	return logf(LevelDebug, format, args...)
 55 | }
 56 | 
 57 | func Printf(format string, args ...interface{}) int {
 58 | 	return logf(LevelLog, format, args...)
 59 | }
 60 | 
 61 | func Warnf(format string, args ...interface{}) int {
 62 | 	return logf(LevelWarn, format, args...)
 63 | }
 64 | 
 65 | func Errorf(format string, args ...interface{}) int {
 66 | 	return logf(LevelError, format, args...)
 67 | }
 68 | 
 69 | func logf(kind consoleType, format string, args ...interface{}) int {
 70 | 	if kind < logLevel {
 71 | 		return 0
 72 | 	}
 73 | 	s := fmt.Sprintf(format, args...)
 74 | 	writeLog(kind, s)
 75 | 	return len(s)
 76 | }
 77 | 
 78 | func Debug(args ...interface{}) int {
 79 | 	return log(LevelDebug, args...)
 80 | }
 81 | 
 82 | func Print(args ...interface{}) int {
 83 | 	return log(LevelLog, args...)
 84 | }
 85 | 
 86 | func Warn(args ...interface{}) int {
 87 | 	return log(LevelWarn, args...)
 88 | }
 89 | 
 90 | func Error(args ...interface{}) int {
 91 | 	return log(LevelError, args...)
 92 | }
 93 | 
 94 | func log(kind consoleType, args ...interface{}) int {
 95 | 	if kind < logLevel {
 96 | 		return 0
 97 | 	}
 98 | 	s := fmt.Sprint(args...)
 99 | 	writeLog(kind, s)
100 | 	return len(s)
101 | }
102 | 


--------------------------------------------------------------------------------
/internal/log/other_log.go:
--------------------------------------------------------------------------------
 1 | //go:build !js
 2 | // +build !js
 3 | 
 4 | package log
 5 | 
 6 | import (
 7 | 	"fmt"
 8 | 	"os"
 9 | )
10 | 
11 | func writeLog(c consoleType, s string) {
12 | 	if os.Getenv("DEBUG") == "true" {
13 | 		fmt.Fprintf(os.Stderr, "%s: %s\n", c.String(), s)
14 | 	}
15 | }
16 | 


--------------------------------------------------------------------------------
/internal/process/attr.go:
--------------------------------------------------------------------------------
 1 | package process
 2 | 
 3 | import (
 4 | 	"github.com/hack-pad/hackpad/internal/fs"
 5 | )
 6 | 
 7 | // ProcAttr is functionally identical to os.ProcAttr.
 8 | // Env is structured as a map (instead of key=value pairs), and files is purely a list of nil-able file descriptor IDs. nil FIDs are to be effectively closed to the new process.
 9 | type ProcAttr struct {
10 | 	Dir   string
11 | 	Env   map[string]string
12 | 	Files []fs.Attr
13 | }
14 | 


--------------------------------------------------------------------------------
/internal/process/context.go:
--------------------------------------------------------------------------------
 1 | package process
 2 | 
 3 | import (
 4 | 	"strings"
 5 | 	"syscall"
 6 | 
 7 | 	"github.com/hack-pad/hackpad/internal/fs"
 8 | 	"github.com/hack-pad/hackpad/internal/log"
 9 | )
10 | 
11 | const initialDirectory = "/home/me"
12 | 
13 | var (
14 | 	currentPID PID
15 | 
16 | 	switchedContextListener func(newPID, parentPID PID)
17 | )
18 | 
19 | func Init(switchedContext func(PID, PID)) {
20 | 	// create 'init' process
21 | 	fileDescriptors, err := fs.NewStdFileDescriptors(minPID, initialDirectory)
22 | 	if err != nil {
23 | 		panic(err)
24 | 	}
25 | 	p, err := newWithCurrent(
26 | 		&process{fileDescriptors: fileDescriptors},
27 | 		minPID,
28 | 		"",
29 | 		nil,
30 | 		&ProcAttr{Env: splitEnvPairs(syscall.Environ())},
31 | 	)
32 | 	if err != nil {
33 | 		panic(err)
34 | 	}
35 | 	p.state = stateRunning
36 | 	pids[minPID] = p
37 | 
38 | 	switchedContextListener = switchedContext
39 | 	switchContext(minPID)
40 | }
41 | 
42 | func switchContext(pid PID) (prev PID) {
43 | 	prev = currentPID
44 | 	log.Debug("Switching context from PID ", prev, " to ", pid)
45 | 	if pid == prev {
46 | 		return
47 | 	}
48 | 	newProcess := pids[pid]
49 | 	currentPID = pid
50 | 	switchedContextListener(pid, newProcess.parentPID)
51 | 	return
52 | }
53 | 
54 | func Current() Process {
55 | 	process, _ := Get(currentPID)
56 | 	return process
57 | }
58 | 
59 | func Get(pid PID) (process Process, ok bool) {
60 | 	p, ok := pids[pid]
61 | 	return p, ok
62 | }
63 | 
64 | func splitEnvPairs(pairs []string) map[string]string {
65 | 	env := make(map[string]string)
66 | 	for _, pair := range pairs {
67 | 		equalIndex := strings.IndexRune(pair, '=')
68 | 		if equalIndex == -1 {
69 | 			env[pair] = ""
70 | 		} else {
71 | 			key, value := pair[:equalIndex], pair[equalIndex+1:]
72 | 			env[key] = value
73 | 		}
74 | 	}
75 | 	return env
76 | }
77 | 


--------------------------------------------------------------------------------
/internal/process/lookpath.go:
--------------------------------------------------------------------------------
 1 | package process
 2 | 
 3 | import (
 4 | 	"os"
 5 | 	"os/exec"
 6 | 	"path/filepath"
 7 | 	"strings"
 8 | )
 9 | 
10 | type stater func(string) (os.FileInfo, error)
11 | 
12 | func lookPath(stat stater, pathVar string, file string) (string, error) {
13 | 	if strings.Contains(file, "/") {
14 | 		err := findExecutable(stat, file)
15 | 		if err == nil {
16 | 			return file, nil
17 | 		}
18 | 		return "", &exec.Error{Name: file, Err: err}
19 | 	}
20 | 	for _, dir := range filepath.SplitList(pathVar) {
21 | 		if dir == "" {
22 | 			dir = "."
23 | 		}
24 | 		path := filepath.Join(dir, file)
25 | 		if err := findExecutable(stat, path); err == nil {
26 | 			return path, nil
27 | 		}
28 | 	}
29 | 	return "", &exec.Error{Name: file, Err: exec.ErrNotFound}
30 | }
31 | 
32 | func findExecutable(stat stater, file string) error {
33 | 	d, err := stat(file)
34 | 	if err != nil {
35 | 		return err
36 | 	}
37 | 	if m := d.Mode(); !m.IsDir() && m&0111 != 0 {
38 | 		return nil
39 | 	}
40 | 	return os.ErrPermission
41 | }
42 | 


--------------------------------------------------------------------------------
/internal/process/process.go:
--------------------------------------------------------------------------------
  1 | package process
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"os"
  7 | 	"sort"
  8 | 	"strings"
  9 | 
 10 | 	"github.com/hack-pad/hackpad/internal/common"
 11 | 	"github.com/hack-pad/hackpad/internal/fs"
 12 | 	"github.com/hack-pad/hackpad/internal/log"
 13 | 	"github.com/hack-pad/hackpadfs/keyvalue/blob"
 14 | 	"github.com/pkg/errors"
 15 | 	"go.uber.org/atomic"
 16 | )
 17 | 
 18 | const (
 19 | 	minPID = 1
 20 | )
 21 | 
 22 | type PID = common.PID
 23 | 
 24 | type processState string
 25 | 
 26 | const (
 27 | 	statePending   processState = "pending"
 28 | 	stateCompiling processState = "compiling wasm"
 29 | 	stateRunning   processState = "running"
 30 | 	stateDone      processState = "done"
 31 | 	stateError     processState = "error"
 32 | )
 33 | 
 34 | var (
 35 | 	pids    = make(map[PID]*process)
 36 | 	lastPID = atomic.NewUint64(minPID)
 37 | )
 38 | 
 39 | type Process interface {
 40 | 	PID() PID
 41 | 	ParentPID() PID
 42 | 
 43 | 	Start() error
 44 | 	Wait() (exitCode int, err error)
 45 | 	Files() *fs.FileDescriptors
 46 | 	WorkingDirectory() string
 47 | 	SetWorkingDirectory(wd string) error
 48 | }
 49 | 
 50 | type process struct {
 51 | 	pid, parentPID  PID
 52 | 	command         string
 53 | 	args            []string
 54 | 	state           processState
 55 | 	attr            *ProcAttr
 56 | 	ctx             context.Context
 57 | 	ctxDone         context.CancelFunc
 58 | 	exitCode        int
 59 | 	err             error
 60 | 	fileDescriptors *fs.FileDescriptors
 61 | 	setFilesWD      func(wd string) error
 62 | }
 63 | 
 64 | func New(command string, args []string, attr *ProcAttr) (Process, error) {
 65 | 	return newWithCurrent(Current(), PID(lastPID.Inc()), command, args, attr)
 66 | }
 67 | 
 68 | func newWithCurrent(current Process, newPID PID, command string, args []string, attr *ProcAttr) (*process, error) {
 69 | 	wd := current.WorkingDirectory()
 70 | 	if attr.Dir != "" {
 71 | 		wd = attr.Dir
 72 | 	}
 73 | 	files, setFilesWD, err := fs.NewFileDescriptors(newPID, wd, current.Files(), attr.Files)
 74 | 	ctx, cancel := context.WithCancel(context.Background())
 75 | 	return &process{
 76 | 		pid:             newPID,
 77 | 		command:         command,
 78 | 		args:            args,
 79 | 		state:           statePending,
 80 | 		attr:            attr,
 81 | 		ctx:             ctx,
 82 | 		ctxDone:         cancel,
 83 | 		err:             err,
 84 | 		fileDescriptors: files,
 85 | 		setFilesWD:      setFilesWD,
 86 | 	}, err
 87 | }
 88 | 
 89 | func (p *process) PID() PID {
 90 | 	return p.pid
 91 | }
 92 | 
 93 | func (p *process) ParentPID() PID {
 94 | 	return p.parentPID
 95 | }
 96 | 
 97 | func (p *process) Files() *fs.FileDescriptors {
 98 | 	return p.fileDescriptors
 99 | }
100 | 
101 | func (p *process) Start() error {
102 | 	err := p.start()
103 | 	if p.err == nil {
104 | 		p.err = err
105 | 	}
106 | 	return p.err
107 | }
108 | 
109 | func (p *process) start() error {
110 | 	pids[p.pid] = p
111 | 	log.Debugf("Spawning process: %v", p)
112 | 	go func() {
113 | 		command, err := p.prepExecutable()
114 | 		if err != nil {
115 | 			p.handleErr(err)
116 | 			return
117 | 		}
118 | 		p.run(command)
119 | 	}()
120 | 	return nil
121 | }
122 | 
123 | func (p *process) prepExecutable() (command string, err error) {
124 | 	fs := p.Files()
125 | 	command, err = lookPath(fs.Stat, os.Getenv("PATH"), p.command)
126 | 	if err != nil {
127 | 		return "", err
128 | 	}
129 | 	fid, err := fs.Open(command, 0, 0)
130 | 	if err != nil {
131 | 		return "", err
132 | 	}
133 | 	defer fs.Close(fid)
134 | 	buf := blob.NewBytesLength(4)
135 | 	_, err = fs.Read(fid, buf, 0, buf.Len(), nil)
136 | 	if err != nil {
137 | 		return "", err
138 | 	}
139 | 	magicNumber := string(buf.Bytes())
140 | 	if magicNumber != "\x00asm" {
141 | 		return "", errors.Errorf("Format error. Expected Wasm file header but found: %q", magicNumber)
142 | 	}
143 | 	return command, nil
144 | }
145 | 
146 | func (p *process) Done() {
147 | 	log.Debug("PID ", p.pid, " is done.\n", p.fileDescriptors)
148 | 	p.fileDescriptors.CloseAll()
149 | 	p.ctxDone()
150 | }
151 | 
152 | func (p *process) handleErr(err error) {
153 | 	p.state = stateDone
154 | 	if err != nil {
155 | 		log.Errorf("Failed to start process: %s", err.Error())
156 | 		p.err = err
157 | 		p.state = stateError
158 | 	}
159 | 	p.Done()
160 | }
161 | 
162 | func (p *process) Wait() (exitCode int, err error) {
163 | 	<-p.ctx.Done()
164 | 	return p.exitCode, p.err
165 | }
166 | 
167 | func (p *process) WorkingDirectory() string {
168 | 	return p.Files().WorkingDirectory()
169 | }
170 | 
171 | func (p *process) SetWorkingDirectory(wd string) error {
172 | 	return p.setFilesWD(wd)
173 | }
174 | 
175 | func (p *process) String() string {
176 | 	return fmt.Sprintf("PID=%s, Command=%v, State=%s, WD=%s, Attr=%+v, Err=%+v, Files:\n%v", p.pid, p.args, p.state, p.WorkingDirectory(), p.attr, p.err, p.fileDescriptors)
177 | }
178 | 
179 | func Dump() interface{} {
180 | 	var s strings.Builder
181 | 	var pidSlice []PID
182 | 	for pid := range pids {
183 | 		pidSlice = append(pidSlice, pid)
184 | 	}
185 | 	sort.Slice(pidSlice, func(a, b int) bool {
186 | 		return pidSlice[a] < pidSlice[b]
187 | 	})
188 | 	for _, pid := range pidSlice {
189 | 		s.WriteString(pids[pid].String() + "\n")
190 | 	}
191 | 	return s.String()
192 | }
193 | 


--------------------------------------------------------------------------------
/internal/process/process_js.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package process
 5 | 
 6 | import (
 7 | 	"syscall/js"
 8 | 
 9 | 	"github.com/hack-pad/hackpad/internal/interop"
10 | )
11 | 
12 | var (
13 | 	jsGo = js.Global().Get("Go")
14 | )
15 | 
16 | func (p *process) JSValue() js.Value {
17 | 	return js.ValueOf(map[string]interface{}{
18 | 		"pid":   p.pid.JSValue(),
19 | 		"ppid":  p.parentPID.JSValue(),
20 | 		"error": interop.WrapAsJSError(p.err, "spawn"),
21 | 	})
22 | }
23 | 
24 | func (p *process) StartCPUProfile() error {
25 | 	return interop.StartCPUProfile(p.ctx)
26 | }
27 | 


--------------------------------------------------------------------------------
/internal/process/process_other.go:
--------------------------------------------------------------------------------
 1 | //go:build !js
 2 | // +build !js
 3 | 
 4 | package process
 5 | 
 6 | import (
 7 | 	"fmt"
 8 | 	"os"
 9 | 	"os/exec"
10 | )
11 | 
12 | func (p *process) run(path string) {
13 | 	cmd := exec.Command(path, p.args...)
14 | 	if p.attr.Env == nil {
15 | 		cmd.Env = os.Environ()
16 | 		p.attr.Env = splitEnvPairs(cmd.Env)
17 | 	} else {
18 | 		for k, v := range p.attr.Env {
19 | 			cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
20 | 		}
21 | 	}
22 | 
23 | 	p.state = stateRunning
24 | 	prev := switchContext(p.pid)
25 | 	err := cmd.Run()
26 | 	switchContext(prev)
27 | 	p.exitCode = cmd.ProcessState.ExitCode()
28 | 	p.handleErr(err)
29 | }
30 | 


--------------------------------------------------------------------------------
/internal/process/wasm.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package process
  5 | 
  6 | import (
  7 | 	"os"
  8 | 	"runtime"
  9 | 	"syscall/js"
 10 | 
 11 | 	"github.com/hack-pad/hackpad/internal/interop"
 12 | 	"github.com/hack-pad/hackpad/internal/log"
 13 | 	"github.com/hack-pad/hackpad/internal/promise"
 14 | )
 15 | 
 16 | var (
 17 | 	jsObject = js.Global().Get("Object")
 18 | )
 19 | 
 20 | func (p *process) newWasmInstance(path string, importObject js.Value) (js.Value, error) {
 21 | 	return p.Files().WasmInstance(path, importObject)
 22 | }
 23 | 
 24 | func (p *process) run(path string) {
 25 | 	defer func() {
 26 | 		go runtime.GC()
 27 | 	}()
 28 | 
 29 | 	exitChan := make(chan int, 1)
 30 | 	runPromise, err := p.startWasmPromise(path, exitChan)
 31 | 	if err != nil {
 32 | 		p.handleErr(err)
 33 | 		return
 34 | 	}
 35 | 	_, err = runPromise.Await()
 36 | 	p.exitCode = <-exitChan
 37 | 	p.handleErr(err)
 38 | }
 39 | 
 40 | func (p *process) startWasmPromise(path string, exitChan chan<- int) (promise.Promise, error) {
 41 | 	p.state = stateCompiling
 42 | 	goInstance := jsGo.New()
 43 | 	goInstance.Set("argv", interop.SliceFromStrings(p.args))
 44 | 	if p.attr.Env == nil {
 45 | 		p.attr.Env = splitEnvPairs(os.Environ())
 46 | 	}
 47 | 	goInstance.Set("env", interop.StringMap(p.attr.Env))
 48 | 	var resumeFuncPtr *js.Func
 49 | 	goInstance.Set("exit", interop.SingleUseFunc(func(this js.Value, args []js.Value) interface{} {
 50 | 		defer func() {
 51 | 			if resumeFuncPtr != nil {
 52 | 				resumeFuncPtr.Release()
 53 | 			}
 54 | 			// TODO free the whole goInstance to fix garbage issues entirely. Freeing individual properties appears to work for now, but is ultimately a bad long-term solution because memory still accumulates.
 55 | 			goInstance.Set("mem", js.Null())
 56 | 			goInstance.Set("importObject", js.Null())
 57 | 		}()
 58 | 		if len(args) == 0 {
 59 | 			exitChan <- -1
 60 | 			return nil
 61 | 		}
 62 | 		code := args[0].Int()
 63 | 		exitChan <- code
 64 | 		if code != 0 {
 65 | 			log.Warnf("Process exited with code %d: %s", code, p)
 66 | 		}
 67 | 		return nil
 68 | 	}))
 69 | 	importObject := goInstance.Get("importObject")
 70 | 
 71 | 	instance, err := p.newWasmInstance(path, importObject)
 72 | 	if err != nil {
 73 | 		return nil, err
 74 | 	}
 75 | 
 76 | 	exports := instance.Get("exports")
 77 | 
 78 | 	resumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 79 | 		defer interop.PanicLogger()
 80 | 		prev := switchContext(p.pid)
 81 | 		ret := exports.Call("resume", interop.SliceFromJSValues(args)...)
 82 | 		switchContext(prev)
 83 | 		return ret
 84 | 	})
 85 | 	resumeFuncPtr = &resumeFunc
 86 | 	wrapperExports := map[string]interface{}{
 87 | 		"run": interop.SingleUseFunc(func(this js.Value, args []js.Value) interface{} {
 88 | 			defer interop.PanicLogger()
 89 | 			prev := switchContext(p.pid)
 90 | 			ret := exports.Call("run", interop.SliceFromJSValues(args)...)
 91 | 			switchContext(prev)
 92 | 			return ret
 93 | 		}),
 94 | 		"resume": resumeFunc,
 95 | 	}
 96 | 	for export, value := range interop.Entries(exports) {
 97 | 		_, overridden := wrapperExports[export]
 98 | 		if !overridden {
 99 | 			wrapperExports[export] = value
100 | 		}
101 | 	}
102 | 	wrapperInstance := jsObject.Call("defineProperty",
103 | 		jsObject.Call("create", instance),
104 | 		"exports", map[string]interface{}{ // Instance.exports is read-only, so create a shim
105 | 			"value":    wrapperExports,
106 | 			"writable": false,
107 | 		},
108 | 	)
109 | 
110 | 	p.state = stateRunning
111 | 	return promise.From(goInstance.Call("run", wrapperInstance)), nil
112 | }
113 | 


--------------------------------------------------------------------------------
/internal/promise/chan.go:
--------------------------------------------------------------------------------
 1 | package promise
 2 | 
 3 | import (
 4 | 	"github.com/pkg/errors"
 5 | )
 6 | 
 7 | type Go struct {
 8 | 	resolveChan, rejectChan <-chan interface{}
 9 | }
10 | 
11 | func NewGo() (resolve, reject Resolver, promise Go) {
12 | 	resolveChan, rejectChan := make(chan interface{}, 1), make(chan interface{}, 1)
13 | 	var p Go
14 | 	p.resolveChan, p.rejectChan = resolveChan, rejectChan
15 | 
16 | 	resolve = func(result interface{}) {
17 | 		resolveChan <- result
18 | 		close(resolveChan)
19 | 		close(rejectChan)
20 | 	}
21 | 	reject = func(result interface{}) {
22 | 		rejectChan <- result
23 | 		close(resolveChan)
24 | 		close(rejectChan)
25 | 	}
26 | 	return resolve, reject, p
27 | }
28 | 
29 | func (p Go) Then(fn func(value interface{}) interface{}) Promise {
30 | 	// TODO support failing a Then call
31 | 	resolve, _, prom := NewGo()
32 | 	go func() {
33 | 		value, ok := <-p.resolveChan
34 | 		if ok {
35 | 			newValue := fn(value)
36 | 			resolve(newValue)
37 | 		}
38 | 	}()
39 | 	return prom
40 | }
41 | 
42 | func (p Go) Catch(fn func(rejectedReason interface{}) interface{}) Promise {
43 | 	_, reject, prom := NewGo()
44 | 	go func() {
45 | 		reason, ok := <-p.rejectChan
46 | 		if ok {
47 | 			newReason := fn(reason)
48 | 			reject(newReason)
49 | 		}
50 | 	}()
51 | 	return prom
52 | }
53 | 
54 | func (p Go) Await() (interface{}, error) {
55 | 	// TODO support error handling inside promise functions instead
56 | 	value := <-p.resolveChan
57 | 	switch err := (<-p.rejectChan).(type) {
58 | 	case nil:
59 | 		return value, nil
60 | 	case error:
61 | 		return value, err
62 | 	default:
63 | 		return value, errors.Errorf("%v", err)
64 | 	}
65 | }
66 | 


--------------------------------------------------------------------------------
/internal/promise/js.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package promise
 5 | 
 6 | import (
 7 | 	"runtime/debug"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/interop"
11 | 	"github.com/hack-pad/hackpad/internal/log"
12 | )
13 | 
14 | var jsPromise = js.Global().Get("Promise")
15 | 
16 | type JS struct {
17 | 	value js.Value
18 | }
19 | 
20 | func From(promiseValue js.Value) JS {
21 | 	return JS{value: promiseValue}
22 | }
23 | 
24 | func New() (resolve, reject Resolver, promise JS) {
25 | 	resolvers := make(chan Resolver, 2)
26 | 	promise = From(
27 | 		jsPromise.New(interop.SingleUseFunc(func(this js.Value, args []js.Value) interface{} {
28 | 			resolve, reject := args[0], args[1]
29 | 			resolvers <- func(result interface{}) { resolve.Invoke(result) }
30 | 			resolvers <- func(result interface{}) { reject.Invoke(result) }
31 | 			return nil
32 | 		})),
33 | 	)
34 | 	resolve, reject = <-resolvers, <-resolvers
35 | 	return
36 | }
37 | 
38 | func (p JS) Then(fn func(value interface{}) interface{}) Promise {
39 | 	return p.do("then", fn)
40 | }
41 | 
42 | func (p JS) do(methodName string, fn func(value interface{}) interface{}) Promise {
43 | 	return JS{
44 | 		value: p.value.Call(methodName, interop.SingleUseFunc(func(this js.Value, args []js.Value) interface{} {
45 | 			var value js.Value
46 | 			if len(args) > 0 {
47 | 				value = args[0]
48 | 			}
49 | 			return fn(value)
50 | 		})),
51 | 	}
52 | }
53 | 
54 | func (p JS) Catch(fn func(rejectedReason interface{}) interface{}) Promise {
55 | 	stack := string(debug.Stack())
56 | 	return p.do("catch", func(rejectedReason interface{}) interface{} {
57 | 		log.ErrorJSValues(
58 | 			js.ValueOf("Promise rejected:"),
59 | 			rejectedReason,
60 | 			js.ValueOf(stack),
61 | 		)
62 | 		return fn(rejectedReason)
63 | 	})
64 | }
65 | 
66 | func (p JS) Await() (interface{}, error) {
67 | 	errs := make(chan error, 1)
68 | 	results := make(chan js.Value, 1)
69 | 	p.Then(func(value interface{}) interface{} {
70 | 		results <- value.(js.Value)
71 | 		close(results)
72 | 		return nil
73 | 	}).Catch(func(rejectedReason interface{}) interface{} {
74 | 		err := js.Error{Value: rejectedReason.(js.Value)}
75 | 		errs <- err
76 | 		close(errs)
77 | 		return nil
78 | 	})
79 | 	select {
80 | 	case err := <-errs:
81 | 		return js.Null(), err
82 | 	case result := <-results:
83 | 		return result, nil
84 | 	}
85 | }
86 | 
87 | func (p JS) JSValue() js.Value {
88 | 	return p.value
89 | }
90 | 


--------------------------------------------------------------------------------
/internal/promise/promise.go:
--------------------------------------------------------------------------------
1 | package promise
2 | 
3 | type Promise interface {
4 | 	Then(fn func(value interface{}) interface{}) Promise
5 | 	Catch(fn func(value interface{}) interface{}) Promise
6 | 	Await() (interface{}, error)
7 | }
8 | 


--------------------------------------------------------------------------------
/internal/promise/resolver.go:
--------------------------------------------------------------------------------
1 | package promise
2 | 
3 | type Resolver func(interface{})
4 | 


--------------------------------------------------------------------------------
/internal/terminal/term.go:
--------------------------------------------------------------------------------
  1 | //go:build js
  2 | // +build js
  3 | 
  4 | package terminal
  5 | 
  6 | import (
  7 | 	"syscall/js"
  8 | 
  9 | 	"github.com/hack-pad/hackpad/internal/fs"
 10 | 	"github.com/hack-pad/hackpad/internal/interop"
 11 | 	"github.com/hack-pad/hackpad/internal/log"
 12 | 	"github.com/hack-pad/hackpad/internal/process"
 13 | 	"github.com/hack-pad/hackpadfs/indexeddb/idbblob"
 14 | 	"github.com/pkg/errors"
 15 | )
 16 | 
 17 | func SpawnTerminal(this js.Value, args []js.Value) interface{} {
 18 | 	go func() {
 19 | 		defer func() {
 20 | 			if r := recover(); r != nil {
 21 | 				log.Error("Recovered from panic:", r)
 22 | 			}
 23 | 		}()
 24 | 		err := Open(args)
 25 | 		if err != nil {
 26 | 			log.Error(err)
 27 | 		}
 28 | 	}()
 29 | 	return nil
 30 | }
 31 | 
 32 | func Open(args []js.Value) error {
 33 | 	if len(args) != 2 {
 34 | 		return errors.New("Invalid number of args for spawnTerminal. Expected 2: term, options")
 35 | 	}
 36 | 	term := args[0]
 37 | 	options := args[1]
 38 | 	if options.Type() != js.TypeObject {
 39 | 		return errors.Errorf("Invalid type for options: %s", options.Type())
 40 | 	}
 41 | 	var procArgs []string
 42 | 	if args := options.Get("args"); args.Truthy() {
 43 | 		procArgs = interop.StringsFromJSValue(args)
 44 | 	}
 45 | 	if len(procArgs) < 1 {
 46 | 		return errors.New("options.args must have at least one argument")
 47 | 	}
 48 | 
 49 | 	workingDirectory := ""
 50 | 	if wd := options.Get("cwd"); wd.Truthy() {
 51 | 		workingDirectory = wd.String()
 52 | 	}
 53 | 
 54 | 	files := process.Current().Files()
 55 | 	stdinR, stdinW := pipe(files)
 56 | 	stdoutR, stdoutW := pipe(files)
 57 | 	stderrR, stderrW := pipe(files)
 58 | 
 59 | 	proc, err := process.New(procArgs[0], procArgs, &process.ProcAttr{
 60 | 		Dir: workingDirectory,
 61 | 		Files: []fs.Attr{
 62 | 			{FID: stdinR},
 63 | 			{FID: stdoutW},
 64 | 			{FID: stderrW},
 65 | 		},
 66 | 	})
 67 | 	if err != nil {
 68 | 		return err
 69 | 	}
 70 | 	err = proc.Start()
 71 | 	if err != nil {
 72 | 		return err
 73 | 	}
 74 | 
 75 | 	f := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
 76 | 		chunk, err := idbblob.New(args[0])
 77 | 		if err != nil {
 78 | 			log.Error("blob: Failed to write to terminal:", err)
 79 | 			return nil
 80 | 		}
 81 | 		_, err = files.Write(stdinW, chunk, 0, chunk.Len(), nil)
 82 | 		if err != nil {
 83 | 			log.Error("write: Failed to write to terminal:", err)
 84 | 		}
 85 | 		return nil
 86 | 	})
 87 | 	go func() {
 88 | 		_, _ = proc.Wait()
 89 | 		f.Release()
 90 | 	}()
 91 | 	term.Call("onData", f)
 92 | 	go readOutputPipes(term, files, stdoutR)
 93 | 	go readOutputPipes(term, files, stderrR)
 94 | 	return nil
 95 | }
 96 | 
 97 | func pipe(files *fs.FileDescriptors) (r, w fs.FID) {
 98 | 	p := files.Pipe()
 99 | 	return p[0], p[1]
100 | }
101 | 
102 | func readOutputPipes(term js.Value, files *fs.FileDescriptors, output fs.FID) {
103 | 	buf, err := idbblob.NewLength(1)
104 | 	if err != nil {
105 | 		panic(err)
106 | 	}
107 | 	for {
108 | 		_, err := files.Read(output, buf, 0, buf.Len(), nil)
109 | 		if err != nil {
110 | 			log.Error("Failed to write to terminal:", err)
111 | 		} else {
112 | 			term.Call("write", buf)
113 | 		}
114 | 	}
115 | }
116 | 


--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
 1 | //go:build js
 2 | // +build js
 3 | 
 4 | package main
 5 | 
 6 | import (
 7 | 	"path/filepath"
 8 | 	"syscall/js"
 9 | 
10 | 	"github.com/hack-pad/hackpad/internal/global"
11 | 	"github.com/hack-pad/hackpad/internal/interop"
12 | 	"github.com/hack-pad/hackpad/internal/js/fs"
13 | 	"github.com/hack-pad/hackpad/internal/js/process"
14 | 	"github.com/hack-pad/hackpad/internal/log"
15 | 	libProcess "github.com/hack-pad/hackpad/internal/process"
16 | 	"github.com/hack-pad/hackpad/internal/terminal"
17 | )
18 | 
19 | func main() {
20 | 	process.Init()
21 | 	fs.Init()
22 | 	global.Set("spawnTerminal", js.FuncOf(terminal.SpawnTerminal))
23 | 	global.Set("dump", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
24 | 		go func() {
25 | 			basePath := ""
26 | 			if len(args) >= 1 {
27 | 				basePath = args[0].String()
28 | 				if filepath.IsAbs(basePath) {
29 | 					basePath = filepath.Clean(basePath)
30 | 				} else {
31 | 					basePath = filepath.Join(libProcess.Current().WorkingDirectory(), basePath)
32 | 				}
33 | 			}
34 | 			var fsDump interface{}
35 | 			if basePath != "" {
36 | 				fsDump = fs.Dump(basePath)
37 | 			}
38 | 			log.Error("Process:\n", process.Dump(), "\n\nFiles:\n", fsDump)
39 | 		}()
40 | 		return nil
41 | 	}))
42 | 	global.Set("profile", js.FuncOf(interop.ProfileJS))
43 | 	global.Set("install", js.FuncOf(installFunc))
44 | 	interop.SetInitialized()
45 | 	select {}
46 | }
47 | 


--------------------------------------------------------------------------------
/server/.gitignore:
--------------------------------------------------------------------------------
 1 | /public/wasm
 2 | 
 3 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
 4 | 
 5 | # dependencies
 6 | /node_modules
 7 | /.pnp
 8 | .pnp.js
 9 | 
10 | # testing
11 | /coverage
12 | 
13 | # production
14 | /build
15 | 
16 | # misc
17 | .DS_Store
18 | .env.local
19 | .env.development.local
20 | .env.test.local
21 | .env.production.local
22 | 
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | 


--------------------------------------------------------------------------------
/server/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "hackpad",
 3 |   "version": "0.1.0",
 4 |   "private": true,
 5 |   "homepage": ".",
 6 |   "dependencies": {
 7 |     "@fontsource/roboto": "^4.5.0",
 8 |     "@fortawesome/fontawesome-free": "^5.15.1",
 9 |     "@testing-library/jest-dom": "^4.2.4",
10 |     "@testing-library/react": "^9.3.2",
11 |     "@testing-library/user-event": "^7.1.2",
12 |     "codemirror": "^5.57.0",
13 |     "mobile-detect": "^1.4.4",
14 |     "react": "^16.13.1",
15 |     "react-dom": "^16.13.1",
16 |     "react-scripts": "3.4.3",
17 |     "whatwg-fetch": "^3.4.0",
18 |     "xterm": "^4.8.1",
19 |     "xterm-addon-fit": "^0.4.0"
20 |   },
21 |   "scripts": {
22 |     "start": "react-scripts start",
23 |     "start-go": "cd .. && nodemon --signal SIGINT -e go -d 2 -x 'make go-static || exit 1'",
24 |     "build": "react-scripts build",
25 |     "test": "react-scripts test",
26 |     "eject": "react-scripts eject"
27 |   },
28 |   "eslintConfig": {
29 |     "extends": "react-app"
30 |   },
31 |   "browserslist": {
32 |     "production": [
33 |       ">0.2%",
34 |       "not dead",
35 |       "not op_mini all"
36 |     ],
37 |     "development": [
38 |       "last 1 chrome version",
39 |       "last 1 firefox version",
40 |       "last 1 safari version"
41 |     ]
42 |   },
43 |   "devDependencies": {
44 |     "nodemon": "^2.0.4"
45 |   }
46 | }
47 | 


--------------------------------------------------------------------------------
/server/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hack-pad/hackpad/f5de9aec72b44869a124d38fc95c1ed04965b15c/server/public/favicon.ico


--------------------------------------------------------------------------------
/server/public/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     
 7 |     
 8 |     
12 |     
13 |     
17 |     
18 |     
19 |     
28 |     Hackpad | Playground
29 |   
30 |   
31 |     
32 |     
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /server/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack-pad/hackpad/f5de9aec72b44869a124d38fc95c1ed04965b15c/server/public/logo192.png -------------------------------------------------------------------------------- /server/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack-pad/hackpad/f5de9aec72b44869a124d38fc95c1ed04965b15c/server/public/logo512.png -------------------------------------------------------------------------------- /server/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Hackpad", 3 | "name": "Hackpad", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /server/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /server/src/App.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --console-border: 2px solid #eee; 3 | --controls-height: 2.5em; 4 | --panes-height: calc(100vh - var(--controls-height)); 5 | --app-accent: rgba(119, 0, 136); 6 | --app-controls-color: #222; 7 | --app-controls-background: #fff; 8 | --app-controls-background-light: rgba(224, 224, 224, 0.5); 9 | --app-controls-highlight: rgba(119, 0, 136, 0.1); 10 | --app-title-highlight: rgba(199, 146, 234); 11 | --app-accent-alternate: rgba(50, 0, 135); 12 | } 13 | 14 | @media screen and (prefers-color-scheme: dark) { 15 | :root { 16 | --console-border: 2px solid #181818; 17 | --app-accent: rgba(199, 146, 234); 18 | --app-controls-color: #fff; 19 | --app-controls-background: #111; 20 | --app-controls-background-light: rgba(32, 32, 32, 0.5); 21 | --app-controls-highlight: rgba(199, 146, 234, 0.1); 22 | --app-accent-alternate: rgb(154, 145, 235); 23 | } 24 | } 25 | 26 | pre, textarea { 27 | letter-spacing: 0.04em; 28 | line-height: 1.25em; 29 | tab-size: 4; 30 | } 31 | 32 | h1 pre, h2 pre, h3 pre, h4 pre, h5 pre, h6 pre { 33 | font-weight: 800; 34 | } 35 | 36 | #app { 37 | height: 100%; 38 | } 39 | 40 | h1.app-title { 41 | font-size: 1.1em; 42 | cursor: default; 43 | user-select: none; 44 | 45 | margin: 0; 46 | padding-right: 0.8em; 47 | margin-right: 0.5em; 48 | border-right: 1px solid #ddd; 49 | } 50 | 51 | .app-title { 52 | font-family: var(--font-title); 53 | font-weight: 400; 54 | letter-spacing: 0.03em; 55 | margin: 0 0.3em; 56 | } 57 | 58 | .app-title-hack { 59 | display: inline-block; 60 | } 61 | 62 | .app-title-hack { 63 | color: #f07178; 64 | } 65 | 66 | .app-title-pad { 67 | color: var(--app-title-highlight); 68 | } 69 | 70 | @media screen and (prefers-color-scheme: dark) { 71 | h1.app-title { 72 | border-right: 1px solid rgb(45, 45, 45); 73 | } 74 | 75 | } 76 | 77 | .console { 78 | display: flex; 79 | flex-direction: column; 80 | } 81 | 82 | .console-output { 83 | position: relative; 84 | overflow: auto; 85 | border-radius: 0.25em; 86 | border-color: #000; 87 | padding: 0.5em 0.75em; 88 | margin: 0; 89 | } 90 | 91 | .console-output .stderr { 92 | color: #a00; 93 | } 94 | 95 | .console-output .note { 96 | color: #00a; 97 | position: absolute; 98 | right: 0; 99 | transform: translateY(calc(-100% + 0.1em)); 100 | padding: 0.1em; 101 | padding-left: 0.5em; 102 | background-color: rgba(80, 80, 80, 0.1); 103 | border-radius: 0.5em; 104 | } 105 | 106 | .terminal { 107 | padding: 0.4em; 108 | } 109 | 110 | @media screen and (prefers-color-scheme: dark) { 111 | body, textarea, button { 112 | background-color: rgb(33, 33, 33); 113 | color: #eee; 114 | } 115 | 116 | button { 117 | border: 1px solid; 118 | border-radius: 0.2em; 119 | } 120 | 121 | .console { 122 | border-top-color: #eee; 123 | } 124 | 125 | .console-output .stderr { 126 | color: #ff5050; 127 | font-weight: bolder; 128 | } 129 | 130 | .console-output .note { 131 | color: #88f; 132 | background-color: rgba(80, 80, 80, 0.9); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /server/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './App.css'; 3 | 4 | import './Tabs.css'; 5 | import "@fontsource/roboto"; 6 | import '@fortawesome/fontawesome-free/css/all.css'; 7 | import Compat from './Compat'; 8 | import Loading from './Loading'; 9 | import { install, run, observeGoDownloadProgress } from './Hackpad'; 10 | import { newEditor } from './Editor'; 11 | import { newTerminal } from './Terminal'; 12 | 13 | function App() { 14 | const [percentage, setPercentage] = React.useState(0); 15 | const [loading, setLoading] = React.useState(true); 16 | React.useEffect(() => { 17 | observeGoDownloadProgress(setPercentage) 18 | 19 | window.editor = { 20 | newTerminal, 21 | newEditor, 22 | } 23 | Promise.all([ install('editor'), install('sh') ]) 24 | .then(() => { 25 | run('editor', '--editor=editor') 26 | setLoading(false) 27 | }) 28 | }, [setLoading, setPercentage]) 29 | 30 | return ( 31 | <> 32 | { loading ? <> 33 | 34 | 35 | : null } 36 |
37 |
38 |
39 | 40 | ); 41 | } 42 | 43 | export default App; 44 | -------------------------------------------------------------------------------- /server/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /server/src/ColorScheme.js: -------------------------------------------------------------------------------- 1 | 2 | 3 | export function listenColorScheme({ light, dark }) { 4 | const fn = mq => { 5 | const darkTheme = mq.matches 6 | if (darkTheme) { 7 | dark() 8 | } else { 9 | light() 10 | } 11 | } 12 | observeTheme(fn) 13 | fn(getMedia()) 14 | } 15 | 16 | function getMedia() { 17 | return window.matchMedia("(prefers-color-scheme: dark)") 18 | } 19 | 20 | function init() { 21 | if (! window.matchMedia) { 22 | return () => false 23 | } 24 | return fn => getMedia().addListener(fn) 25 | } 26 | 27 | const observeTheme = init(); 28 | -------------------------------------------------------------------------------- /server/src/Compat.css: -------------------------------------------------------------------------------- 1 | .compat { 2 | padding: 0 2em; 3 | } 4 | 5 | .compatibility-warning-dialog { 6 | visibility: hidden; 7 | z-index: 1000; 8 | position: absolute; 9 | top: var(--controls-height); 10 | right: 0; 11 | color: var(--app-controls-color); 12 | background-color: var(--app-controls-background); 13 | text-align: left; 14 | width: 20em; 15 | padding: 0 1em; 16 | border-left: 2px solid; 17 | border-bottom: 2px solid; 18 | border-color: var(--app-controls-background-light); 19 | border-bottom-left-radius: 0.5em; 20 | } 21 | 22 | .compatibility-warning-dialog p { 23 | margin-top: 0.25em; 24 | } 25 | 26 | .compatibility-warning-dialog.compatibility-warning-show { 27 | visibility: visible; 28 | } 29 | 30 | .ide .controls .control.compatibility-warning { 31 | color: rgba(230, 160, 0, 1); 32 | } 33 | 34 | .ide .controls .control.compatibility-warning:hover { 35 | background-color: rgba(230, 160, 0, 0.2); 36 | } 37 | -------------------------------------------------------------------------------- /server/src/Compat.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Compat.css'; 3 | import MobileDetect from 'mobile-detect'; 4 | 5 | 6 | const md = new MobileDetect(window.navigator.userAgent); 7 | let browserName = "" 8 | if (window.navigator.vendor.match(/google/i)) { 9 | browserName = 'Chrome' 10 | } else if (navigator.userAgent.match(/firefox\//i)) { 11 | browserName = 'Firefox' 12 | } 13 | const knownWorkingBrowsers = [ 14 | 'Chrome', 15 | 'Firefox', 16 | ] 17 | const isCompatibleBrowser = md.mobile() === null && knownWorkingBrowsers.includes(browserName) 18 | 19 | export default function Compat() { 20 | if (isCompatibleBrowser) { 21 | return null 22 | } 23 | return ( 24 |
25 |

Hackpad may not work reliably in your browser.

26 |

If you're experience any issues, try a recent version of {joinOr(knownWorkingBrowsers)} on a device with enough memory, like a PC.

27 |
28 | ) 29 | } 30 | 31 | function joinOr(arr) { 32 | if (arr.length === 1) { 33 | return arr[0] 34 | } 35 | if (arr.length === 2) { 36 | return `${arr[0]} or ${arr[1]}` 37 | } 38 | 39 | const commaDelimited = arr.slice(0, arr.length - 1).join(", ") 40 | return `${commaDelimited}, or ${arr[arr.length - 1]}` 41 | } 42 | -------------------------------------------------------------------------------- /server/src/Editor.css: -------------------------------------------------------------------------------- 1 | .editor { 2 | height: 100%; 3 | } 4 | 5 | .editor .CodeMirror { 6 | height: 100%; 7 | } 8 | 9 | .editor .CodeMirror-lines { 10 | padding: 0.5em 0; /* Vertical padding around content */ 11 | } 12 | 13 | .editor .CodeMirror pre.CodeMirror-line, 14 | .editor .CodeMirror pre.CodeMirror-line-like { 15 | padding: 0 0.5em; /* Horizontal padding of content */ 16 | } 17 | 18 | @media screen and (prefers-color-scheme: dark) { 19 | .editor-file-picker { 20 | background-color: transparent; 21 | color: inherit; 22 | font-family: inherit; 23 | outline: none; 24 | border: 1px solid rgba(255, 255, 255, 0.3); 25 | border-radius: 0.2em; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /server/src/Editor.js: -------------------------------------------------------------------------------- 1 | import CodeMirror from 'codemirror/lib/codemirror'; 2 | import 'codemirror/lib/codemirror.css'; 3 | import 'codemirror/theme/material-darker.css'; 4 | import 'codemirror/mode/go/go'; 5 | 6 | import { listenColorScheme } from './ColorScheme'; 7 | import './Editor.css'; 8 | 9 | export function newEditor(elem, onEdit) { 10 | const editor = CodeMirror(elem, { 11 | mode: "go", 12 | theme: "default", 13 | lineNumbers: true, 14 | indentUnit: 4, 15 | indentWithTabs: true, 16 | viewportMargin: Infinity, 17 | }) 18 | listenColorScheme({ 19 | light: () => editor.setOption("theme", "default"), 20 | dark: () => editor.setOption("theme", "material-darker"), 21 | }) 22 | editor.on('change', onEdit) 23 | 24 | elem.addEventListener('click', e => { 25 | editor.focus() 26 | if (e.target === elem) { 27 | // If we've clicked outside the code editor area, then it must be the bottom empty space. 28 | editor.setCursor({ line: editor.lineCount()-1 }) 29 | } 30 | }) 31 | return { 32 | getContents() { 33 | return editor.getValue() 34 | }, 35 | 36 | setContents(contents) { 37 | editor.setValue(contents) 38 | }, 39 | 40 | getCursorIndex() { 41 | return editor.getCursor().ch 42 | }, 43 | 44 | setCursorIndex(index) { 45 | editor.setCursor({ ch: index }) 46 | }, 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/src/Hackpad.js: -------------------------------------------------------------------------------- 1 | import WebAssembly from './WebAssembly'; 2 | import 'whatwg-fetch'; 3 | 4 | const Go = window.Go; // loaded from wasm_exec.js script in index.html 5 | 6 | let overlayProgress = 0; 7 | let progressListeners = []; 8 | 9 | async function init() { 10 | const startTime = new Date().getTime() 11 | const go = new Go(); 12 | const cmd = await WebAssembly.instantiateStreaming(fetch(`wasm/main.wasm`), go.importObject) 13 | go.env = { 14 | 'GOMODCACHE': '/home/me/.cache/go-mod', 15 | 'GOPROXY': 'https://proxy.golang.org/', 16 | 'GOROOT': '/usr/local/go', 17 | 'HOME': '/home/me', 18 | 'PATH': '/bin:/home/me/go/bin:/usr/local/go/bin/js_wasm:/usr/local/go/pkg/tool/js_wasm', 19 | } 20 | go.run(cmd.instance) 21 | const { hackpad, fs } = window 22 | console.debug(`hackpad status: ${hackpad.ready ? 'ready' : 'not ready'}`) 23 | 24 | const mkdir = promisify(fs.mkdir) 25 | await mkdir("/bin", {mode: 0o700}) 26 | await hackpad.overlayIndexedDB('/bin', {cache: true}) 27 | await hackpad.overlayIndexedDB('/home/me') 28 | await mkdir("/home/me/.cache", {recursive: true, mode: 0o700}) 29 | await hackpad.overlayIndexedDB('/home/me/.cache', {cache: true}) 30 | 31 | await mkdir("/usr/local/go", {recursive: true, mode: 0o700}) 32 | await hackpad.overlayTarGzip('/usr/local/go', 'wasm/go.tar.gz', { 33 | persist: true, 34 | skipCacheDirs: [ 35 | '/usr/local/go/bin/js_wasm', 36 | '/usr/local/go/pkg/tool/js_wasm', 37 | ], 38 | progress: percentage => { 39 | overlayProgress = percentage 40 | progressListeners.forEach(c => c(percentage)) 41 | }, 42 | }) 43 | 44 | console.debug("Startup took", (new Date().getTime() - startTime) / 1000, "seconds") 45 | } 46 | 47 | const initOnce = init(); // always wait on this to ensure hackpad window object is ready 48 | 49 | export async function install(name) { 50 | await initOnce 51 | return window.hackpad.install(name) 52 | } 53 | 54 | export async function run(name, ...args) { 55 | const process = await spawn({ name, args }) 56 | return await wait(process.pid) 57 | } 58 | 59 | export async function wait(pid) { 60 | await initOnce 61 | const { child_process } = window 62 | return await new Promise((resolve, reject) => { 63 | child_process.wait(pid, (err, process) => { 64 | if (err) { 65 | reject(err) 66 | return 67 | } 68 | resolve(process) 69 | }) 70 | }) 71 | } 72 | 73 | export async function spawn({ name, args, ...options }) { 74 | await initOnce 75 | const { child_process } = window 76 | return await new Promise((resolve, reject) => { 77 | const subprocess = child_process.spawn(name, args, options) 78 | if (subprocess.error) { 79 | reject(new Error(`Failed to spawn command: ${name} ${args.join(" ")}: ${subprocess.error}`)) 80 | return 81 | } 82 | resolve(subprocess) 83 | }) 84 | } 85 | 86 | export async function spawnTerminal(term, options) { 87 | await initOnce 88 | const { hackpad } = window 89 | return hackpad.spawnTerminal(term, options) 90 | } 91 | 92 | export async function mkdirAll(path) { 93 | await initOnce 94 | const { fs } = window 95 | fs.mkdirSync(path, { recursive: true, mode: 0o755 }) 96 | } 97 | 98 | export function observeGoDownloadProgress(callback) { 99 | progressListeners.push(callback) 100 | callback(overlayProgress) 101 | } 102 | 103 | function promisify(fn) { 104 | return (...args) => { 105 | return new Promise((resolve, reject) => { 106 | const newArgs = [...args] 107 | newArgs.push((err, ...results) => { 108 | if (err) { 109 | reject(err) 110 | } else { 111 | resolve(results) 112 | } 113 | }) 114 | fn(...newArgs) 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /server/src/Loading.css: -------------------------------------------------------------------------------- 1 | .app-loading { 2 | --app-loading-size: 6rem; 3 | 4 | z-index: 1000; 5 | position: absolute; 6 | height: 100vh; 7 | width: 100vw; 8 | background-color: rgba(255, 255, 255, 0.4); 9 | } 10 | 11 | @media screen and (prefers-color-scheme: dark) { 12 | .app-loading { 13 | background-color: rgba(0, 0, 0, 0.4); 14 | } 15 | } 16 | 17 | .app-loading .app-loading-center { 18 | position: relative; 19 | top: 50%; 20 | left: 50%; 21 | transform: translate(-50%, -50%); 22 | display: flex; 23 | flex-direction: column; 24 | align-items: center; 25 | } 26 | 27 | .app-loading .app-loading-spinner { 28 | position: relative; /* enable percentage to be absolutely centered */ 29 | font-size: var(--app-loading-size); 30 | width: var(--app-loading-size); 31 | height: var(--app-loading-size); 32 | margin: 1.5rem; 33 | } 34 | 35 | .app-loading p { 36 | text-align: center; 37 | margin: 0.25rem; 38 | padding: 0; 39 | } 40 | 41 | .app-loading-percentage { 42 | font-size: 1rem; 43 | position: absolute; 44 | top: 50%; 45 | left: 50%; 46 | transform: translateX(calc(-50% + 0.25rem)); 47 | font-style: italic; 48 | } 49 | -------------------------------------------------------------------------------- /server/src/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './Loading.css'; 3 | 4 | 5 | export default function Loading({ percentage }) { 6 | return ( 7 |
8 |
9 |
10 | { percentage !== undefined ? 11 | {Math.round(percentage)}% 12 | : null } 13 | 14 |
15 |

16 | installing 17 | hackpad 18 | 19 |

20 |

please wait...

21 |
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /server/src/Tabs.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --tab-background: rgb(240, 240, 240); 3 | --tab-bar: transparent; 4 | --tab-active: #fff; 5 | --tab-hover: rgb(250, 250, 250); 6 | --tab-highlight: rgb(170, 170, 170); 7 | --control-color: var(--tab-highlight); 8 | --control-hover: #111; 9 | --control-hover-background: rgb(220, 220, 220); 10 | 11 | --tab-bar-height: 2.3m; 12 | } 13 | 14 | @media screen and (prefers-color-scheme: dark) { 15 | :root { 16 | --tab-background: rgb(20, 20, 20); 17 | --tab-bar: transparent; 18 | --tab-active: rgb(33, 33, 33); 19 | --tab-hover: rgb(40, 40, 40); 20 | --tab-highlight: rgb(170, 170, 170); 21 | --control-color: var(--tab-highlight); 22 | --control-hover: #fff; 23 | --control-hover-background: rgb(70, 70, 70); 24 | } 25 | } 26 | 27 | .tab-bar { 28 | background-color: var(--tab-background); 29 | height: var(--tab-bar-height); 30 | display: flex; 31 | flex-direction: row; 32 | flex-wrap: wrap; 33 | align-items: center; 34 | } 35 | 36 | .tab-bar button { 37 | cursor: pointer; 38 | margin: 0; 39 | outline: none; 40 | transition: background-color 0.2s, color 0.2s, border-color 0.2s; 41 | } 42 | 43 | .tab-bar .tab-button { 44 | font-size: 1em; 45 | height: var(--tab-bar-height); 46 | box-sizing: border-box; 47 | padding: 0.5em 1.25em; 48 | border: none; 49 | border-bottom: 1px solid transparent; 50 | border-radius: 0; 51 | background-color: var(--tab-bar); 52 | cursor: pointer; 53 | } 54 | 55 | .tab-bar .tab-button:hover { 56 | border-bottom: 1px solid var(--tab-highlight); 57 | background-color: var(--tab-hover); 58 | } 59 | 60 | .tab-bar .tab-button.active { 61 | background-color: var(--tab-active); 62 | } 63 | 64 | .tab-bar .tab-button.active:hover { 65 | background-color: var(--tab-hover); 66 | } 67 | 68 | .tab-buttons { 69 | display: flex; 70 | flex-direction: row; 71 | flex-wrap: wrap; 72 | } 73 | 74 | .tabs { 75 | flex-grow: 1; 76 | overflow: hidden; 77 | } 78 | 79 | .tab { 80 | visibility: hidden; 81 | position: absolute; 82 | overflow: hidden; 83 | height: 100%; 84 | } 85 | 86 | .tab.active { 87 | visibility: visible; 88 | position: unset; 89 | } 90 | 91 | button.tab-new { 92 | display: block; 93 | background: none; 94 | border: none; 95 | color: var(--control-color); 96 | margin: 0.4em; 97 | text-align: center; 98 | font-weight: 800; 99 | font-family: sans-serif; 100 | font-size: 0.8em; 101 | height: 2.2em; 102 | width: 2.2em; 103 | border-radius: 0.25em; 104 | } 105 | 106 | .tab-new:hover { 107 | background-color: var(--control-hover-background); 108 | color: var(--control-hover); 109 | } 110 | 111 | .tab-bar .tab-close { 112 | display: inline-block; 113 | border: none; 114 | background: none; 115 | color: var(--control-color); 116 | margin-left: 0.3em; 117 | margin-right: -0.5em; 118 | } 119 | 120 | .tab-bar .tab-close:hover { 121 | color: var(--control-hover); 122 | background-color: var(--control-hover-background); 123 | } 124 | 125 | .tab-bar .tab-close { 126 | font-size: 0.8em; 127 | height: 1.8em; 128 | width: 1.8em; 129 | border-radius: 0.25em; 130 | } 131 | -------------------------------------------------------------------------------- /server/src/Terminal.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import 'xterm/css/xterm.css'; 4 | import { Terminal as XTerminal } from 'xterm'; 5 | import { FitAddon } from 'xterm-addon-fit'; 6 | import { listenColorScheme } from './ColorScheme'; 7 | 8 | export default function Terminal({ args, ...props }) { 9 | const elem = React.useRef(null) 10 | React.useEffect(() => { 11 | if (elem) { 12 | newTerminal(elem) 13 | } 14 | }, [elem]) 15 | 16 | return
17 | } 18 | 19 | const fontScale = 0.85 20 | 21 | export function newTerminal(elem) { 22 | const fitAddon = new FitAddon() 23 | const term = new XTerminal({ 24 | }) 25 | term.loadAddon(fitAddon) 26 | 27 | const dark = "rgb(33, 33, 33)" 28 | const light = "white" 29 | listenColorScheme({ 30 | light: () => term.setOption('theme', { 31 | background: light, 32 | foreground: dark, 33 | cursor: dark, 34 | }), 35 | dark: () => term.setOption('theme', { 36 | background: dark, 37 | foreground: light, 38 | cursor: light, 39 | }), 40 | }) 41 | 42 | term.open(elem) 43 | term.setOption('cursorBlink', true) 44 | term.focus() 45 | const fit = () => { 46 | const fontSize = parseFloat(getComputedStyle(elem).fontSize) 47 | term.setOption('fontSize', fontSize * fontScale) 48 | fitAddon.fit() 49 | } 50 | 51 | fit() 52 | if (window.ResizeObserver) { 53 | const parent = elem.parentNode 54 | const observer = new ResizeObserver(() => { 55 | if (! elem.parentNode) { 56 | // elem removed from DOM 57 | observer.unobserve(parent) 58 | return 59 | } 60 | if (elem.classList.contains("active")) { 61 | fit() 62 | } 63 | }) 64 | observer.observe(parent) 65 | } else { 66 | window.addEventListener('resize', fit) 67 | } 68 | return term 69 | } 70 | -------------------------------------------------------------------------------- /server/src/WebAssembly.js: -------------------------------------------------------------------------------- 1 | // WebAssembly plus polyfills 2 | 3 | if (!WebAssembly.instantiateStreaming) { // polyfill 4 | WebAssembly.instantiateStreaming = async (resp, importObject) => { 5 | const source = await (await resp).arrayBuffer(); 6 | return await WebAssembly.instantiate(source, importObject); 7 | }; 8 | } 9 | 10 | export default WebAssembly; 11 | -------------------------------------------------------------------------------- /server/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --font-mono: Menlo, Courier, monospace; 3 | --font-title: Roboto, Menlo, Courier, monospace; 4 | } 5 | 6 | html, body { 7 | font-family: "Helvetica Neue", Helvetica; 8 | padding: 0; 9 | margin: 0; 10 | box-sizing: border-box; 11 | height: 100vh; 12 | width: 100vw; 13 | overflow: hidden; 14 | } 15 | 16 | h1, h2, h3, h4, h5, h6 { 17 | font-weight: 400; 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | nav ul { 23 | display: inline-block; 24 | list-style: none; 25 | padding: 0; 26 | margin: 0; 27 | } 28 | 29 | nav li { 30 | display: inline-block; 31 | } 32 | 33 | #root { 34 | height: 100%; 35 | width: 100%; 36 | } 37 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want your app to work offline and load faster, you can change 15 | // unregister() to register() below. Note this comes with some pitfalls. 16 | // Learn more about service workers: https://bit.ly/CRA-PWA 17 | serviceWorker.unregister(); 18 | -------------------------------------------------------------------------------- /server/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /server/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then(response => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type'); 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then(registration => { 115 | registration.unregister().then(() => { 116 | window.location.reload(); 117 | }); 118 | }); 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config); 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then(registration => { 135 | registration.unregister(); 136 | }) 137 | .catch(error => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /server/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | --------------------------------------------------------------------------------