Current section
Files
Jump to
Current section
Files
src/smol.gleam
//// <style>
//// small {
//// font-size: 0.7em;
//// opacity: 0.75;
//// }
//// .goto-top {
//// display: block;
//// font-size: 0.85em;
//// text-align: right;
//// }
//// h4 {
//// margin-bottom: 0;
//// + p {
//// margin-top: 0;
//// }
//// }
//// </style>
//// <script>
//// (callback => document.readyState !== 'loading' ? callback() : document.addEventListener('DOMContentLoaded', callback, { once: true }))(() => {
//// const list = document.querySelector('.sidebar > ul:last-of-type')
//// const sortedLists = document.createDocumentFragment()
//// const sortedMembers = document.createDocumentFragment()
////
//// for (const header of document.querySelectorAll('main > h4')) {
//// sortedLists.append((() => {
//// const node = document.createElement('h3')
//// node.append(header.textContent)
//// return node
//// })())
//// sortedMembers.append((() => {
//// const node = document.createElement('h2')
//// node.append(header.textContent)
//// return node
//// })())
////
//// const sortedList = document.createElement('ul')
//// sortedLists.append(sortedList)
////
//// for (const anchor of header.nextElementSibling.querySelectorAll('a')) {
//// const href = anchor.getAttribute('href')
//// const member = document.querySelector(`.member:has(h2 > a[href="${href}"])`)
//// const sidebar = list.querySelector(`li:has(a[href="${href}"])`)
//// sortedList.append(sidebar)
//// sortedMembers.append(member)
//// }
//// }
////
//// document.querySelector('.sidebar').insertBefore(sortedLists, list)
//// document.querySelector('.module-members:has(#module-values)').insertBefore(sortedMembers, document.querySelector('#module-values').nextSibling)
////
//// const goToTop = document.createElement('a')
//// goToTop.classList.add('goto-top')
//// goToTop.setAttribute('href', '#')
//// goToTop.textContent = 'Back to top ↑'
//// for (const member of document.querySelectorAll('.member')) {
//// member.insertBefore(goToTop.cloneNode(true), null)
//// }
//// })
//// </script>
////
//// **Tip:** Hover the links for short summaries!
////
//// #### Builder
//// [new](#new "Create a new builder for a handler"),
//// [port](#port "Set the port"),
//// [random_port](#random_port "Choose a random port"),
//// [bind](#bind "Bind to a network interface"),
//// [bind_all](#bind_all "Bind to all network interfaces"),
//// [after_start](#after_start "Set a callback once the server is started"),
//// [on_error](#on_error "Set the error handler")
////
//// #### Server Operations
//// [start](#start "Start the server")
//// [get_port](#get_port "Get the port the server is listening on"),
//// [get_interface](#get_interface "Get the interface the server is listening on")
//// [stop](#stop "Stop the server")
////
//// #### Request Handling
//// [read_bytes_tree](#read_bytes_tree "Read the body as a BytesTree"),
//// [read_bytes](#read_bytes "Read the body as a BitArray"),
//// [read_string](#read_string "Read the body as a String"),
//// [read_json](#read_json "Read and decode the body as JSON"),
//// [read_form](#read_form "Read and parse the body as an url-encoded form"),
//// [require_method](#require_method "Middleware to ensure a specific method"),
//// [require_content_type](#require_content_type "Middleware to ensure a specific content-type header")
////
//// #### Response Creation
//// [send_file](#send_file "Send a file response"),
//// [send_bytes](#send_bytes "Send binary data"),
//// [send_chunked](#send_chunked "Send a streamed rseponse"),
//// [send_html](#send_html "Send html response"),
//// [send_json](#send_json "Send JSON response"),
//// [send_string](#send_string "Send text response"),
//// [send_status](#send_status "Send a status code response"),
//// [redirect](#redirect "Redirect the client to a different url"),
//// [moved_permanently](#moved_permanently "Redirect the client, permanently"),
//// [server_sent_events](#server_sent_events "Send a SSE stream response"),
//// [websocket](#websocket "Open a websocket connection"),
//// [with_status](#with_status "Set the status of a response"),
//// [with_extra_headers](#with_extra_headers "Add some headers to a response"),
//// [send](#send "Helper for chunked/SSE/Websocket responses"),
//// [step](#step "Helper for chunked/SSE/Websocket responses"),
//// [close](#close "Helper for chunked/SSE/Websocket responses")
////
//// #### Advanced
//// [adapt](#adapt "Convert a Gleam handler to a fetch handler"),
//// [adapt_node](#adapt_node "Convert a Gleam handler to a Node.JS handler"),
//// [detect_runtime](#detect_runtime "Detect JavaScript runtime environment"),
//// [rescue](#rescue "Recover from exceptions")
//// [status_text](#status_text "Convert a status code into a string")
// TODO: get rid of status_text if the status_code package provides it maybe?
// TODO: smol_dev hot-reloading server?
// TODO: smol_erlang/smoler/smolerl because wisp is annoyingly not useful
// TODO: smol_middleware package? -> auth/logging/rescue/error pages/body parsing/"look at koa" (other name bc it might work for mist too? average? bigger?)
// TODO: smol_lustre
//
import filepath
import gleam/bit_array
import gleam/bytes_tree.{type BytesTree}
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode.{type Decoder}
import gleam/http.{type Method}
import gleam/http/request
import gleam/http/response.{Response}
import gleam/int
import gleam/io
import gleam/javascript/promise.{type Promise}
import gleam/json.{type Json}
import gleam/list
import gleam/option.{type Option, None, Some}
import gleam/result
import gleam/string
import gleam/uri
import marceau
import smol/internal/builder.{Builder}
pub type Builder =
builder.Builder
/// smol uses a standard web `ReadableStream` under the hood on all runtimes,
/// including Node.js!
pub type ReadableStream =
builder.ReadableStream
// -- BUILDER ------------------------------------------------------------------
/// Creates a new server builder with the given request handler.
///
/// The server listens on `http://localhost:4000` by default. See the examples
/// pages for example on how to use this function.
///
pub fn new(handler: fn(Request) -> Promise(Response)) -> Builder {
Builder(
port: 4000,
interface: "127.0.0.1",
handler: handler,
after_start: default_after_start,
on_error: default_error_handler,
)
}
fn default_after_start(port: Int, interface: String) {
io.println(
"smol: Listening on http://" <> interface <> ":" <> int.to_string(port),
)
}
@external(javascript, "./smol.ffi.mjs", "error_handler")
fn default_error_handler(message: String, error: Dynamic) -> Nil
/// Sets the port for the server to listen on.
///
/// By default smol tries to listen on port `4000`.
///
/// ## Example
///
/// ```gleam
/// smol.new(handler)
/// |> smol.port(8080)
/// ```
///
pub fn port(builder: Builder, port: Int) -> Builder {
Builder(..builder, port:)
}
/// Configures the server to use a random available port.
///
/// Useful for testing to avoid port conflicts.
///
/// ## Example
///
/// ```gleam
/// smol.new(handler)
/// |> smol.random_port()
/// ```
///
pub fn random_port(builder: Builder) -> Builder {
Builder(..builder, port: 0)
}
/// Sets the network interface to listen on.
///
/// By default smol listens on `"127.0.0.1"` (localhost).
///
/// ## Example
///
/// ```gleam
/// smol.new(handler)
/// |> smol.bind("192.168.1.100")
/// ```
///
pub fn bind(builder: Builder, interface: String) -> Builder {
Builder(..builder, interface:)
}
/// Configures the server to listen on all network interfaces (`0.0.0.0`).
///
/// ## Example
///
/// ```gleam
/// smol.new(handler)
/// |> smol.bind_all()
/// ```
///
pub fn bind_all(builder: Builder) -> Builder {
Builder(..builder, interface: "0.0.0.0")
}
/// Sets a callback function to be called after the server starts.
///
/// The function receives the actual port and interface the server is listening
/// on. By default, a simple `Listening on...` message is printed.
///
/// ## Example
///
/// ```gleam
/// fn log_start(port, interface) {
/// io.println("Server started on " <> interface <> ":" <> int.to_string(port))
/// }
///
/// smol.new(handler)
/// |> smol.after_start(log_start)
/// ```
///
pub fn after_start(
builder: Builder,
after_start: fn(Int, String) -> Nil,
) -> Builder {
Builder(..builder, after_start:)
}
/// Sets a callback function to be called whenever an error happens.
///
/// The function receives a string containing some context as well as the raw
/// javascript error value.
///
/// By default, errors are logged to standard error.
///
/// ## Example
///
/// ```gleam
/// fn error_handler(message, error) {
/// io.println("Errror: " <> message <> ": " <> string.inspect(error))
/// }
///
/// smol.new(handler)
/// |> smol.error_handler(error_handler)
/// ```
///
pub fn on_error(
builder: Builder,
on_error: fn(String, Dynamic) -> Nil,
) -> Builder {
Builder(..builder, on_error:)
}
// -- READING REQUESTS ---------------------------------------------------------
/// A convenience alias for a HTTP request with a `ReadableBody` as the body.
pub type Request =
request.Request(ReadableStream)
/// Reads the request body as a BytesTree and calls the provided callback.
///
/// The `up_to` parameter sets a limit on the size of the body that can be read.
/// If the body exceeds this limit, a 413 (Payload Too Large) response is sent.
///
/// ### Example
///
/// ```gleam
/// use body <- smol.read_bytes_tree(request, up_to: 1024 * 1024)
/// // ... do something with the body ...
/// smol.send_string("Received bytes")
/// ```
///
pub fn read_bytes_tree(
from request: Request,
up_to max_body_size: Int,
then next: fn(BytesTree) -> Promise(Response),
) -> Promise(Response) {
use result <- promise.await(read(request, max_body_size))
case result {
Ok(bytes) -> next(bytes)
Error(_) -> send_status(413)
}
}
/// Reads the request body as a BitArray and calls the provided callback.
///
/// The `up_to` parameter sets a limit on the size of the body that can be read.
/// If the body exceeds this limit, a 413 (Payload Too Large) response is sent.
///
///
/// ## Example
///
/// ```gleam
/// // a simple echo server!
/// use body <- smol.read_bytes(request, up_to: 1024 * 1024)
/// smol.send_bytes(body)
/// ```
///
pub fn read_bytes(
from request: Request,
up_to max_body_size: Int,
then next: fn(BitArray) -> Promise(Response),
) -> Promise(Response) {
use body <- read_bytes_tree(request, max_body_size)
next(bytes_tree.to_bit_array(body))
}
/// Reads the request body as a UTF-8 encoded String and calls the provided callback.
///
/// The `up_to` parameter sets a limit on the size of the body that can be read.
/// If the body exceeds this limit, a 413 (Payload Too Large) response is sent.
/// If the body cannot be decoded as UTF-8, a 400 (Bad Request) response is sent.
///
/// ## Example
///
/// ```gleam
/// use body <- smol.read_string(request, up_to: 1024 * 1024)
/// smol.send_string("You sent: " <> body)
/// ```
///
pub fn read_string(
from request: Request,
up_to max_body_size: Int,
then next: fn(String) -> Promise(Response),
) -> Promise(Response) {
use body <- read_bytes_tree(request, max_body_size)
case bytes_tree.to_bit_array(body) |> bit_array.to_string {
Ok(string) -> next(string)
Error(_) -> send_status(400)
}
}
/// Reads and decodes a JSON request body, then calls the provided callback.
///
/// The `decoder` parameter is used to decode the JSON into a specific type.
/// The `up_to` parameter sets a limit on the size of the body that can be read.
/// If the body exceeds this limit, a 413 (Payload Too Large) response is sent.
/// If the body cannot be decoded as UTF-8, a 400 (Bad Request) response is sent.
/// If the body cannot be parsed as JSON, a 422 (Unprocessable Entity) response is sent.
///
/// ## Example
///
/// ```gleam
/// type User {
/// User(name: String, age: Int)
/// }
///
/// fn user_decoder() {
/// use name <- decode.field("name", decode.string)
/// use age <- decode.field("age", decode.int)
/// decode.success(User(name:, age:))
/// }
///
/// fn handler(request) {
/// use user <- smol.read_json(
/// request,
/// using: user_decoder(),
/// up_to: 1024 * 1024,
/// )
///
/// // Process the user
/// smol.send_string("Hello, " <> user.name)
/// }
/// ```
///
pub fn read_json(
from request: Request,
using decoder: Decoder(a),
up_to max_body_size: Int,
then next: fn(a) -> Promise(Response),
) -> Promise(Response) {
use body <- read_string(request, max_body_size)
case json.parse(from: body, using: decoder) {
Ok(result) -> next(result)
Error(_) -> send_status(422)
}
}
/// Reads and decodes a url-encoded request body, then calls the provided callback.
///
/// The `up_to` parameter sets a limit on the size of the body that can be read.
/// If the body exceeds this limit, a 413 (Payload Too Large) response is sent.
/// If the body cannot be decoded as UTF-8, a 400 (Bad Request) response is sent.
/// If the body cannot be parsed as a query string, a 422 (Unprocessable Entity)
/// response is sent.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use values <- smol.read_form(request, up_to: 1024 * 1024)
/// let email = list.key_find(values, "email") |> result.unwrap("")
/// let password = list.key_find(values, "password") |> result.unwrap("")
/// // ...
/// }
/// ```
///
pub fn read_form(
from request: Request,
up_to max_body_size: Int,
then next: fn(List(#(String, String))) -> Promise(Response),
) -> Promise(Response) {
use body <- read_string(request, max_body_size)
case uri.parse_query(body) {
Ok(pairs) -> next(pairs)
Error(_) -> send_status(422)
}
}
/// A middleware function ensuring that the request has a specific HTTP method.
///
/// Returns a 405 (Method Not Allowed) response if the method does not match.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use <- smol.require_method(request, http.Post)
/// // ...
/// }
/// ```
///
pub fn require_method(
from request: Request,
require method: Method,
then next: fn() -> Promise(Response),
) -> Promise(Response) {
case request.method == method {
True -> next()
False -> send_status(405)
}
}
/// A middleware function ensuring that the provided `Content-Type` header in the
/// request matches one of the acceptable values.
///
/// Returns a 415 (Unsupported Media Type) response if the header does not match.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use <- smol.require_content_type(request, ["application/json"])
/// use body <- smol.read_json(request, 1024 * 1024, user_decoder())
/// // ...
/// }
/// ```
///
pub fn require_content_type(
from request: Request,
accept content_types: List(String),
then next: fn() -> Promise(Response),
) -> Promise(Response) {
let is_acceptable = case request.get_header(request, "content-type") {
Ok(content_type) -> {
use candidate <- list.any(content_types)
content_type == candidate
|| string.starts_with(content_type, candidate <> ";")
}
Error(_) -> False
}
case is_acceptable {
True -> next()
False ->
send_status(415)
|> with_extra_headers([#("accept", string.join(content_types, ", "))])
}
}
/// A middleware function that allows you to recover from an exception thrown
/// inside the inner request handler.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use <- smol.rescue(fn(_err) { smol.send_status(500) })
/// // ...
/// }
/// ```
///
@external(javascript, "./smol.ffi.mjs", "rescue")
pub fn rescue(
on_error: fn(Dynamic) -> Promise(Response),
inner: fn() -> Promise(Response),
) -> Promise(Response)
@external(javascript, "./smol.ffi.mjs", "read")
fn read(request: Request, max_body_size: Int) -> Promise(Result(BytesTree, Nil))
// -- SENDING RESPONSES --------------------------------------------------------
/// A convenience alias for a HTTP response with a `ReadableBody` as the body.
pub type Response =
response.Response(ReadableStream)
pub type FileError {
IsDir
NoAccess
NoEntry
UnknownFileError
RuntimeNotSupportedFileError
}
/// Sends a file as the response.
///
/// The `path` parameter is the file path to send. The path will be joined to
/// the current working directory. Make sure the path cannot escape the folder
/// you are trying to serve from!
///
/// The `offset` parameter specifies the starting byte offset (default 0).
/// The `limit` parameter specifies the maximum number of bytes to send
/// (default is the entire file).
///
/// `Content-Length` and `Content-Type` headers will be set automatically based
/// on the file name and size.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use _error <- smol.send_file(
/// "./priv/public/index.html",
/// offset: 0,
/// limit: option.None,
/// )
///
/// smol.send_status(404)
/// }
/// ```
///
pub fn send_file(
path: String,
offset offset: Int,
limit limit: Option(Int),
or fallback: fn(FileError) -> Promise(Response),
) -> Promise(Response) {
let limit = option.unwrap(limit, 0)
use maybe_file <- promise.await(from_file(path, offset, limit))
case maybe_file {
Error(error) -> fallback(error)
Ok(file) -> {
let content_length = case limit > 0 {
True -> int.clamp(file.size - offset, 0, limit)
False -> int.max(file.size - offset, 0)
}
let content_type = case file.mime {
option.None ->
filepath.extension(path)
|> result.map(marceau.extension_to_mime_type)
|> result.unwrap("application/octet-stream")
option.Some(mime) -> mime
}
promise.resolve(
Response(status: 200, body: file.stream, headers: [
#("content-length", int.to_string(content_length)),
#("content-type", content_type),
]),
)
}
}
}
@internal
pub type File {
File(stream: ReadableStream, size: Int, mime: option.Option(String))
}
@external(javascript, "./smol.ffi.mjs", "from_file")
fn from_file(
path: String,
offset: Int,
limit: Int,
) -> Promise(Result(File, FileError))
/// Creates a response with binary data.
///
/// The response will have content-type `application/octet-stream`.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// let binary_data = <<1, 2, 3, 4, 5>>
/// smol.send_bytes(binary_data)
/// }
/// ```
///
pub fn send_bytes(bytes: BitArray) -> Promise(Response) {
promise.resolve(Response(
status: 200,
headers: [
#("content-type", "application/octet-stream"),
#("content-length", int.to_string(bit_array.byte_size(bytes))),
],
body: from_bit_array(bytes),
))
}
/// Creates a plain text response with the given string.
///
/// The response will have context-type: `text/plain; charset=utf-8`.
///
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// smol.send_string("Hello, World!")
/// }
/// ```
///
pub fn send_string(text: String) -> Promise(Response) {
let body = <<text:utf8>>
promise.resolve(Response(
status: 200,
headers: [
#("content-type", "text/plain; charset=utf-8"),
#("content-length", int.to_string(bit_array.byte_size(body))),
],
body: from_bit_array(body),
))
}
/// Creates a html response with the given string.
///
/// The response will have context-type: `text/html; charset=utf-8`.
///
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// smol.send_html("<!doctype html><h1>Hello, World!")
/// }
/// ```
///
pub fn send_html(text: String) -> Promise(Response) {
let body = <<text:utf8>>
promise.resolve(Response(
status: 200,
headers: [
#("content-type", "text/html; charset=utf-8"),
#("content-length", int.to_string(bit_array.byte_size(body))),
],
body: from_bit_array(body),
))
}
/// A small helper to create a response with the given status code and a
/// standard status message.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// smol.send_status(404) // Response with "Not Found" body
/// }
/// ```
///
pub fn send_status(status: Int) -> Promise(Response) {
let body = <<status_text(status):utf8>>
promise.resolve(Response(
status:,
headers: [
#("content-type", "text/plain; charset=utf-8"),
#("content-length", int.to_string(bit_array.byte_size(body))),
],
body: from_bit_array(body),
))
}
/// Creates a JSON response with the given JSON data.
///
/// The response will have Content-Type: application/json; charset=utf-8.
///
/// ```gleam
/// fn handler(request) {
/// let user = User(name: "Ada", age: 4)
/// let data = json.object([
/// #("name", json.string(user.name)),
/// #("age", json.int(user.age)),
/// ])
///
/// smol.send_json(data)
/// }
/// ```
///
pub fn send_json(json: Json) -> Promise(Response) {
let body = <<json.to_string(json):utf8>>
promise.resolve(Response(
status: 200,
headers: [
#("content-type", "application/json; charset=utf-8"),
#("content-length", int.to_string(bit_array.byte_size(body))),
],
body: from_bit_array(body),
))
}
/// Respond with a 303 (See Other) response, redirecting the client `GET` a different url.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use <- smol.require_method(http.Post)
/// use user <- smol.read_json(request, up_to: 1024 * 1024, using: user_decoder())
///
/// use <- promise.await(update_user_in_database(user))
///
/// smol.redirect(to: "/users/" <> int.to_string(user.id) <> "/edit")
/// }
/// ```
///
pub fn redirect(to url: String) -> Promise(Response) {
let body = <<url:utf8>>
promise.resolve(Response(
status: 303,
headers: [
#("content-type", "text/plain; charset=utf-8"),
#("content-length", int.to_string(bit_array.byte_size(body))),
#("location", url),
],
body: from_bit_array(body),
))
}
/// Respond with a 308 (Moved Permanently) response.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// case request.path_segments(request) {
/// ["api", ..rest] -> handle_api(request)
/// // move all /backend/* requests to /api/*
/// ["backend", ..rest] -> smol.moved_permanently("/api/" <> string.join(rest, "/"))
/// }
/// }
/// ```
///
pub fn moved_permanently(to url: String) -> Promise(Response) {
let body = <<url:utf8>>
promise.resolve(Response(
status: 308,
headers: [
#("content-type", "text/plain; charset=utf-8"),
#("content-length", int.to_string(bit_array.byte_size(body))),
#("location", url),
],
body: from_bit_array(body),
))
}
/// A type used in various functions as a return type from "unfold" or "update"
/// - style functions, usually used in combination with a `Promise`, similar to
/// `actor.Next` or `yielder.Step`.
///
/// Represents the result of a stateful iteration that can send messages back to
/// a client.
pub type Step(state, output) {
/// Stop iterating, close the connection. We are done sending.
Close
/// Loop, without sending anything.
Step(state: state)
/// Loop, sending `returning` to the client.
Send(state: state, sending: output)
}
/// A convenience method useful for async functions.
///
/// Equivalent to `promise.resolve(Close)`
///
pub fn close() -> Promise(Step(state, output)) {
promise.resolve(Close)
}
/// A convenience method useful for async functions.
///
/// Equivalent to `promise.resolve(Step(state))`
///
pub fn step(state state: state) -> Promise(Step(state, output)) {
promise.resolve(Step(state))
}
/// A convenience method useful for async functions.
///
/// Equivalent to `promise.resolve(Send(state:, returning:))`
///
pub fn send(
state state: state,
sending sending: output,
) -> Promise(Step(state, output)) {
promise.resolve(Send(state:, sending:))
}
/// Creates a chunked transfer-encoded HTTP response from a yielder function.
///
/// The yielder function produces chunks of data over time, allowing efficient
/// streaming of large responses without loading everything into memory at once.
///
/// Note that file responses are automatically streamed.
///
/// ## Example
///
/// ```gleam
/// // Stream a large file in chunks
/// fn stream_handler(request) {
/// smol.send_chunked(
/// from: #(0, file_size),
/// with: fn(state) {
/// let #(offset, total) = state
///
/// case offset >= total {
/// // we are done!
/// True -> smol.close()
/// False -> {
/// // Read next chunk (10KB at a time)
/// let chunk_size = int.min(10240, total - offset)
/// use chunk <- promise.await(read_chunk(path, offset, chunk_size))
///
/// // Return chunk and advance to next offset
/// smol.send(
/// state: #(offset + chunk_size, total),
/// sending: chunk,
/// )
/// }
/// }
/// }
/// )
/// }
/// ```
///
pub fn send_chunked(
from state: state,
with fun: fn(state) -> Promise(Step(state, BitArray)),
) -> Promise(Response) {
let body = from_unfold(state, fun)
promise.resolve(Response(
status: 200,
headers: [
#("conent-type", "application/octet-stream"),
#("transfer-encoding", "chunked"),
#("connection", "keep-alive"),
],
body: body,
))
}
@external(javascript, "./smol.ffi.mjs", "from_unfold")
fn from_unfold(
state: state,
unfold: fn(state) -> Promise(Step(state, BitArray)),
) -> ReadableStream
@external(javascript, "./smol.ffi.mjs", "from_bit_array")
fn from_bit_array(bits: BitArray) -> ReadableStream
/// Convenience function to change the status code of a response.
///
/// ## Example
///
/// ```gleam
/// smol.send_string("I could not find this :(")
/// |> smol.with_status(404)
/// ```
///
pub fn with_status(
response: Promise(Response),
status: Int,
) -> Promise(Response) {
use response <- promise.await(response)
promise.resolve(Response(..response, status:))
}
/// Convenience function to add headers to a response.
///
/// ## Example
///
/// ```gleam
/// smol.send_send_status(201)
/// |> smol.with_extra_headers([#("Location", "/user/" <> user_id)])
/// ```
///
pub fn with_extra_headers(
response: Promise(Response),
headers: List(#(String, String)),
) -> Promise(Response) {
use response <- promise.await(response)
promise.resolve({
use response, #(key, value) <- list.fold(over: headers, from: response)
response.set_header(response, key, value)
})
}
// -- SERVER-SENT EVENTS -------------------------------------------------------
pub type SseEvent {
SseEvent(
event: Option(String),
data: String,
id: Option(String),
retry: Option(Int),
)
}
fn format_sse_event(event: SseEvent) -> BitArray {
let str = case event.event {
Some(event_type) -> "event: " <> event_type <> "\n"
None -> ""
}
let str = case event.id {
Some(id) -> str <> "id: " <> id <> "\n"
None -> str
}
let str = case event.retry {
Some(ms) -> str <> "retry: " <> int.to_string(ms) <> "\n"
None -> str
}
// Add data (split multi-line data with multiple data: lines)
let str = {
use str, line <- list.fold(string.split(event.data, on: "\n"), str)
str <> "data: " <> line <> "\n"
}
let str = str <> "\n"
<<str:utf8>>
}
/// Creates a Server-Sent Events (SSE) response, producing events using an
/// async yielder function.
///
/// The unfold function takes a state and returns either:
/// - `Send(new_state, event)` to emit an event and continue with new state
/// - `Step(new_state)` to loop once without emitting an event
/// - `Close` to end the stream.
///
/// smol also provides the convenience functions [send](#send), [step](#step), and [close](#close)
/// which return these values already wrapped in a promise.
///
/// ## Example
///
/// ```gleam
/// fn sse_handler(request) {
/// // Create a counter that generates events every second
/// use count <- server_sent_events(0)
/// use _ <- promise.await(promise.wait(1000))
/// case count < 10 {
/// True -> {
/// let event = SseEvent(
/// event: Some("update"),
/// data: "Count is " <> int.to_string(count),
/// id: None,
/// retry: None,
/// )
/// smol.send(state: count + 1, sending: event)
/// }
/// False -> smol.close()
/// }
/// }
/// ```
///
pub fn server_sent_events(
from state: state,
with fun: fn(state) -> Promise(Step(state, SseEvent)),
) -> Promise(Response) {
let fun = fn(state) {
use result <- promise.await(fun(state))
case result {
Send(new_state, event) -> send(new_state, format_sse_event(event))
Step(new_state) -> step(new_state)
Close -> close()
}
}
send_chunked(state, fun)
|> with_extra_headers([
#("content-type", "text/event-stream"),
#("cache-control", "no-cache"),
])
}
// -- WEBSOCKETS ---------------------------------------------------------------
pub type WebsocketMessage {
Binary(bytes: BitArray)
Text(text: String)
}
/// Attempt to open a websocket connection in response to this request.
///
/// The client has to have made a valid `Upgrade` request, otherwise smol will
/// respond with status 426.
///
/// When the upgrade succeeds, an actor-like process is started, sending and
/// receiving messages from the client.
///
/// ```gleam
/// type Msg {
/// ConnectionOpened(dispatch: fn(Msg) -> Nil)
/// ReceivedMessage(smol.WebsocketMessage)
/// ConnectionClosed
/// }
///
/// fn websockets_handler(request) {
/// use count, msg <- smol.websocket(
/// init: 1,
/// on_open: ConnectionOpened,
/// on_message: ReceivedMessage,
/// on_close: ConnectionClosed
/// )
///
/// case msg {
/// ConnectionOpened(_) -> smol.step(state)
/// ConnectionClosed -> smol.close()
/// ReceivedMessage(smol.Text(text:)) -> {
/// let response = "Message No. " <> int.to_string(count) <> ": " <> text
/// smol.send(state: count + 1, sending: smol.Text(response))
/// }
/// ReceivedMessage(smol.Binary(_)) -> smol.step(state: count + 1)
/// }
/// }
/// ```
///
pub fn websocket(
init init: state,
update update: fn(state, msg) -> Promise(Step(state, WebsocketMessage)),
on_open handle_open: fn(fn(msg) -> Nil) -> msg,
on_message handle_message: fn(WebsocketMessage) -> msg,
on_close handle_close: msg,
) -> Promise(Response) {
// NOTE: both the body and response are kinda fake here -
// the body is empty and is used as an object holding a reference to the actor spec
// the response is closer to reality, but upgrade will be handled by the
// server runtime again, and headers like sec-websocket-accept are missing..
let body =
from_upgrade(
init:,
update:,
on_open: handle_open,
on_message: handle_message,
on_close: handle_close,
)
// We construct an Upgrade required response here to indicate that the server
// _wanted_ to use websockets, but those were not properly implemented in one
// way or another.
promise.resolve(Response(status: 426, headers: [], body:))
}
@external(javascript, "./smol.ffi.mjs", "from_upgrade")
fn from_upgrade(
init init: state,
update update: fn(state, msg) -> Promise(Step(state, WebsocketMessage)),
on_open handle_open: fn(fn(msg) -> Nil) -> msg,
on_message handle_message: fn(WebsocketMessage) -> msg,
on_close handle_close: msg,
) -> ReadableStream
// -- START --------------------------------------------------------------------
pub opaque type Server {
Server(port: Int, interface: String, stop: fn() -> promise.Promise(Nil))
}
@internal
pub fn started(
port: Int,
interface: String,
stop: fn() -> promise.Promise(Nil),
) -> Result(Server, _) {
Ok(Server(port:, interface:, stop:))
}
/// Adapts a smol handler function to work with standard Web
/// Request/Response objects.
///
/// This is useful for integrating with other JavaScript frameworks or
/// environments that use the standard Fetch API, like serverless platforms or
/// service workers.
///
@external(javascript, "./adapt.ffi.mjs", "adapt")
pub fn adapt(
handler: fn(Request) -> Promise(Response),
) -> fn(Dynamic) -> Promise(Dynamic)
/// Adapts a smol handler function to work with NodeJS.
///
/// This can be useful for integrating Gleam and smol into existing Node projects,
/// or for legacy environments where Node is assumed.
///
@external(javascript, "./adapt.ffi.mjs", "adaptNode")
pub fn adapt_node(
handler: fn(Request) -> Promise(Response),
) -> fn(Dynamic, Dynamic) -> Nil
/// Starts the server with the configured options.
///
/// The returned promise resolves once the server has been started or failed
/// to start.
///
/// ## Example
///
/// ```gleam
/// let handler = fn(request) {
/// smol.send_string("Hello, Joe!")
/// }
///
/// use server <- promise.await(
/// smol.new(handler)
/// |> smol.port(8080)
/// |> smol.start()
/// )
///
/// case server {
/// Ok(server) -> {
/// io.println("Server started!")
/// }
/// Error(_) -> {
/// io.println("Could not start the server")
/// }
/// }
/// ```
///
@external(javascript, "./smol.ffi.mjs", "start")
pub fn start(builder: Builder) -> Promise(Result(Server, Nil))
/// Gets the port that the server is listening on.
///
/// This is especially useful when using `random_port()` to determine which
/// port was assigned.
///
/// ## Example
///
/// ```gleam
/// use server <- promise.await(smol.start(builder))
/// use server <- result.try(server)
/// let port = smol.get_port(server)
/// io.println("Server listening on port " <> int.to_string(port))
/// ```
///
pub fn get_port(server: Server) -> Int {
server.port
}
/// Gets the network interface that the server is listening on.
///
/// ## Example
///
/// ```gleam
/// use server <- promise.await(smol.start(builder))
/// use server <- result.try(server)
/// let interface = smol.get_interface(server)
/// io.println("Server listening on interface " <> interface)
/// ```
///
pub fn get_interface(server: Server) -> String {
server.interface
}
/// Stops the server gracefully.
///
/// Returns a Promise that resolves when the server has fully stopped.
///
/// ## Example
///
/// ```gleam
/// use server <- promise.await(smol.start(builder))
/// case server {
/// Ok(server) -> {
/// io.println("Server started, stopping in 10 seconds...")
/// use _ <- promise.await(promise.wait(10_000))
/// io.println("Stopping the server...")
/// use _ <- promise.await(smol.stop(server))
/// io.println("Server stopped.")
/// }
/// Error(_) -> {
/// io.println("Could not start the server!")
/// }
/// }
/// ```
///
pub fn stop(server: Server) -> Promise(Nil) {
server.stop()
}
// -- HELPERS ------------------------------------------------------------------
pub type Runtime {
Node
Deno
Bun
}
/// Detects the current JavaScript runtime environment.
///
/// Returns a Result containing the Runtime or an error if the runtime couldn't
/// be detected.
///
/// ## Example
///
/// ```gleam
/// fn main() {
/// case smol.detect_runtime() {
/// Ok(smol.Node) -> io.println("Running on Node.js")
/// Ok(smol.Deno) -> io.println("Running on Deno")
/// Ok(smol.Bun) -> io.println("Running on Bun")
/// Error(_) -> io.println("Unknown runtime")
/// }
/// }
/// ```
///
@external(javascript, "./smol.ffi.mjs", "detect_runtime")
pub fn detect_runtime() -> Result(Runtime, Nil)
/// Returns the status text, given a status code.
///
/// If the status code is not known, return the code as a string instead.
///
/// ## Example
///
/// ```gleam
/// smol.status_text(418)
/// // --> "I'm a teapot"
///
/// smol.status_text(911)
/// // --> "911"
/// ```
///
pub fn status_text(status: Int) -> String {
case status {
100 -> "Continue"
101 -> "Switching Protocols"
103 -> "Early Hints"
200 -> "OK"
201 -> "Created"
202 -> "Accepted"
203 -> "Non-Authoritative Information"
204 -> "No Content"
205 -> "Reset Content"
206 -> "Partial Content"
300 -> "Multiple Choices"
301 -> "Moved Permanently"
302 -> "Found"
303 -> "See Other"
304 -> "Not Modified"
307 -> "Temporary Redirect"
308 -> "Permanent Redirect"
400 -> "Bad Request"
401 -> "Unauthorized"
402 -> "Payment Required"
403 -> "Forbidden"
404 -> "Not Found"
405 -> "Method Not Allowed"
406 -> "Not Acceptable"
407 -> "Proxy Authentication Required"
408 -> "Request Timeout"
409 -> "Conflict"
410 -> "Gone"
411 -> "Length Required"
412 -> "Precondition Failed"
413 -> "Payload Too Large"
414 -> "URI Too Long"
415 -> "Unsupported Media Type"
416 -> "Range Not Satisfiable"
417 -> "Expectation Failed"
418 -> "I'm a teapot"
422 -> "Unprocessable Entity"
425 -> "Too Early"
426 -> "Upgrade Required"
428 -> "Precondition Required"
429 -> "Too Many Requests"
431 -> "Request Header Fields Too Large"
451 -> "Unavailable For Legal Reasons"
500 -> "Internal Server Error"
501 -> "Not Implemented"
502 -> "Bad Gateway"
503 -> "Service Unavailable"
504 -> "Gateway Timeout"
505 -> "HTTP Version Not Supported"
506 -> "Variant Also Negotiates"
507 -> "Insufficient Storage"
508 -> "Loop Detected"
510 -> "Not Extended"
511 -> "Network Authentication Required"
_ -> int.to_string(status)
}
}