Thin Mars wrapper for file-based routing, SSR, and asset loading
# Start development server in playground
cd examples/sol_app
pnpm install
just sol dev# 1) Sync from luna.mbt dist -> Sol canonical assets -> examples/static
just sync-luna-assets ../luna.mbt
# 2) Check sync status only (CI-friendly)
just check-luna-assets ../luna.mbt
# 3) Re-sync examples/static from canonical assets
just sync-example-assets# 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",
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"],
},
],
}{
"$schema": "./schemas/sol.config.schema.json"
}myapp/
├── moon.mod.json # MoonBit module definition
├── package.json # npm package definition
├── sol.config.json # Sol config file
├── app/
│ ├── server/ # Server components
│ │ ├── moon.pkg
│ │ └── routes.mbt # routes() + config() + page functions
│ ├── client/ # Client components (Islands)
│ │ ├── moon.pkg
│ │ └── counter.mbt # render + hydrate functions
│ └── __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 --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 commandNote: 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) |
| Layout | Nested layout group |
| WithMiddleware | Route group with middleware applied |
// 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")
)// 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])// Define action handler
let submit_handler = @action.ActionHandler(async fn(ctx) {
let body = ctx.body
// ... processing
@action.ActionResult::ok(@js.any({ "success": true }))
})
// Register to registry
pub fn action_registry() -> @action.ActionRegistry {
@action.ActionRegistry::new(allowed_origins=["http://localhost:7777"])
.register(@action.ActionDef::new("submit-form", 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("-")]),
])
}| 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 clean# Start development server in playground
cd examples/sol_app
npm install
just sol dev# Create new project
sol new myapp --user yourname
cd myapp
# Install dependencies
npm install
# Start development server
npm run devexport default {
islands: ["app/client"],
routes: "app/server",
output: "app/__gen__",
runtime: "node",
client_auto_exports: false,
}{
"$schema": "./schemas/sol.config.schema.json"
}myapp/
├── moon.mod.json # MoonBit module definition
├── package.json # npm package definition
├── sol.config.json # Sol config file
├── app/
│ ├── server/ # Server components
│ │ ├── moon.pkg
│ │ └── routes.mbt # routes() + config() + page functions
│ ├── client/ # Client components (Islands)
│ │ ├── moon.pkg
│ │ └── counter.mbt # render + hydrate functions
│ └── __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 --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 buildsol serve # Default port 7777
sol serve --port 8080 # Specify portNote: 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) |
| Layout | Nested layout group |
| WithMiddleware | Route group with middleware applied |
// Admin section layout
using @server_dom {
h1,
nav,
div,
text,
sol_link,
}
fn admin_layout(
props : @router.PageProps,
content : @server_dom.ServerNode,
) -> @server_dom.ServerNode raise {
@server_dom.ServerNode::sync(@luna.fragment([
h1([text("Admin Panel")]),
nav([
sol_link(href="/admin", [text("Dashboard")]),
sol_link(href="/admin/users", [text("Users")]),
]),
div(class="admin-content", [content.to_vnode()]),
]))
}
// Route definition
// segment="/admin" + path="/" => /admin
// segment="/admin" + path="/users" => /admin/users
@router.SolRoutes::Layout(
segment="/admin", // URL prefix
layout=admin_layout, // Layout function
children=[
// /admin
@router.SolRoutes::Page(path="/", handler=@router.PageHandler(admin_dashboard), title="Admin", meta=[], revalidate=None, cache=None),
// /admin/users
@router.SolRoutes::Page(path="/users", handler=@router.PageHandler(admin_users), title="Users", meta=[], revalidate=None, cache=None),
],
)// 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")
)// 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])// Define action handler
let submit_handler = @action.ActionHandler(async fn(ctx) {
let body = ctx.body
// ... processing
@action.ActionResult::ok(@js.any({ "success": true }))
})
// Register to registry
pub fn action_registry() -> @action.ActionRegistry {
@action.ActionRegistry::new(allowed_origins=["http://localhost:7777"])
.register(@action.ActionDef::new("submit-form", 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("-")]),
])
}| Trigger | Description |
|---|---|
| Load | Immediately on page load |
| Idle | On requestIdleCallback |
| Visible | On IntersectionObserver detection |
| Media(query) | On media query match |
| None | Manual trigger |
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 cleanpub struct BundleConfig {
input : Array[(String, String)]
output_dir : String
format : OutputFormat
entry_file_names : String
chunk_file_names : String
minify : Bool
sourcemap : Bool
}pub struct IslandConfig[E] {
id : String
script_url : String
trigger : TriggerType
state : StateConfig
content : Node[E, String]?
ssr_html : String?
}pub struct IslandManifestEntry {
name : String
entry_path : String
}pub enum OutputFormat {
Esm
}#external
pub type RolldownChunk#external
pub type RolldownOutputpub(all) struct StaticDirServeConfig {
path_prefix : String
local_dir : String
}pub struct StaticFileConfig {
path_prefix : String
local_dir : String
mappings : Array[(String, String)]
cache_control : String
}fn StaticFileConfig::with_mapping(self : StaticFileConfig, url_filename : String, local_path : String) -> StaticFileConfigfn build_from_manifest_file(manifest_path : String, cwd : String, on_success : (Array[String]) -> Unit, on_error : (String) -> Unit) -> Unitasync fn bundle_islands_prod(islands : Array[(String, String)], output_dir : String) -> RolldownOutputasync fn bundle_islands_result(islands : Array[(String, String)], output_dir : String) -> BundleResultfn generate_state_script(id : String, json : String) -> Stringfn[E] island(id : String, url : String, state : String, children : Array[Node[E, String]], trigger? : TriggerType) -> Node[E, String]fn[E] island_page(app : Server, path : String, render_fn : (Context) -> Array[IslandConfig[E]], title? : String, head? : String, loader_url? : String) -> Unitfn[E] island_with(id : String, url : String, state : String, render : () -> Node[E, String], trigger? : TriggerType) -> Node[E, String]fn render_island_page(islands : Array[String], title? : String, head? : String, loader_url? : String) -> Stringfn[E] render_page(node : Node[E, String], title? : String, head? : String, hydration? : Bool) -> Stringfn[E] render_page_with_preloads(node : Node[E, String], title? : String, head? : String, hydration? : Bool) -> Stringfn main {
@sol.run(fn(app) { @sol.page(app, "/", home_page) |> @sol.serve_static() })
}fn main {
@sol.run_app(configure_app)
}fn[E] wc_island(name : String, url : String, styles : String, state : String, children : Array[Node[E, String]], trigger? : TriggerType) -> Node[E, String]fn[E] wc_island_with(name : String, url : String, styles : String, state : String, render : () -> Node[E, String], trigger? : TriggerType) -> Node[E, String]Install
Download zipThin Mars wrapper for file-based routing, SSR, and asset loading