├── .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 |
2 |
3 |
4 |
5 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------