├── .gitignore
├── static
├── robots.txt
├── logo.ico
├── index.html
├── style.css
└── playground.js
├── .github
└── workflows
│ └── ci.yml
├── go.mod
├── handlers_test.go
├── LICENSE
├── main.go
├── README.md
├── go.sum
└── handlers.go
/.gitignore:
--------------------------------------------------------------------------------
1 | zig-play
2 |
--------------------------------------------------------------------------------
/static/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Allow: /
3 |
--------------------------------------------------------------------------------
/static/logo.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/gsquire/zig-play/HEAD/static/logo.ico
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | on: [push, pull_request]
2 | name: CI
3 | jobs:
4 | test:
5 | strategy:
6 | matrix:
7 | go-version: [1.25.x]
8 | os: [ubuntu-latest, macos-latest, windows-latest]
9 | runs-on: ${{ matrix.os }}
10 | steps:
11 | - name: Install Go
12 | uses: actions/setup-go@v5
13 | with:
14 | go-version: ${{ matrix.go-version }}
15 | - name: Checkout code
16 | uses: actions/checkout@v4
17 | - name: Test
18 | run: |
19 | go vet ./...
20 | go test ./...
21 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/gsquire/zig-play
2 |
3 | go 1.25.4
4 |
5 | require (
6 | github.com/gorilla/handlers v1.5.2
7 | github.com/julienschmidt/httprouter v1.3.0
8 | github.com/justinas/alice v1.2.0
9 | github.com/rs/zerolog v1.34.0
10 | github.com/sethvargo/go-limiter v1.1.0
11 | github.com/unrolled/secure v1.17.0
12 | )
13 |
14 | require (
15 | github.com/felixge/httpsnoop v1.0.4 // indirect
16 | github.com/mattn/go-colorable v0.1.14 // indirect
17 | github.com/mattn/go-isatty v0.0.20 // indirect
18 | golang.org/x/sys v0.38.0 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/handlers_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 | "net/http/httptest"
7 | "testing"
8 |
9 | "github.com/rs/zerolog"
10 | )
11 |
12 | func TestBodySizeLimit(t *testing.T) {
13 | payload := bytes.Repeat([]byte("big"), 6*1024*1024)
14 |
15 | req, err := http.NewRequest(http.MethodPost, "/server/run", bytes.NewBuffer(payload))
16 | if err != nil {
17 | t.Fatal(err)
18 | }
19 |
20 | rr := httptest.NewRecorder()
21 | handler := LoggingMiddleware(http.HandlerFunc(Run), zerolog.Nop())
22 |
23 | handler.ServeHTTP(rr, req)
24 |
25 | if status := rr.Code; status != http.StatusInternalServerError {
26 | t.Errorf("handler returned wrong status code: got %v want %v",
27 | status, http.StatusInternalServerError)
28 | }
29 |
30 | expected := "reading body\n"
31 | if rr.Body.String() != expected {
32 | t.Errorf("handler returned unexpected body: got %v want %v",
33 | rr.Body.String(), expected)
34 | }
35 |
36 | }
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Garrett Squire
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "net/http"
6 | "os"
7 | "time"
8 |
9 | "github.com/gorilla/handlers"
10 | "github.com/julienschmidt/httprouter"
11 | "github.com/justinas/alice"
12 | "github.com/rs/zerolog"
13 | "github.com/sethvargo/go-limiter/httplimit"
14 | "github.com/sethvargo/go-limiter/memorystore"
15 | "github.com/unrolled/secure"
16 | )
17 |
18 | func securitySettings() *secure.Secure {
19 | return secure.New(secure.Options{
20 | BrowserXssFilter: true,
21 | ContentTypeNosniff: true,
22 | FrameDeny: true,
23 | STSPreload: true,
24 | STSSeconds: 31536000,
25 | })
26 | }
27 |
28 | func main() {
29 | // Users can compile code 5 times per minute.
30 | rateLimiter, err := memorystore.New(&memorystore.Config{
31 | Tokens: 5,
32 | Interval: time.Minute,
33 | })
34 | if err != nil {
35 | log.Fatal("error making rate limiter", err)
36 | }
37 | rlMiddle, err := httplimit.NewMiddleware(rateLimiter, httplimit.IPKeyFunc())
38 | if err != nil {
39 | log.Fatal(err)
40 | }
41 |
42 | logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
43 |
44 | router := httprouter.New()
45 |
46 | router.Handler(http.MethodPost, "/server/run", http.HandlerFunc(Run))
47 | router.Handler(http.MethodPost, "/server/fmt", http.HandlerFunc(Fmt))
48 |
49 | chain := alice.New(rlMiddle.Handle, securitySettings().Handler, handlers.CompressHandler, handlers.RecoveryHandler()).Then(LoggingMiddleware(router, logger))
50 | log.Fatal(http.ListenAndServe(":8080", chain))
51 | }
52 |
--------------------------------------------------------------------------------
/static/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Zig Playground
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
29 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zig Playground
2 |
3 | This is a rudimentary online compiler for the [Zig](https://ziglang.org) programming language. It
4 | is inspired by the [Go](https://play.golang.org) playground.
5 |
6 | It's currently served from this [page](https://zig-play.dev).
7 |
8 | ### Setup
9 | The main server is a Go binary that serves up a single HTML page that allows you to enter your Zig
10 | code and then run it. To host it yourself, you will need a Go tool chain which can be installed via
11 | `brew` on a Mac. If you wish to run it locally, you must compile it for your `GOOS` and `GOARCH`.
12 | You should also have Zig installed and accessible from within your `$PATH` on the host.
13 |
14 | ### Hosting
15 | I currently am using a VPS and have [Caddy](https://caddyserver.com) as a reverse proxy.
16 |
17 | ### FAQ
18 | > What can this playground do?
19 |
20 | It is currently set up to simply run and format a single Zig source file. (i.e. `zig run source.zig` & `zig fmt source.zig`)
21 |
22 | > Are there any timeouts?
23 |
24 | If your code doesn't build within 30 seconds, the server will quit your request.
25 |
26 | > Why am I getting rate-limited?
27 |
28 | You're allowed five executions per minute which I think is fairly generous.
29 |
30 | > Is it secure?
31 |
32 | Go read the source. I do not collect logs of any kind and am not interested in your data. Unless it
33 | is causing issues to the service.
34 |
35 | > Will this always be available?
36 |
37 | To the best of my ability, I will try and keep this online.
38 |
39 | ### Contact
40 | Feel free to write to hello@zig-play.dev with any questions or comments.
41 |
42 | ### License
43 | MIT
44 |
--------------------------------------------------------------------------------
/static/style.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --background-color: #FFFFFF;
3 | --color: #000000;
4 | --border: rgb(171, 171, 171);
5 | }
6 |
7 | [data-theme="dark"] {
8 | --background-color: #111;
9 | --color: #bbb;
10 | --border: rgb(83, 84, 85);
11 | }
12 |
13 | [data-theme="dark"] #toggle-dark {
14 | display: none;
15 | }
16 |
17 | [data-theme="light"] #toggle-light {
18 | display: none;
19 | }
20 |
21 | h1,
22 | p,
23 | label,
24 | select,
25 | button {
26 | font-family: sans-serif;
27 | }
28 |
29 | div {
30 | box-sizing: border-box;
31 | }
32 |
33 | body {
34 | color: var(--color);
35 | background-color: var(--background-color);
36 | }
37 |
38 | main {
39 | display: block;
40 | width: 90vw;
41 | margin: 0 auto;
42 | }
43 |
44 | .header {
45 | display: flex;
46 | justify-content: space-between;
47 | flex-wrap: wrap;
48 | }
49 |
50 | .playground-controls {
51 | display: flex;
52 | align-items: center;
53 | gap: 0.5rem;
54 | padding-top: 0.4rem;
55 | }
56 |
57 | h1 {
58 | font-size: 2.5rem;
59 | margin-right: 0.5rem;
60 | }
61 |
62 | #stdout {
63 | display: block;
64 | padding: 1rem;
65 | margin: 2rem 0;
66 | font-family: monospace;
67 | width: 100%;
68 | height: 20vh;
69 | border: 1px dashed gray;
70 | overflow: scroll;
71 | white-space: pre;
72 | }
73 |
74 | select,
75 | button {
76 | font-size: 1rem;
77 | padding: 0.6rem;
78 | border: none;
79 | background: lightblue;
80 | cursor: pointer;
81 | }
82 |
83 | #playground {
84 | display: flex;
85 | flex-direction: column;
86 | }
87 |
88 | #editor {
89 | border: solid 1px var(--border);
90 | font-size: 0.8rem;
91 | /* Ensure the font for ace editor is monospace! */
92 | font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'Source Code Pro', 'source-code-pro', monospace !important;
93 | height: 60vh;
94 | }
95 |
96 | /* On smaller screen widths, the flex-based header and controls
97 | * will start to wrap. Make our editor take up less height. */
98 | @media only screen and (max-width: 800px) {
99 | #editor {
100 | height: 50vh;
101 | }
102 | }
103 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
2 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
3 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
4 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
5 | github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
6 | github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
7 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
8 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
9 | github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo=
10 | github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA=
11 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
12 | github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
13 | github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
14 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
15 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
16 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
17 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
18 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
19 | github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
20 | github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
21 | github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
22 | github.com/sethvargo/go-limiter v1.1.0 h1:eLeZVQ2zqJOiEs03GguqmBVG6/T6lsZB+6PP1t7J6fA=
23 | github.com/sethvargo/go-limiter v1.1.0/go.mod h1:01b6tW25Ap+MeLYBuD4aHunMrJoNO5PVUFdS9rac3II=
24 | github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
25 | github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
26 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
27 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
28 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29 | golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
30 | golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
31 |
--------------------------------------------------------------------------------
/handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "io"
6 | "net/http"
7 | "os"
8 | "os/exec"
9 | "path"
10 | "path/filepath"
11 | "time"
12 |
13 | "github.com/rs/zerolog"
14 | )
15 |
16 | type Command int
17 | type CtxKey string
18 |
19 | const (
20 | R Command = iota
21 | F
22 | )
23 |
24 | const CtxLogger CtxKey = "logger"
25 |
26 | func LoggingMiddleware(h http.Handler, logger zerolog.Logger) http.Handler {
27 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
28 | ctx := context.WithValue(r.Context(), CtxLogger, logger)
29 | h.ServeHTTP(w, r.WithContext(ctx))
30 | })
31 | }
32 |
33 | func whichZig(r *http.Request) string {
34 | const zigVersion = "X-Zig-Version"
35 |
36 | return r.Header.Get(zigVersion)
37 | }
38 |
39 | func execute(w http.ResponseWriter, r *http.Request, command Command) {
40 | logger := r.Context().Value(CtxLogger).(zerolog.Logger)
41 |
42 | // Limit how big a source file can be. 5MB here.
43 | r.Body = http.MaxBytesReader(w, r.Body, 5*1024*1024)
44 | zigSource, err := io.ReadAll(r.Body)
45 | if err != nil {
46 | logger.Error().Err(err).Msg("reading the request body")
47 | http.Error(w, "reading body", http.StatusInternalServerError)
48 | return
49 | }
50 | defer r.Body.Close()
51 |
52 | // Set up the temporary resources.
53 | playgroundDir := os.Getenv("PLAYGROUND_DIR")
54 | dir, err := os.MkdirTemp(playgroundDir, "playground")
55 | if err != nil {
56 | logger.Error().Err(err).Msg("making the temporary directory")
57 | http.Error(w, "creating temporary directory", http.StatusInternalServerError)
58 | return
59 | }
60 | defer os.RemoveAll(dir)
61 |
62 | tmpSource := filepath.Join(dir, "play.zig")
63 | if err := os.WriteFile(tmpSource, []byte(zigSource), 0666); err != nil {
64 | logger.Error().Err(err).Msg("copying the source")
65 | http.Error(w, "copying zig source", http.StatusInternalServerError)
66 | return
67 | }
68 |
69 | // Currently we cap compilation times at thirty seconds.
70 | ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
71 | defer cancel()
72 |
73 | // We only have two commands for now.
74 | var output []byte
75 | if command == R {
76 | home := os.Getenv("HOME")
77 | output, err = exec.CommandContext(ctx, path.Join(home, "zrun.sh"), whichZig(r), tmpSource).CombinedOutput()
78 | } else {
79 | fd, ferr := os.Open(tmpSource)
80 | if ferr != nil {
81 | logger.Error().Err(err).Msg("opening the tmp source during fmt command")
82 | }
83 | cmd := exec.CommandContext(ctx, "zvm", "run", whichZig(r), "fmt", "--stdin")
84 | cmd.Stdin = fd
85 | output, err = cmd.CombinedOutput()
86 | }
87 |
88 | if err != nil {
89 | logger.Error().Err(err).Msg("running the command")
90 | w.WriteHeader(http.StatusBadRequest)
91 | }
92 |
93 | _, err = w.Write(output)
94 | if err != nil {
95 | logger.Error().Err(err).Msg("writing the response")
96 | http.Error(w, "writing response", http.StatusInternalServerError)
97 | }
98 | }
99 |
100 | func Run(w http.ResponseWriter, r *http.Request) {
101 | execute(w, r, R)
102 | }
103 |
104 | func Fmt(w http.ResponseWriter, r *http.Request) {
105 | execute(w, r, F)
106 | }
107 |
--------------------------------------------------------------------------------
/static/playground.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Global ace editor variable.
3 | */
4 | var editor;
5 |
6 | const runCmd = '/server/run';
7 | const fmtCmd = '/server/fmt';
8 |
9 | /**
10 | * Execute a function.
11 | * @param {string} route - The function to execute.
12 | */
13 | async function execute(route) {
14 | const stdout = document.getElementById('stdout');
15 | if (!stdout) {
16 | console.error("Couldn't find element #stdout.");
17 | return;
18 | }
19 | stdout.innerHTML = "Waiting for server...";
20 |
21 | const code = editor.getValue();
22 | var version = document.getElementById("version-select").value;
23 |
24 | try {
25 | const res = await fetch(route, {
26 | method: 'POST',
27 | headers: {
28 | 'Content-Type': 'text/plain',
29 | 'X-Zig-Version': version
30 | },
31 | body: code
32 | });
33 | const text = await res.text();
34 | let msg = text;
35 | if (res.status === 429) {
36 | msg = 'Too many requests. Please wait a minute and then try again.';
37 | } else if (!res.ok) {
38 | msg = 'An error occurred:\n' + text;
39 | }
40 | // If run command, we display the resulting text.
41 | if (route === runCmd) {
42 | stdout.innerHTML = msg;
43 | } else if (route === fmtCmd) {
44 | // For format command, we set the editor text to the output and
45 | // let the user know the command is complete.
46 | if (code != msg) {
47 | // Preserve selection to try to put the cursor about where
48 | // it was before format.
49 | selection = editor.selection.toJSON();
50 | editor.setValue(msg);
51 | editor.selection.fromJSON(selection)
52 | }
53 | stdout.innerHTML = '';
54 | }
55 | } catch (e) {
56 | stdout.innerHTML = 'Could not connect to server.';
57 | }
58 | }
59 |
60 | async function runCode() {
61 | execute(runCmd);
62 | }
63 |
64 | async function fmtCode() {
65 | execute(fmtCmd);
66 | }
67 |
68 | function toggleTheme() {
69 | // Determine new value of toggle and set data-theme.
70 | let newValue = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
71 | document.documentElement.setAttribute('data-theme', newValue);
72 | // Update our ace editor with new theme as well.
73 | let editorTheme = newValue === 'dark' ? 'ace/theme/vibrant_ink' : 'ace/theme/clouds';
74 | editor.setTheme(editorTheme);
75 | }
76 |
77 | /**
78 | * Our demo code. Setting this up as a variable to keep the HTML clean.
79 | * In the future, we can add a map of name/code pairs to allow a select
80 | * list with various code examples. For example this snippet would be "hello world"
81 | * but we would also allow them to select "zigg zagg" from the examples:
82 | *
83 | * https://ziglang.org/learn/samples/
84 | * */
85 | const demoCode = `// You can edit this code!
86 | // Click into the editor and start typing.
87 | const std = @import("std");
88 | const builtin = @import("builtin");
89 |
90 | pub fn main() void {
91 | std.debug.print("Hello, {s}! (using Zig version: {f})", .{ "world", builtin.zig_version });
92 | }
93 | `; // Adding trailing space so it matches "format" output.
94 |
95 | // On content loaded, set up ace editor and detect dark/light mode and set data-theme appropriately.
96 | document.addEventListener('DOMContentLoaded', function () {
97 | editor = ace.edit("editor");
98 | // Set the value of our editor to our demo code.
99 | // The second param prevents default "select all" behavior.
100 | editor.setValue(demoCode, -1);
101 | if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
102 | editor.setTheme("ace/theme/vibrant_ink");
103 | document.documentElement.setAttribute('data-theme', 'dark');
104 | } else {
105 | editor.setTheme("ace/theme/clouds");
106 | document.documentElement.setAttribute('data-theme', 'light');
107 | }
108 | // Set editor mode to zig.
109 | editor.session.setMode("ace/mode/zig");
110 | });
111 |
--------------------------------------------------------------------------------