Fullstack in One MoonBit Project#

This tutorial builds a small fullstack app in one MoonBit module.

You will implement one shared validation rule set and use it in both places:

  • frontend/: show local warnings and call backend

  • backend/: validate again and return JSON response

The key is supported-targets:

  • frontend/ is js

  • backend/ is native

  • shared/ is target-agnostic

Prerequisites#

  • MoonBit toolchain installed

  • hurl installed for API testing

Step 1: Create the module#

moon new fullstack_one_project
cd fullstack_one_project
moon add moonbitlang/async@0.19.2
moon add moonbit-community/rabbita

Project layout:

fullstack_one_project
├── Makefile
├── moon.mod.json
├── backend
│   ├── api.hurl
│   ├── index.html
│   ├── main.mbt
│   └── moon.pkg
├── frontend
│   ├── main.mbt
│   └── moon.pkg
└── shared
    ├── moon.pkg
    ├── shared_test.mbt
    └── task.mbt

Module config:

{
  "name": "moonbit-community/fullstack-one-project-doc",
  "version": "0.1.0",
  "deps": {
    "moonbitlang/async": "0.19.2",
    "moonbit-community/rabbita": "0.11.5"
  },
  "preferred-target": "native",
  "supported-targets": "+wasm+wasm-gc+js+native"
}

Step 2: Implement shared domain validation#

Define request/response types with derive(ToJson, FromJson) and one suberror-based validator in shared/. Both frontend and backend import this package.

import {
  "moonbitlang/core/json" @json,
}
pub(all) struct  {
   : 
} derive(, ToJson, )

///|
pub(all) suberror  {
  
  ()
  
} derive(, , )

///|
pub(all) enum  {
  ()
  ()
  
} derive(, ToJson, )

///|
pub fn (
   : ,
) ->  raise  {
  let  = ..().()
  if .() == 0 {
    raise 
  } else if .() > 24 {
    raise (.())
  } else if .("#") is (_) {
    raise 
  }
}

///|
pub fn ( : ) ->  {
  match  {
     => "title cannot be empty"
    () => "title is too long (\{length}), max is 24"
     => "title cannot contain '#'"
  }
}

///|
pub impl  for  with (, ) {
  let  = match  {
    () => "accepted: \{title}"
    () => "validation_error: \{warning_text(err)}"
     => "invalid_json: invalid request json"
  }
  .()
}

Step 3: Implement the frontend (js)#

Frontend behavior:

  • validate title locally with shared rules

  • if valid, POST to backend /submit

  • display backend response text

import {
  "moonbit-community/fullstack-one-project-doc/shared" @shared,
  "moonbitlang/core/json" @json,
  "moonbit-community/rabbita" @rabbita,
  "moonbit-community/rabbita/html" @html,
  "moonbit-community/rabbita/http" @rhttp,
}

supported_targets = "js"

options(
  "is-main": true,
)
fn main {
  let  = (
    ={ : "", : , :  },
    =(, , ) => {
      match  {
        () => {
          let  = ()
          (, { , , :  })
        }
         =>
          match . {
            () =>
              (
                .,
                { .., : ("not sent: \{message}") },
              )
             => {
              let  = .::{ : . }
              let  = .().()
              let  : [, ] = .::( => {
                  (())
                },
              )
              let  = (
                "http://127.0.0.1:8080/submit",
                ::(),
                ,
              )
              (
                ,
                { .., : ("sending json request...") },
              )
            }
          }
        () => {
          let  = match  {
            () =>
              try {
                let  :  = (
                  (),
                )
                ("\{response}")
              } catch {
                _ => ("invalid backend response json")
              }
            () => ("request failed: \{err}")
          }
          (., { .., , })
        }
      }
    },
    =(, ) => {
      let  = match . {
        () => ("warning: \{message}")
         => ("local validation passed")
      }
      let  = match . {
        () => ("backend response: \{response}")
         => ("backend response: (none yet)")
      }
      let  = .
      ([
        ("Shared Validation Demo"),
        (
          =,
          ,
          = => (()),
          ,
        ),
        (=(), "Submit as JSON"),
        ,
        ,
      ])
    },
  )
  ().("app")
}

Step 4: Implement the backend (native)#

Backend behavior:

  • serve GET / from static backend/index.html

  • serve GET /frontend.js from frontend build output

  • handle POST /submit with shared validation and JSON response

import {
  "moonbit-community/fullstack-one-project-doc/shared" @shared,
  "moonbitlang/core/json" @json,
  "moonbitlang/async",
  "moonbitlang/async/fs" @fs,
  "moonbitlang/async/http" @http,
  "moonbitlang/async/socket" @socket,
  "moonbitlang/async/stdio",
}

supported_targets = "native"

options(
  "is-main": true,
)
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Shared Validation Demo</title>
  </head>
  <body>
    <h1>Shared Validation Demo</h1>
    <p>Backend serves this page and the built frontend bundle.</p>
    <div id="app"></div>
    <script src="/frontend.js"></script>
  </body>
</html>
async fn main {
  .("starting backend on http://127.0.0.1:8080\n")
  let  = (::("127.0.0.1:8080")) catch {
     => {
      ..("failed to start backend: \{err}\n")
      return
    }
  }

  .((, , ) => {
    match (., .) {
      (, "/") =>
        (
          , , , "missing backend/index.html",
        )
      (, "/frontend.js") =>
        (
          , , , "missing frontend bundle; run `moon build frontend --target js`",
        )
      (, "/submit") => {
        let  = .().() catch { _ => "" }
        let  :  = try {
          let  :  = (
            (),
          )
          try {
            ()
            .::(
              ..().(),
            )
          } catch {
             => .::()
          }
        } catch {
          _ => .::
        }
        let  = match  {
          .::(_) => 200
          _ => 400
        }
        let  = if  == 200 { "OK" } else { "BadRequest" }
        
        ..(, , =)
        ..(.().())
        .()
      }
      _ =>
        
        ..(404, "NotFound", =)
        ..("Not Found")
        .()
    }
  })
}

Step 5: Use Makefile shortcuts#

.PHONY: help build-frontend run-backend check test api-test verify verify-all clean

help:
	@echo "Targets:"
	@echo "  make build-frontend  Build frontend JS bundle"
	@echo "  make run-backend     Run backend server on 127.0.0.1:8080"
	@echo "  make check           Run moon check for all targets"
	@echo "  make test            Run moon test for all targets"
	@echo "  make api-test        Run Hurl API tests against local backend"
	@echo "  make verify          Run check + test"
	@echo "  make verify-all      Run verify + api-test"
	@echo "  make clean           Remove build artifacts"

build-frontend:
	moon build frontend --target js

run-backend:
	moon run backend --target native

check:
	moon check --deny-warn --target all

test:
	moon test --deny-warn --target all

api-test: build-frontend
	@command -v hurl >/dev/null 2>&1 || { echo "hurl is required for api-test"; exit 1; }
	@set -eu; \
		moon run backend --target native >/tmp/fullstack-one-project-backend.log 2>&1 & \
		pid=$$!; \
		trap 'kill $$pid >/dev/null 2>&1 || true' EXIT INT TERM; \
		sleep 1; \
		hurl --test backend/api.hurl

verify: check test

verify-all: verify api-test

clean:
	rm -rf _build

Common workflow:

make build-frontend
make run-backend

Then open http://127.0.0.1:8080/ in a browser.

Step 6: Test API with Hurl#

Hurl test suite:

GET http://127.0.0.1:8080/
HTTP 200
[Asserts]
body contains "<div id=\"app\"></div>"

GET http://127.0.0.1:8080/frontend.js
HTTP 200
[Asserts]
body contains "function"

POST http://127.0.0.1:8080/submit
Content-Type: application/json
{
  "title": "Write docs"
}
HTTP 200
[Asserts]
jsonpath "$[0]" == "Accepted"
jsonpath "$[1]" == "Write docs"

POST http://127.0.0.1:8080/submit
Content-Type: application/json
{
  "title": "bad #title"
}
HTTP 400
[Asserts]
jsonpath "$[0]" == "ValidationError"
jsonpath "$[1]" == "ForbiddenHash"

POST http://127.0.0.1:8080/submit
Content-Type: application/json
{
  "title": "01234567890123456789012345"
}
HTTP 400
[Asserts]
jsonpath "$[0]" == "ValidationError"
jsonpath "$[1][0]" == "TooLong"
jsonpath "$[1][1]" == 26

POST http://127.0.0.1:8080/submit
Content-Type: application/json
```
{"title":
```
HTTP 400
[Asserts]
jsonpath "$" == "InvalidJson"

Run it:

make api-test

This verifies:

  • static GET / and GET /frontend.js

  • accepted submit (200)

  • rejected submit (400) for invalid titles

  • rejected submit (400) for malformed JSON input

Step 7: Verify everything#

make verify-all

This runs:

  • moon check --deny-warn --target all

  • moon test --deny-warn --target all

  • Hurl API tests

You now have one executable project where frontend and backend share the same validation contract and error model.