Current section

Files

Jump to
libero src libero@codegen_server.erl
Raw

src/libero@codegen_server.erl

-module(libero@codegen_server).
-compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]).
-define(FILEPATH, "src/libero/codegen_server.gleam").
-export([write_websocket/2, write_atoms/2, write_if_missing/2, write_main/6]).
-if(?OTP_RELEASE >= 27).
-define(MODULEDOC(Str), -moduledoc(Str)).
-define(DOC(Str), -doc(Str)).
-else.
-define(MODULEDOC(Str), -compile([])).
-define(DOC(Str), -compile([])).
-endif.
?MODULEDOC(
" Server-side output generators.\n"
"\n"
" Emits `websocket.gleam`, the atoms registration `.erl` file, and the\n"
" `<app>.gleam` entry point. These all land in the server package and\n"
" only compile on the Erlang target.\n"
).
-file("src/libero/codegen_server.gleam", 20).
?DOC(
" Generate the server websocket handler at `server_generated/websocket.gleam`.\n"
" Also writes the Erlang FFI for decoding push messages.\n"
).
-spec write_websocket(binary(), binary()) -> {ok, nil} |
{error, libero@gen_error:gen_error()}.
write_websocket(Server_generated, Context_module) ->
Gleam_output = <<Server_generated/binary, "/websocket.gleam"/utf8>>,
Module_path = libero@codegen:last_split(Server_generated, <<"src/"/utf8>>),
Content = <<<<<<<<"//// Code generated by libero. DO NOT EDIT.
////
//// WebSocket handler for mist. Handles dispatch, push frame
//// forwarding, and topic cleanup on disconnect.
import gleam/dynamic/decode
import gleam/erlang/atom
import gleam/erlang/process
import gleam/http/request.{type Request}
import gleam/list
import gleam/option.{type Option, Some}
import gleam/result
import gleam/string
import libero/push
import libero/ws_logger.{type Logger}
import mist.{type Connection}
import "/utf8,
Module_path/binary>>/binary,
"/dispatch
import "/utf8>>/binary,
Context_module/binary>>/binary,
".{type HandlerContext}
pub type ConnState {
ConnState(handler_ctx: HandlerContext, topics: List(String), logger: Logger)
}
type PushMsg {
PushFrame(BitArray)
Ignored
}
/// Wire up a WebSocket upgrade with dispatch, push, and topic management.
/// The `logger` argument receives connect/disconnect, panic, and send
/// failure messages. Use `ws_logger.default_logger()` for stdout output
/// or pass your own structured logger.
pub fn upgrade(
request req: Request(Connection),
handler_ctx handler_ctx: HandlerContext,
topics topics: List(String),
logger logger: Logger,
) {
mist.websocket(
request: req,
handler: handler,
on_init: on_init(handler_ctx, topics, logger),
on_close: fn(conn_state: ConnState) {
list.each(conn_state.topics, fn(t) { push.leave(topic: t) })
logger.debug(\"WebSocket: disconnected\")
},
)
}
fn on_init(handler_ctx: HandlerContext, topics: List(String), logger: Logger) {
fn(_conn: mist.WebsocketConnection) -> #(ConnState, Option(process.Selector(PushMsg))) {
logger.debug(\"WebSocket: connected\")
list.each(topics, fn(t) { push.join(topic: t) })
let selector =
process.new_selector()
|> process.select_record(
tag: atom.create(\"libero_push\"),
fields: 1,
mapping: fn(record) {
{ use frame <- decode.field(1, decode.bit_array)
decode.success(PushFrame(frame))
}
|> decode.run(record, _)
|> result.unwrap(Ignored)
},
)
#(ConnState(handler_ctx:, topics:, logger:), Some(selector))
}
}
fn handler(
conn_state: ConnState,
message: mist.WebsocketMessage(PushMsg),
conn: mist.WebsocketConnection,
) {
case message {
mist.Binary(data) -> {
let #(response_bytes, maybe_panic, new_handler_ctx) =
dispatch.handle(handler_ctx: conn_state.handler_ctx, data:)
case maybe_panic {
Some(info) ->
conn_state.logger.error(
\"RPC panic: \"
<> info.fn_name
<> \" (trace \"
<> info.trace_id
<> \"): \"
<> info.reason,
)
_ -> Nil
}
case mist.send_binary_frame(conn, response_bytes) {
Ok(_) -> Nil
Error(reason) ->
conn_state.logger.warning(
\"Failed to send WebSocket frame: \" <> string.inspect(reason),
)
}
mist.continue(ConnState(..conn_state, handler_ctx: new_handler_ctx))
}
mist.Custom(PushFrame(frame)) -> {
case mist.send_binary_frame(conn, frame) {
Ok(_) -> Nil
Error(reason) ->
conn_state.logger.warning(
\"Failed to send WebSocket push frame: \" <> string.inspect(reason),
)
}
mist.continue(conn_state)
}
mist.Custom(Ignored) -> mist.continue(conn_state)
mist.Closed | mist.Shutdown -> mist.stop()
mist.Text(_) -> mist.continue(conn_state)
}
}
"/utf8>>,
libero@codegen:ensure_parent_dir(Gleam_output),
libero@codegen:write_file(Gleam_output, Content).
-file("src/libero/codegen_server.gleam", 154).
?DOC(
" Generate an Erlang FFI file that pre-registers all constructor atoms\n"
" discovered by the type graph walker, plus framework atoms used by\n"
" libero's wire protocol. Calling `ensure/0` from this module creates\n"
" the atoms in the BEAM atom table so that `binary_to_term([safe])`\n"
" can decode client ETF payloads without rejecting unknown atoms.\n"
).
-spec write_atoms(libero@config:config(), list(libero@walker:discovered_type())) -> {ok,
nil} |
{error, libero@gen_error:gen_error()}.
write_atoms(Config, Discovered) ->
Framework_atoms = [<<"ok"/utf8>>,
<<"error"/utf8>>,
<<"some"/utf8>>,
<<"none"/utf8>>,
<<"nil"/utf8>>,
<<"true"/utf8>>,
<<"false"/utf8>>,
<<"malformed_request"/utf8>>,
<<"unknown_function"/utf8>>,
<<"internal_error"/utf8>>,
<<"decode_error"/utf8>>],
Discovered_atoms = begin
_pipe = gleam@list:flat_map(
Discovered,
fun(T) -> erlang:element(5, T) end
),
gleam@list:map(_pipe, fun(V) -> erlang:element(4, V) end)
end,
All_atoms = begin
_pipe@1 = lists:append(Framework_atoms, Discovered_atoms),
_pipe@2 = gleam@list:unique(_pipe@1),
gleam@list:sort(_pipe@2, fun gleam@string:compare/2)
end,
Atom_list = begin
_pipe@3 = gleam@list:map(
All_atoms,
fun(Atom) ->
<<<<" <<\""/utf8, Atom/binary>>/binary, "\">>"/utf8>>
end
),
gleam@string:join(_pipe@3, <<",\n"/utf8>>)
end,
Content = <<<<<<<<"%% Code generated by libero. DO NOT EDIT.
%%
%% Pre-registers all constructor atoms that may appear in client ETF
%% payloads, so binary_to_term([safe]) can decode them without
%% rejecting unknown atoms.
%%
%% ensure/0 uses persistent_term as a one-shot guard so the
%% binary_to_atom calls only run once per VM lifetime.
%%
%% lists:foreach + fun is used instead of bare binary_to_atom calls
%% because the Erlang compiler optimizes away pure BIF calls whose
%% results are discarded.
-module("/utf8,
(erlang:element(4, Config))/binary>>/binary,
").
-export([ensure/0]).
ensure() ->
case persistent_term:get({?MODULE, done}, false) of
true -> nil;
false -> do_ensure()
end.
do_ensure() ->
lists:foreach(fun(B) -> binary_to_atom(B) end, [
"/utf8>>/binary,
Atom_list/binary>>/binary,
"
]),
persistent_term:put({?MODULE, done}, true),
nil.
"/utf8>>,
Output = erlang:element(3, Config),
libero@codegen:ensure_parent_dir(Output),
libero@codegen:write_file(Output, Content).
-file("src/libero/codegen_server.gleam", 428).
?DOC(
" Write content to a file, skipping if the file already exists.\n"
" Used for scaffolded files that users may customize after generation.\n"
).
-spec write_if_missing(binary(), binary()) -> {ok, nil} |
{error, libero@gen_error:gen_error()}.
write_if_missing(Path, Content) ->
case begin
_pipe = simplifile_erl:is_file(Path),
gleam@result:unwrap(_pipe, false)
end of
true ->
gleam_stdlib:println(
<<<<" kept "/utf8, Path/binary>>/binary,
" (exists, not overwriting)"/utf8>>
),
{ok, nil};
false ->
libero@codegen:write_file(Path, Content)
end.
-file("src/libero/codegen_server.gleam", 211).
?DOC(
" Generate the server entry point at\n"
" `<project_path>/src/<app_name>.gleam`.\n"
).
-spec write_main(
binary(),
integer(),
binary(),
binary(),
list(binary()),
binary()
) -> {ok, nil} | {error, libero@gen_error:gen_error()}.
write_main(
App_name,
Port,
Server_generated,
Context_module,
Js_client_names,
Project_path
) ->
Output = <<<<<<Project_path/binary, "/src/"/utf8>>/binary,
(gleam@string:replace(App_name, <<"-"/utf8>>, <<"_"/utf8>>))/binary>>/binary,
".gleam"/utf8>>,
Dispatch_module = libero@codegen:last_split(
Server_generated,
<<"src/"/utf8>>
),
Ws_module = <<Dispatch_module/binary, "/websocket"/utf8>>,
{Js_routes, Index_route} = case Js_client_names of
[] ->
{<<""/utf8>>,
<<"
_, _ ->
response.new(404)
|> response.set_body(mist.Bytes(bytes_tree.from_string(\"Not found\")))"/utf8>>};
[First_js | _] ->
Routes = begin
_pipe = gleam@list:map(
Js_client_names,
fun(Name) ->
<<<<<<<<" _, [\""/utf8, Name/binary>>/binary,
"\", ..path] ->
serve_file(
\"clients/"/utf8>>/binary,
Name/binary>>/binary,
"/build/dev/javascript/\"
<> string.join(list.filter(path, fn(s) { s != \"..\" && s != \"\" }), \"/\"),
)"/utf8>>
end
),
_pipe@1 = gleam@string:join(_pipe, <<"\n"/utf8>>),
(fun(R) -> <<"\n"/utf8, R/binary>> end)(_pipe@1)
end,
Index = <<<<<<<<"
_, _ -> serve_html(\"<!DOCTYPE html>
<html>
<head>
<meta charset=\\\"utf-8\\\">
<meta name=\\\"viewport\\\" content=\\\"width=device-width, initial-scale=1\\\">
</head>
<body>
<div id=\\\"app\\\"></div>
<script type=\\\"module\\\">
import { main } from \\\"/"/utf8,
First_js/binary>>/binary,
"/"/utf8>>/binary,
First_js/binary>>/binary,
"/app.mjs\\\";
main();
</script>
</body>
</html>\")"/utf8>>,
{Routes, Index}
end,
Context_qualifier = libero@codegen:last_split(Context_module, <<"/"/utf8>>),
Content = <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<"//// Generated by libero as a starting point. Customize for your application.
////
//// Server-only: this module starts a mist HTTP server and depends on
//// Erlang-target generated dispatch. Customize freely; it won't compile to JS.
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http
import gleam/http/request.{type Request}
import gleam/http/response
import gleam/io
import gleam/list
import gleam/option.{None, Some}
import gleam/string
import libero/push
import libero/ws_logger
import mist.{type Connection}
import "/utf8,
Dispatch_module/binary>>/binary,
"/dispatch
import "/utf8>>/binary,
Ws_module/binary>>/binary,
" as ws
import "/utf8>>/binary,
Context_module/binary>>/binary,
"
// Server entry point. Initializes the push system (for server-to-client
// broadcasts), registers atoms for safe ETF decoding, creates the shared
// HandlerContext, and starts a mist HTTP server with WebSocket and RPC routes.
pub fn main() {
let _ = push.init()
let _ = dispatch.ensure_atoms()
let handler_ctx = "/utf8>>/binary,
Context_qualifier/binary>>/binary,
".new()
let logger = ws_logger.default_logger()
let started =
fn(req: Request(Connection)) {
case req.method, request.path_segments(req) {
_, [\"ws\"] ->
ws.upgrade(
request: req,
handler_ctx:,
topics: [],
logger:,
)
http.Post, [\"rpc\"] -> handle_rpc(req, handler_ctx, logger)"/utf8>>/binary,
Js_routes/binary>>/binary,
Index_route/binary>>/binary,
"
}
}
|> mist.new
|> mist.port("/utf8>>/binary,
(erlang:integer_to_binary(Port))/binary>>/binary,
")
|> mist.start
case started {
Ok(_) -> process.sleep_forever()
Error(reason) -> {
io.println_error(
\"libero: failed to start mist on port "/utf8>>/binary,
(erlang:integer_to_binary(Port))/binary>>/binary,
": \"
<> string.inspect(reason),
)
panic as \"mist failed to start\"
}
}
}
// HTTP RPC endpoint. Accepts POST requests with ETF-encoded call envelopes,
// passes them to dispatch.handle (same handler logic as WebSocket), and
// returns the ETF-encoded response. State changes are not persisted across
// requests since HTTP is stateless. Use WebSocket for stateful interactions.
fn handle_rpc(
req: Request(Connection),
handler_ctx: "/utf8>>/binary,
Context_qualifier/binary>>/binary,
".HandlerContext,
logger: ws_logger.Logger,
) -> response.Response(mist.ResponseData) {
case mist.read_body(req, 1_000_000) {
Ok(req) -> {
let #(response_bytes, maybe_panic, _new_handler_ctx) =
dispatch.handle(handler_ctx:, data: req.body)
case maybe_panic {
Some(info) ->
logger.error(
\"RPC panic: \"
<> info.fn_name
<> \" (trace \"
<> info.trace_id
<> \"): \"
<> info.reason,
)
None -> Nil
}
response.new(200)
|> response.set_header(\"content-type\", \"application/octet-stream\")
|> response.set_body(mist.Bytes(bytes_tree.from_bit_array(response_bytes)))
}
Error(_) ->
response.new(400)
|> response.set_body(mist.Bytes(bytes_tree.from_string(\"Bad request\")))
}
}
// Return an HTML string as a response.
fn serve_html(html: String) -> response.Response(mist.ResponseData) {
response.new(200)
|> response.set_header(\"content-type\", \"text/html\")
|> response.set_body(mist.Bytes(bytes_tree.from_string(html)))
}
// Serve a static file from disk. Used for client JS bundles and assets.
fn serve_file(
path: String,
) -> response.Response(mist.ResponseData) {
case mist.send_file(path, offset: 0, limit: None) {
Ok(body) ->
response.new(200)
|> response.set_header(\"content-type\", content_type(path))
|> response.set_body(body)
Error(_) ->
response.new(404)
|> response.set_body(mist.Bytes(bytes_tree.from_string(\"Not found\")))
}
}
// Map file extensions to MIME types for static file serving.
fn content_type(path: String) -> String {
case string.split(path, \".\") |> list.last {
Ok(\"js\") | Ok(\"mjs\") -> \"application/javascript\"
Ok(\"css\") -> \"text/css\"
Ok(\"html\") -> \"text/html\"
Ok(\"json\") -> \"application/json\"
Ok(\"wasm\") -> \"application/wasm\"
Ok(\"svg\") -> \"image/svg+xml\"
Ok(\"png\") -> \"image/png\"
Ok(\"ico\") -> \"image/x-icon\"
Ok(\"map\") -> \"application/json\"
_ -> \"application/octet-stream\"
}
}
"/utf8>>,
libero@codegen:ensure_parent_dir(Output),
write_if_missing(Output, Content).