Thin Mars wrapper for file-based routing, SSR, and asset loading
Dependencies
// moon.mod.json
{ "deps": { "mizchi/sol": "0.21.1", "mizchi/luna": "0.21.0" } }moon install mizchi/sol/cmd/sol # → $MOON_HOME/bin/sol
# or via npm
pnpm add -g @luna_ui/sol # npm wrapper 0.20.0cd sol/examples/sol_app
pnpm install
sol devsol new myapp --user mizchi --cloudflare
cd myapp
pnpm install
pnpm devimport solApp from "./.sol/prod/server/main.js";
import apiWorker from "./worker-api.mjs";
export default {
fetch(request, env, ctx) {
const path = new URL(request.url).pathname;
return path.startsWith("/api/")
? apiWorker.fetch(request, env, ctx)
: solApp.fetch(request, env, ctx);
},
};just -f ../astra/justfile sync-luna-assets ../luna.mbt# Create new project
sol new myapp --user yourname
cd myapp
# Install dependencies
pnpm install
# Start development server
pnpm dev
# Build and deploy preflight (Cloudflare Workers default)
sol build
sol deploy --dry-runexport default {
islands: ["app/client"],
routes: "app/server",
output: "app/__gen__",
runtime: "node",
// Defaults preserve the generated Sol pipeline.
serverEntry: "auto", // "auto" | "generated" | "user"
clientBundle: "auto", // "auto" | "external"
client_auto_exports: false,
wasmEntryPoints: [
{
id: "users_show",
route: "/users/:id",
source: "app/entries/users_show.mbtx",
runtime: "wagi", // "wagi" | "wasi-cli" | "component"
method: ["GET"],
},
],
}let client_manifest =
#|{
#| "app/client/counter.ts": {
#| "file": "assets/counter-abc123.js",
#| "imports": ["assets/runtime-def456.js"]
#| }
#|}
pub fn config() -> @sol.RouterConfig {
@sol.RouterConfig::default()
.with_client_manifest_json(client_manifest, base_url="/static/")
}@sol.island(
@types.counter_at(
"/static/assets/counter-abc123.js",
{ initial_count: 42 },
),
[counter_ssr()],
)export default {
clientBundle: "external",
contractTs: [
{
input: "app/client/props.ts",
props: "CounterProps",
package: "app/client",
clientUrl: "/static/assets/counter-abc123.js",
},
],
}{
"routes": [
{
"path": "/api/items/:id",
"query": [
{ "name": "token" },
{ "name": "include", "optional": true }
]
}
],
"actions": [
{
"id": "submit-contact",
"request": {
"name": "SubmitContactRequest",
"fields": [{ "name": "email", "type": "String" }]
},
"response": {
"name": "SubmitContactResponse",
"fields": [{ "name": "ok", "type": "Bool" }]
}
}
],
"apis": [
{
"method": "POST",
"path": "/api/items/:id",
"request": {
"name": "CreateItemRequest",
"fields": [{ "name": "title", "type": "String" }]
},
"response": {
"name": "ItemResponse",
"fields": [{ "name": "id", "type": "String" }]
}
}
]
}{
"$schema": "./schemas/sol.config.schema.json"
}myapp/
├── moon.mod.json # MoonBit module definition
├── package.json # npm package definition
├── sol.config.json # Sol config file
├── worker.entry.mjs # Cloudflare starter compose point (--cloudflare)
├── wrangler.toml # Cloudflare Workers config (--cloudflare)
├── app/
│ ├── server/ # Server components
│ │ ├── moon.pkg
│ │ └── routes.mbt # routes() + config() + page functions
│ ├── client/ # Client components (Islands)
│ │ ├── moon.pkg
│ │ ├── counter.mbt # render + hydrate functions
│ │ └── api_tools.mbt # copy/status/format starter controls
│ └── __gen__/ # Auto-generated (sol generate)
│ ├── client/ # Client exports
│ └── server/ # Server entry point
└── static/
└── loader.js # Island loadersol new myapp --user mizchi # Create mizchi/myapp package
sol new myapp --user mizchi --cloudflare
sol new myapp --user mizchi --dev # Use local luna path (for development)sol dev # Default port 7777
sol dev --port 8080 # Specify port
sol dev --clean # Clear cache and buildsol build # JS target (default)
sol build --target wasm # WASM target
sol build --skip-bundle # Skip rolldown
sol build --skip-generate # Skip generation
sol build --clean # Clear cache and build
sol build --wasm-entrypoints --runtime wagi --emit-spin-fragmentsol serve # Default port 7777
sol serve --port 8080 # Specify portsol deploy # Dry-run guidance
sol deploy --provider cloudflare-workers --project my-worker
sol deploy --execute # Execute wrangler commandsol doctor # Print warnings/errors
sol doctor --strict # Treat warnings as failuresNote: Usually sol dev and sol build call this internally, so explicit execution is not needed.
sol generate # Use sol.config.ts or sol.config.json (default: prod)
sol generate --mode dev # Development mode (outputs to .sol/dev/)
sol generate --mode prod # Production mode (outputs to .sol/prod/)sol clean # Delete .sol/, app/__gen__/, _build/// app/server/routes.mbt
pub fn routes() -> Array[@router.SolRoutes] {
[
// Page route
@router.SolRoutes::Page(
path="/",
handler=@router.PageHandler(home_page),
title="Home",
meta=[],
revalidate=None,
cache=None,
),
// API route (GET)
@router.SolRoutes::Get(
path="/api/health",
handler=@router.ApiHandler(api_health),
),
// API route (POST)
@router.SolRoutes::Post(
path="/api/submit",
handler=@router.ApiHandler(api_submit),
),
// Nested layout
@router.SolRoutes::Layout(
segment="/admin",
layout=admin_layout,
children=[
@router.SolRoutes::Page(path="/", handler=@router.PageHandler(admin_dashboard), title="Admin", meta=[], revalidate=None, cache=None),
@router.SolRoutes::Page(path="/users", handler=@router.PageHandler(admin_users), title="Users", meta=[], revalidate=None, cache=None),
],
),
// Apply middleware
@router.SolRoutes::WithMiddleware(
middleware=[@middleware.cors(), @middleware.logger()],
children=[
@router.SolRoutes::Get(path="/api/data", handler=@router.ApiHandler(api_data)),
],
),
]
}
pub fn config() -> @router.RouterConfig {
@router.RouterConfig::default()
.with_default_head(head())
.with_loader_url("/static/loader.js")| Variant | Description |
|---|---|
| Page | Page route (HTML response) |
| Get | GET API route (JSON response) |
| Post | POST API route (JSON response) |
| RawGet / RawPost / RawPut / RawDelete / RawPatch | Raw Web Response route |
| Layout | Nested layout group |
| WithMiddleware | Route group with middleware applied |
import solApp from "./.sol/prod/server/main.js";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname.startsWith("/api/")) {
return apiFetch(request, env, ctx);
}
return solApp.fetch(request, env, ctx);
},
};// Middleware composition
let middleware = @middleware.logger()
.then(@middleware.cors())
.then(@middleware.security_headers())
// Apply to routes
@router.SolRoutes::WithMiddleware(
middleware=[middleware],
children=[...],
)| Middleware | Description |
|---|---|
| logger() | Request logging |
| cors() | CORS headers |
| csrf() | CSRF protection |
| security_headers() | Security headers |
| nosniff() | X-Content-Type-Options |
| frame_options(value) | X-Frame-Options |
@middleware.cors_with_config(
@middleware.CorsConfig::default()
.with_origin_single("https://example.com")
.with_methods(["GET", "POST"])
.with_credentials()
)@middleware.security_headers_with_config(
@middleware.SecurityHeadersConfig::default()
.with_csp("default-src 'self'")
.with_frame_options("DENY")
)fn require_admin() -> @middleware.Middleware {
@middleware.Middleware(@mars.Handler(async fn(ctx) {
match ctx.header("Authorization") {
Some(token) if token.has_prefix("Bearer ") => ()
_ => ctx.json({ "error": "unauthorized" }.to_json(), status=401)
}
}))
}
pub fn routes() -> Array[@sol.SolRoutes] {
[
@sol.route("/", home),
@sol.with_mw([require_admin()], [
@sol.route("/admin", admin_page),
@sol.api_post("/api/items", create_item),
]),
]
}// Sequential execution (m1 → m2)
let combined = @middleware.then_(m1, m2)
// or
let combined = m1.then(m2)
// Compose from array
let pipeline = @middleware.pipeline([m1, m2, m3])struct SubmitRequest {
value : String
} derive(FromJson)
struct SubmitResponse {
success : Bool
} derive(ToJson)
// Define action handler
let submit_handler = @action.ActionHandler(async fn(ctx) {
let req : SubmitRequest = match ctx.decode_json() {
Some(req) => req
None => return @action.ActionResult::bad_request("Invalid JSON payload")
}
// ... processing with req
@action.ActionResult::json(SubmitResponse::{ success: true })
})
// Register to registry
pub fn action_registry() -> @action.ActionRegistry {
@action.ActionRegistry::new(allowed_origins=["http://localhost:7777"])
.register(@action.ActionDef::from_key(@types.action_submit(), submit_handler))
}| Type | Description |
|---|---|
| Success(data) | Success, returns JSON data |
| Redirect(url) | Client-side redirect (returns JSON with redirect instruction) |
| HttpRedirect(url) | HTTP redirect (returns 302 with Location header) |
| ClientError(status, msg) | Client error (4xx) |
| ServerError(msg) | Server error (5xx) |
// app/client/counter.mbt
pub fn counter(count : @signal.Signal[Int]) -> @luna.Node[CounterAction] {
div(class="counter", [
span(class="count-display", [text_of(count)]),
button(onclick=@luna.action(Increment), [text("+")]),
button(onclick=@luna.action(Decrement), [text("-")]),
])
}// Auto-generated: app/__gen__/types/types.mbt
pub struct CounterProps { initial_count : Int } derive(ToJson, FromJson)
pub fn counter(props : CounterProps, trigger~ : @luna.Trigger) -> @luna.ComponentRef[CounterProps]// app/server/home.mbt
let counter_props : @types.CounterProps = { initial_count: 42 }
// Type-safe island embedding
@sol.island(
@types.counter(counter_props),
[div([button([text("Count: 42")])])], // SSR fallback children
)@server_dom.client(
@types.counter(counter_props),
[div([text("Loading...")])],
)@sol.island_raw("counter", "/static/counter.js", props_json, children)| Trigger | Description |
|---|---|
| Load | Immediately on page load |
| Idle | On requestIdleCallback |
| Visible | On IntersectionObserver detection |
| Media(query) | On media query match |
| None | Manual trigger |
<script>
window.__LUNA_ALLOWED_HOSTS__ = [
"127.0.0.1:3456",
"https://cdn.example.com"
];
</script>window.__LUNA_SET_ALLOWED_HOSTS__?.(["127.0.0.1"]);
window.__LUNA_SCAN__?.();sol_link(href="/about", [text("About")])@server_dom.ServerNode::async_(async fn() {
let data = fetch_data() // async function call
div([text(data)])
})pub fn config() -> @router.RouterConfig {
@router.RouterConfig::default()
.with_default_head(head())
.with_loader_url("/static/loader.js")
.with_streaming_ssr()
}Note: streaming response uses root_template when __LUNA_MAIN__ exists; otherwise it falls back to built-in page shell.
src/sol/
├── runtime.mbt # Core API
├── compiler.mbt # Rolldown bindings
├── router/ # Hono router integration
│ ├── router.mbt # Route registration
│ ├── sol_routes.mbt # SolRoutes definition
│ └── fragment.mbt # Fragment response (CSR)
├── middleware/ # Middleware system
│ ├── types.mbt # MwContext, MwRequest, MwResponse
│ ├── compose.mbt # Composition functions (then, pipeline)
│ ├── logger.mbt # Logger middleware
│ ├── cors.mbt # CORS middleware
│ ├── csrf.mbt # CSRF middleware
│ └── security_headers.mbt # Security headers
├── action/ # Server Actions
│ ├── action.mbt # ActionHandler, ActionResult
│ └── router.mbt # register_actions
└── cli/ # CLI tools
├── main.mbt # Entry point
├── new.mbt # sol new
├── dev.mbt # sol dev
├── build.mbt # sol build
├── serve.mbt # sol serve
├── generate.mbt # sol generate
└── clean.mbt # sol cleanThin Mars wrapper for file-based routing, SSR, and asset loading
Dependencies