// Sol - SSR-first Web Framework for MoonBit
//
// Built on top of Mars, providing SSR with Luna VNodes.
// Inspired by Next.js, Solid Start, and Qwik City.
//
///|
/// Mars app type alias
pub type App = @mars.Server
///|
/// Mars context type alias
pub type Ctx = @mars.Context
///|
/// Create a new Sol application
pub fn create_app() -> App {
@mars.Server::new()
}
///|
/// Check if running in dev mode (SOL_DEV env variable)
extern "js" fn is_dev_mode() -> Bool =
#| () => process.env.SOL_DEV === "1"
///|
/// Check if running in adapter mode (SOL_ADAPTER env variable)
/// When true, server startup is delegated to the adapter wrapper (serve.js)
extern "js" fn is_adapter_mode() -> Bool =
#| () => process.env.SOL_ADAPTER === "1"
///|
/// HMR client script for dev mode (reads HMR_PORT from environment)
pub fn get_hmr_script() -> String {
let port = get_hmr_port()
""
}
///|
extern "js" fn ffi_init_fs() -> @js.Promise[Unit] =
#| () => import('node:fs').then(fs => { globalThis.__SOL_FS__ = fs; })
///|
/// Create a new Sol application and run callback with it
/// This is useful for main function that cannot be async
pub fn create_app_then(callback : (App) -> Unit) -> Unit {
// Initialize fs module first, then create Server app
let _ = ffi_init_fs().then(fn(_) {
let app = @mars.Server::new()
callback(app)
@js.Promise::resolve(@js.any(()))
})
}
// ============================================================================
// Application Runner (reduces boilerplate)
// ============================================================================
///|
/// Global app reference for non-Node runtimes (Cloudflare, Deno, Bun)
/// This is set by run() and exported as __SOL_APP__
let sol_app_ref : Ref[App?] = { val: None }
///|
extern "js" fn ffi_export_sol_app(fetch_handler : MarsFetchHandler) -> Unit =
#| (fetchHandler) => { globalThis.__SOL_APP__ = { fetch: fetchHandler }; }
///|
/// Type alias for Mars fetch handler with js_async Promise
priv struct MarsFetchHandler((@mars.JsRequest) -> @js.Promise[@mars.JsResponse])
///|
/// Convert Mars Promise type to @js.Promise for FFI compatibility
/// Mars uses moonbitlang/async/js_async.Promise, Sol uses mizchi/js/core.Promise
/// They are the same JS Promise object at runtime
extern "js" fn ffi_convert_fetch_handler(
handler : @js.Any,
) -> MarsFetchHandler =
#| (handler) => handler
///|
/// Check if running in Node.js environment
extern "js" fn ffi_is_node() -> Bool =
#| () => typeof process !== 'undefined' && process.versions?.node != null
///|
/// Run a Sol application with standard configuration
/// This is the recommended way to start a Sol server.
///
/// Example:
/// ```moonbit
/// fn main {
/// @sol.run(fn(app) { @sol.page(app, "/", home_page) |> @sol.serve_static() })
/// }
/// ```
pub fn run(configure : (App) -> Unit, port? : Int) -> Unit {
let port = port.unwrap_or_else(get_port)
create_app_then(fn(app) {
configure(app)
// Store app reference for non-Node runtimes
sol_app_ref.val = Some(app)
// Export fetch handler globally for entry point access
let fetch_handler = app.to_fetch_handler()
ffi_export_sol_app(ffi_convert_fetch_handler(@js.any(fetch_handler)))
// Only start server in Node.js environment (and not in adapter mode)
// In adapter mode, serve.js handles startup instead
if ffi_is_node() && not(is_adapter_mode()) {
println("Server running at http://localhost:" + port.to_string())
serve(app, port)
}
// For other runtimes (Cloudflare, Deno, Bun) or adapter mode, the entry point handles startup
})
}
///|
/// Run a Sol application that creates its own app instance
/// This is used for generated configure_app() functions that take no parameters.
///
/// Example:
/// ```moonbit
/// fn main {
/// @sol.run_app(configure_app)
/// }
/// ```
pub fn run_app(create_and_configure : () -> App, port? : Int) -> Unit {
let port = port.unwrap_or_else(get_port)
// For adapter mode on non-Node runtimes (Cloudflare Workers, Deno, Bun),
// initialize synchronously without fs module
if is_adapter_mode() && not(ffi_is_node()) {
let app = create_and_configure()
sol_app_ref.val = Some(app)
let fetch_handler = app.to_fetch_handler()
ffi_export_sol_app(ffi_convert_fetch_handler(@js.any(fetch_handler)))
return
}
// For Node.js (both adapter and standalone mode), initialize fs module
// first for static file serving, then set up the app
let _ = ffi_init_fs().then(fn(_) {
let app = create_and_configure()
// Store app reference
sol_app_ref.val = Some(app)
// Export fetch handler globally for entry point access
let fetch_handler = app.to_fetch_handler()
ffi_export_sol_app(ffi_convert_fetch_handler(@js.any(fetch_handler)))
// Start server only if not in adapter mode
if not(is_adapter_mode()) {
println("Server running at http://localhost:" + port.to_string())
serve(app, port)
}
@js.Promise::resolve(@js.any(()))
})
}
///|
/// Render a VNode to HTML string with full document wrapper
/// Use hydration=true to enable hydration markers (for pages with Islands)
pub fn[E] render_page(
node : @luna.Node[E, String],
title? : String,
head? : String,
hydration? : Bool,
) -> String {
let title_str = title.unwrap_or("App")
let head_str = head.unwrap_or("")
let enable_hydration = hydration.unwrap_or(false)
let body_html = if enable_hydration {
@render.render_to_string_with_hydration(node)
} else {
@render.render_to_string(node).html
}
"" +
title_str +
"" +
head_str +
"" +
body_html +
"
"
}
///|
/// Render a VNode to HTML string without document wrapper (for partial/fragment responses)
pub fn[E] render_fragment(node : @luna.Node[E, String]) -> String {
@render.render_to_string_with_hydration(node)
}
///|
/// Register a page route that renders a VNode
/// Use hydration=true for pages with Islands (adds loader script and hydration markers)
pub fn[E] page(
app : App,
path : String,
render_fn : (Ctx) -> @luna.Node[E, String],
title? : String,
head? : String,
hydration? : Bool,
loader_url? : String,
) -> Unit {
app.get(
path,
@mars.handler(async fn(c) {
let node = render_fn(c)
let enable_hydration = hydration.unwrap_or(false)
// Add HMR script in dev mode
let dev_script = if is_dev_mode() { get_hmr_script() } else { "" }
if enable_hydration {
// Hydration enabled: render with hydration markers and include loader script
let loader = loader_url.unwrap_or(default_loader_url)
let loader_script = ""
let full_head = head.unwrap_or("") + loader_script + dev_script
let html = render_page(node, title?, head=full_head, hydration=true)
c.html(html)
} else {
// Static page: render without hydration (but still add HMR in dev)
let full_head = head.unwrap_or("") + dev_script
let html = render_page(node, title?, head=full_head)
c.html(html)
}
}),
)
}
///|
/// Register an API route that returns JSON
pub fn api(app : App, path : String, handler : (Ctx) -> Json) -> Unit {
app.get(path, @mars.handler(async fn(c) { c.json(handler(c)) }))
}
///|
/// Create a plain JavaScript object from key-value pairs
/// Use this for API responses instead of MoonBit Map
extern "js" fn ffi_create_json_obj(pairs : Array[(String, @js.Any)]) -> @js.Any =
#| (pairs) => Object.fromEntries(pairs.map(p => [p._0, p._1]))
///|
/// Create a JSON object for API responses
pub fn json_obj(pairs : Array[(String, @js.Any)]) -> @js.Any {
ffi_create_json_obj(pairs)
}
///|
/// FFI to convert MoonBit Json to JavaScript value
extern "js" fn ffi_json_to_any(json : Json) -> @js.Any =
#| (json) => {
#| // Handle MoonBit Json tagged union
#| if (json === null || json === undefined) return null;
#| switch (json.$tag) {
#| case 0: return null; // Null
#| case 1: return json._0; // True -> true
#| case 2: return json._0; // False -> false
#| case 3: return json._0; // Number
#| case 4: return json._0; // String
#| case 5: return json._0.map(j => arguments.callee(j)); // Array
#| case 6: { // Object
#| const result = {};
#| for (const [k, v] of json._0.entries()) {
#| result[k] = arguments.callee(v);
#| }
#| return result;
#| }
#| default: return json;
#| }
#| }
///|
/// Convert MoonBit Json to JavaScript Any value
pub fn json_to_any(json : Json) -> @js.Any {
ffi_json_to_any(json)
}
///|
/// Convert MoonBit Json array to JavaScript Any value
pub fn json_array_to_any(arr : Array[Json]) -> @js.Any {
// Map each Json to Any and return as JS array
let result : Array[@js.Any] = arr.map(fn(j) { ffi_json_to_any(j) })
ffi_array_to_any(result)
}
///|
extern "js" fn ffi_array_to_any(arr : Array[@js.Any]) -> @js.Any =
#| (arr) => arr
///|
/// Register a POST API route
pub fn api_post(app : App, path : String, handler : (Ctx) -> Json) -> Unit {
app.post(path, @mars.handler(async fn(c) { c.json(handler(c)) }))
}
///|
/// Serve the app using @hono/node-server
pub fn serve(app : App, port : Int) -> Unit {
let fetch_handler = app.to_fetch_handler()
ffi_serve(ffi_convert_fetch_handler(@js.any(fetch_handler)), port)
}
///|
extern "js" fn ffi_serve(fetch_handler : MarsFetchHandler, port : Int) -> Unit =
#|(fetchHandler, port) => {
#| import('@hono/node-server').then(({ serve }) => {
#| serve({ fetch: fetchHandler, port });
#| });
#|}
///|
/// Get port from PORT environment variable, default to 7777
pub fn get_port() -> Int {
ffi_get_port()
}
///|
extern "js" fn ffi_get_port() -> Int =
#| () => parseInt(process.env.PORT || '7777', 10)
///|
/// Get HMR port from HMR_PORT environment variable, default to PORT + 100
pub fn get_hmr_port() -> Int {
ffi_get_hmr_port()
}
///|
extern "js" fn ffi_get_hmr_port() -> Int =
#| () => parseInt(process.env.HMR_PORT || String(parseInt(process.env.PORT || '7777', 10) + 100), 10)
///|
/// Set environment variable
pub fn set_env(key : String, value : String) -> Unit {
ffi_set_env(key, value)
}
///|
extern "js" fn ffi_set_env(key : String, value : String) -> Unit =
#| (key, value) => { process.env[key] = value }
// ============================================================================
// Island Architecture Support
// ============================================================================
///|
/// Default loader script URL
pub let default_loader_url : String = "/loader.js"
///|
/// Island configuration
pub struct IslandConfig[E] {
/// Unique identifier for the island
id : String
/// ES module URL for hydration script
script_url : String
/// Hydration trigger type
trigger : @luna.TriggerType
/// State configuration
state : @stella.StateConfig
/// SSR rendered content (VNode)
content : @luna.Node[E, String]?
/// SSR rendered HTML string (alternative to content)
ssr_html : String?
}
///|
/// Create a new island configuration
pub fn[E] IslandConfig::new(
id : String,
script_url : String,
) -> IslandConfig[E] {
{
id,
script_url,
trigger: @luna.TriggerType::Load,
state: @stella.StateConfig::Empty,
content: None,
ssr_html: None,
}
}
///|
/// Builder: set trigger type
pub fn[E] IslandConfig::with_trigger(
self : IslandConfig[E],
trigger : @luna.TriggerType,
) -> IslandConfig[E] {
{ ..self, trigger, }
}
///|
/// Builder: set inline state from JSON
pub fn[E] IslandConfig::with_state(
self : IslandConfig[E],
json : String,
) -> IslandConfig[E] {
{ ..self, state: @stella.StateConfig::Inline(json) }
}
///|
/// Builder: set state from script reference
pub fn[E] IslandConfig::with_state_ref(
self : IslandConfig[E],
script_id : String,
) -> IslandConfig[E] {
{ ..self, state: @stella.StateConfig::ScriptRef(script_id) }
}
///|
/// Builder: set state from URL
pub fn[E] IslandConfig::with_state_url(
self : IslandConfig[E],
url : String,
) -> IslandConfig[E] {
{ ..self, state: @stella.StateConfig::Url(url) }
}
///|
/// Builder: set SSR content from VNode
pub fn[E] IslandConfig::with_content(
self : IslandConfig[E],
node : @luna.Node[E, String],
) -> IslandConfig[E] {
{ ..self, content: Some(node) }
}
///|
/// Builder: set SSR content from raw HTML string
pub fn[E] IslandConfig::with_ssr_html(
self : IslandConfig[E],
html : String,
) -> IslandConfig[E] {
{ ..self, ssr_html: Some(html) }
}
///|
/// Render an island to HTML string
pub fn[E] render_island(config : IslandConfig[E]) -> String {
// Priority: ssr_html > content (VNode) > empty
let ssr_content = match config.ssr_html {
Some(html) => html
None =>
match config.content {
Some(node) => @render.render_to_string_with_hydration(node)
None => ""
}
}
// Convert state to shard format
let shard_config = @stella.ShardConfig::new(config.id, config.script_url)
.with_trigger(config.trigger)
.with_ssr_content(ssr_content)
// Apply state configuration
let final_config = match config.state {
@stella.StateConfig::Empty => shard_config
@stella.StateConfig::Inline(json) => shard_config.with_state(json)
@stella.StateConfig::ScriptRef(id) => shard_config.with_state_ref(id)
@stella.StateConfig::Url(url) => shard_config.with_state_url(url)
}
@stella.generate_shard(final_config).html
}
///|
/// Render a page with islands
/// Includes the loader script automatically
pub fn render_island_page(
islands : Array[String],
title? : String,
head? : String,
loader_url? : String,
) -> String {
let title_str = title.unwrap_or("App")
let head_str = head.unwrap_or("")
let loader = loader_url.unwrap_or(default_loader_url)
let sb = StringBuilder::new(size_hint=4096)
sb.write_string(
"",
)
sb.write_string(title_str)
sb.write_string("")
sb.write_string(head_str)
sb.write_string("")
for island_html in islands {
sb.write_string(island_html)
}
sb.write_string("")
sb.to_string()
}
///|
/// Register an island page route
pub fn[E] island_page(
app : App,
path : String,
render_fn : (Ctx) -> Array[IslandConfig[E]],
title? : String,
head? : String,
loader_url? : String,
) -> Unit {
app.get(
path,
@mars.handler(async fn(c) {
let islands = render_fn(c)
let island_htmls = islands.map(fn(config) { render_island(config) })
let html = render_island_page(island_htmls, title?, head?, loader_url?)
c.html(html)
}),
)
}
///|
/// Render island with separate state script (for large state)
pub fn[E] render_island_with_state_script(
config : IslandConfig[E],
state_json : String,
) -> String {
// Priority: ssr_html > content (VNode) > empty
let ssr_content = match config.ssr_html {
Some(html) => html
None =>
match config.content {
Some(node) => @render.render_to_string_with_hydration(node)
None => ""
}
}
let shard_config = @stella.ShardConfig::new(config.id, config.script_url)
.with_trigger(config.trigger)
.with_ssr_content(ssr_content)
@stella.generate_shard_with_state_script(shard_config, state_json).html
}
///|
/// Generate state script tag for external state
pub fn generate_state_script(id : String, json : String) -> String {
@stella.generate_state_script(id, json)
}
// ============================================================================
// Island VNode Support (using @luna.island)
// ============================================================================
///|
/// Create an Island VNode from a component render function
/// This is the recommended way to create Islands with type-safe VNode children
pub fn[E] island(
id : String,
url : String,
state : String,
children : Array[@luna.Node[E, String]],
trigger? : @luna.TriggerType = @luna.TriggerType::Load,
) -> @luna.Node[E, String] {
@luna.island(id, url, state, children, trigger~)
}
///|
/// Create an Island VNode with a render function for children
pub fn[E] island_with(
id : String,
url : String,
state : String,
render : () -> @luna.Node[E, String],
trigger? : @luna.TriggerType = @luna.TriggerType::Load,
) -> @luna.Node[E, String] {
@luna.island(id, url, state, [render()], trigger~)
}
///|
/// Render a VNode to HTML string
/// Use hydration=true to enable hydration markers
pub fn[E] render_node(
node : @luna.Node[E, String],
hydration? : Bool = false,
) -> String {
if hydration {
@render.render_to_string_with_hydration(node)
} else {
@render.render_to_string(node).html
}
}
// ============================================================================
// Web Components Island VNode Support (using @luna.wc_island)
// ============================================================================
///|
/// Default wc-loader script URL
pub let default_wc_loader_url : String = "/static/wc-loader.js"
///|
/// Create a Web Components Island VNode
/// Uses Declarative Shadow DOM for SSR and wc-loader for hydration
pub fn[E] wc_island(
name : String,
url : String,
styles : String,
state : String,
children : Array[@luna.Node[E, String]],
trigger? : @luna.TriggerType = @luna.TriggerType::Load,
) -> @luna.Node[E, String] {
@luna.wc_island(name, url, styles, state, children, trigger~)
}
///|
/// Create a Web Components Island VNode with a render function for children
pub fn[E] wc_island_with(
name : String,
url : String,
styles : String,
state : String,
render : () -> @luna.Node[E, String],
trigger? : @luna.TriggerType = @luna.TriggerType::Load,
) -> @luna.Node[E, String] {
@luna.wc_island(name, url, styles, state, [render()], trigger~)
}
///|
/// Register a page route with Web Components islands
/// Uses wc-loader for hydration
pub fn[E] wc_page(
app : App,
path : String,
render_fn : (Ctx) -> @luna.Node[E, String],
title? : String,
head? : String,
wc_loader_url? : String,
) -> Unit {
app.get(
path,
@mars.handler(async fn(c) {
let node = render_fn(c)
let loader = wc_loader_url.unwrap_or(default_wc_loader_url)
let loader_script = ""
// Add HMR script in dev mode
let dev_script = if is_dev_mode() { get_hmr_script() } else { "" }
let full_head = head.unwrap_or("") + loader_script + dev_script
let html = render_page(node, title?, head=full_head, hydration=true)
c.html(html)
}),
)
}
// ============================================================================
// Streaming SSR Support
// ============================================================================
///|
/// Render a page with streaming SSR and async boundaries (VAsync nodes)
/// Returns a ReadableStream for HTTP response
/// VAsync nodes in the tree will be rendered with fallback first,
/// then resolved content will be streamed as replacement scripts
pub fn render_streaming_page(
shell : @luna.Node[Unit, String],
title? : String,
head? : String,
loader_url? : String,
) -> @http.Response {
let title_str = title.unwrap_or("App")
let head_str = head.unwrap_or("")
let loader = loader_url.unwrap_or(default_loader_url)
// Build header HTML
let header_html = "" +
title_str +
"" +
head_str +
""
// Build footer HTML
let footer_html = "
"
// Create streaming response with async resolution
let stream = create_streaming_response_with_async(
header_html, footer_html, shell,
)
ffi_create_stream_response(stream)
}
///|
/// Create a ReadableStream that streams HTML with async resolution
fn create_streaming_response_with_async(
header : String,
footer : String,
shell : @luna.Node[Unit, String],
) -> @js.Any {
ffi_create_streaming_response_async(header, footer, async fn(write) {
// Use render_to_stream_with_async which handles VAsync nodes
@stream_renderer.render_to_stream_with_async(
shell,
@stream_renderer.StreamWriter::from_callback(write),
)
})
}
///|
extern "js" fn ffi_create_streaming_response_async(
header : String,
footer : String,
render_with_async : async ((String) -> Unit) -> Unit,
) -> @js.Any =
#| (header, footer, renderWithAsync) => new ReadableStream({
#| async start(controller) {
#| const encoder = new TextEncoder();
#| const write = (chunk) => controller.enqueue(encoder.encode(chunk));
#| write(header);
#| await renderWithAsync(write);
#| write(footer);
#| controller.close();
#| }
#| })
///|
extern "js" fn ffi_create_stream_response(stream : @js.Any) -> @http.Response =
#| (stream) => new Response(stream, { headers: { 'Content-Type': 'text/html; charset=utf-8' } })
///|
/// Register a streaming page route with async support
/// VAsync nodes in the rendered tree will be handled automatically
/// Note: This uses ReadableStream internally for proper streaming support
pub fn streaming_page(
app : App,
path : String,
render_fn : (Ctx) -> @luna.Node[Unit, String],
title? : String,
head? : String,
loader_url? : String,
) -> Unit {
app.get(
path,
@mars.handler(async fn(c) {
let shell = render_fn(c)
let title_str = title.unwrap_or("App")
let head_str = head.unwrap_or("")
let loader = loader_url.unwrap_or(default_loader_url)
let header_html = "" +
title_str +
"" +
head_str +
""
let footer_html = "
"
// Create streaming response and send via FFI
let stream = create_streaming_response_with_async(
header_html, footer_html, shell,
)
ffi_set_streaming_response(c, stream)
}),
)
}
///|
/// Set streaming response via FFI (for Mars Context)
extern "js" fn ffi_set_streaming_response(ctx : Ctx, stream : @js.Any) -> Unit =
#| (ctx, stream) => {
#| const response = new Response(stream, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
#| ctx.platform.response_resolve(response);
#| }
///|
/// Render a VNode to HTML with preload URLs for islands
/// Returns SSRResult with html and preload_urls
pub fn[E] render_with_island_preloads(
node : @luna.Node[E, String],
) -> @render.SSRResult {
@render.render_to_string(node, preload=true)
}
///|
/// Render a page with automatic modulepreload tags for islands
pub fn[E] render_page_with_preloads(
node : @luna.Node[E, String],
title? : String,
head? : String,
hydration? : Bool,
) -> String {
let title_str = title.unwrap_or("App")
let enable_hydration = hydration.unwrap_or(false)
// Render and collect preload URLs
let _ = enable_hydration // Reserved for future use
let result = @render.render_to_string(node, preload=true)
// Generate preload tags
let preload_tags = @render.generate_preload_tags(result.preload_urls)
// Combine head with preload tags
let head_str = head.unwrap_or("") + preload_tags
"" +
title_str +
"" +
head_str +
"" +
result.html +
"
"
}
// ============================================================================
// Static File Serving
// ============================================================================
///|
extern "js" fn ffi_create_js_response(
body : String,
content_type : String,
) -> @http.Response =
#| (body, contentType) => new Response(body, { headers: { 'Content-Type': contentType } })
///|
/// Create a response for static files with security headers
extern "js" fn ffi_create_static_response(
body : String,
content_type : String,
cache_control : String,
) -> @http.Response =
#| (body, contentType, cacheControl) => new Response(body, { headers: { 'Content-Type': contentType, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': cacheControl } })
///|
extern "js" fn ffi_read_file_sync(path : String) -> String? =
#| (path) => {
#| try {
#| if (!globalThis.__SOL_FS__) {
#| throw new Error('fs module not initialized. Call __SOL_INIT_FS__ first.');
#| }
#| return globalThis.__SOL_FS__.readFileSync(path, 'utf-8');
#| } catch (e) {
#| return undefined;
#| }
#| }
///|
/// Create a text response with content type
pub fn text_response(body : String, content_type : String) -> @http.Response {
ffi_create_js_response(body, content_type)
}
///|
/// Create a JavaScript response
pub fn js_response(body : String) -> @http.Response {
ffi_create_js_response(body, "application/javascript")
}
///|
/// Create a static file response with security headers
pub fn static_response(
body : String,
content_type : String,
cache_control? : String,
) -> @http.Response {
let cache = cache_control.unwrap_or("max-age=3600")
ffi_create_static_response(body, content_type, cache)
}
///|
/// Read file synchronously (for static file serving)
pub fn read_file_sync(path : String) -> String? {
ffi_read_file_sync(path)
}
///|
/// Get request pathname from context
pub fn get_request_path(ctx : Ctx) -> String {
ctx.path()
}
///|
/// Static file configuration
pub struct StaticFileConfig {
/// URL path prefix (e.g., "/static")
path_prefix : String
/// Local directory path (e.g., "static")
local_dir : String
/// File mappings: (url_filename, local_path)
mappings : Array[(String, String)]
/// Cache-Control header value (default: "max-age=3600", use "no-cache, no-store" for dev)
cache_control : String
}
///|
/// Create default static file config for Luna apps
pub fn StaticFileConfig::default() -> StaticFileConfig {
{
path_prefix: "/static",
local_dir: "static",
mappings: [("loader.js", "static/loader.js")],
cache_control: "max-age=3600",
}
}
///|
/// Create static file config for development (no caching)
pub fn StaticFileConfig::dev() -> StaticFileConfig {
{
path_prefix: "/static",
local_dir: "static",
mappings: [("loader.js", "static/loader.js")],
cache_control: "no-cache, no-store, must-revalidate",
}
}
///|
/// Builder: add file mapping
pub fn StaticFileConfig::with_mapping(
self : StaticFileConfig,
url_filename : String,
local_path : String,
) -> StaticFileConfig {
let new_mappings = self.mappings.copy()
new_mappings.push((url_filename, local_path))
{ ..self, mappings: new_mappings }
}
///|
/// Configuration for serving a static directory (SSG output)
pub(all) struct StaticDirServeConfig {
/// URL path prefix (e.g., "/docs")
path_prefix : String
/// Local directory path (relative to cwd)
local_dir : String
}
///|
/// Create default StaticDirServeConfig
pub fn StaticDirServeConfig::new(
path_prefix : String,
_source_dir : String,
) -> StaticDirServeConfig {
// Map to .sol/prod/static/{path_prefix}
let local_dir = ".sol/prod/static" +
path_prefix.trim_end(chars="/").to_string()
{ path_prefix, local_dir }
}
///|
/// Register a static directory for serving SSG output
/// This serves HTML files from .sol/prod/static/{path_prefix}
pub fn serve_static_dir(app : App, config : StaticDirServeConfig) -> Unit {
let prefix = config.path_prefix.trim_end(chars="/").to_string()
let local_dir = config.local_dir
// Serve index.html for directory roots
app.get(
prefix,
@mars.handler(async fn(c) {
serve_static_html_async(c, local_dir, "index.html")
}),
)
// Serve all paths under the prefix
app.get(
prefix + "/*",
@mars.handler(async fn(c) {
let path = c.path()
let start_idx = prefix.length() + 1
let rest = path[start_idx:].to_string().trim_end(chars="/").to_string()
// Try exact path first, then as directory index
if rest.is_empty() {
serve_static_html_async(c, local_dir, "index.html")
} else if rest.has_suffix(".html") {
serve_static_html_async(c, local_dir, rest)
} else if rest.has_suffix(".css") {
serve_static_css_async(c, local_dir, rest)
} else if rest.has_suffix(".js") {
serve_static_js_async(c, local_dir, rest)
} else {
// Try as directory with index.html
serve_static_html_async(c, local_dir, rest + "/index.html")
}
}),
)
}
///|
fn serve_static_html_async(
c : Ctx,
local_dir : String,
filename : String,
) -> Unit {
let path = local_dir + "/" + filename
match read_file_sync(path) {
Some(content) => c.html(content)
None => c.text("Not found: " + filename)
}
}
///|
fn serve_static_css_async(
c : Ctx,
local_dir : String,
filename : String,
) -> Unit {
let path = local_dir + "/" + filename
c.set_header("Content-Type", "text/css")
match read_file_sync(path) {
Some(content) => c.text(content)
None => c.text("/* Not found: " + filename + " */")
}
}
///|
fn serve_static_js_async(
c : Ctx,
local_dir : String,
filename : String,
) -> Unit {
let path = local_dir + "/" + filename
c.set_header("Content-Type", "application/javascript")
match read_file_sync(path) {
Some(content) => c.text(content)
None => c.text("// Not found: " + filename)
}
}
///|
/// Register static file serving middleware
pub fn serve_static(app : App, config? : StaticFileConfig) -> Unit {
let cfg = config.unwrap_or(StaticFileConfig::default())
let prefix = cfg.path_prefix
let mappings = cfg.mappings
// In dev mode, disable caching to avoid stale JS/CSS issues
let cache_ctl = if is_dev_mode() {
"no-cache, no-store, must-revalidate"
} else {
cfg.cache_control
}
app.get(
prefix + "/*",
@mars.handler(async fn(c) {
let path = c.path()
let start_idx = prefix.length() + 1
let filename = path[start_idx:].to_string() // Remove prefix + "/"
// Check mappings
for mapping in mappings {
let (url_file, local_path) = mapping
if filename == url_file {
match read_file_sync(local_path) {
Some(content) => {
let content_type = if url_file.has_suffix(".js") {
"application/javascript"
} else if url_file.has_suffix(".css") {
"text/css"
} else {
"text/plain"
}
c.set_header("Content-Type", content_type)
c.set_header("Cache-Control", cache_ctl)
c.set_header("X-Content-Type-Options", "nosniff")
c.text(content)
return
}
None => {
c.set_header("Content-Type", "application/javascript")
c.set_header("Cache-Control", cache_ctl)
c.set_header("X-Content-Type-Options", "nosniff")
c.text("// " + url_file + " not found")
return
}
}
}
}
// Fallback: try to read from local_dir/filename, then .sol/{dev,prod}/static/filename
// Dev is checked first as it's more commonly used during development
let local_path = cfg.local_dir + "/" + filename
let sol_dev_path = ".sol/dev/static/" + filename
let sol_prod_path = ".sol/prod/static/" + filename
let content = match read_file_sync(local_path) {
Some(c) => Some(c)
None =>
match read_file_sync(sol_dev_path) {
Some(c) => Some(c)
None => read_file_sync(sol_prod_path)
}
}
match content {
Some(content) => {
let content_type = if filename.has_suffix(".js") {
"application/javascript"
} else if filename.has_suffix(".css") {
"text/css"
} else if filename.has_suffix(".html") {
"text/html"
} else {
"text/plain"
}
c.set_header("Content-Type", content_type)
c.set_header("Cache-Control", cache_ctl)
c.set_header("X-Content-Type-Options", "nosniff")
c.text(content)
}
None => {
c.set_header("Content-Type", "text/plain")
c.set_header("Cache-Control", cache_ctl)
c.set_header("X-Content-Type-Options", "nosniff")
c.text("Not found: " + filename)
}
}
}),
)
}