Current section

Files

Jump to
smol src smol.gleam
Raw

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"),
//// [read_multipart](#read_multipart "Read and parse a multipart form"),
//// [handle_head](#handle_head "Handle HEAD requests as GET requests"),
//// [cache](#cache "Handle conditional cache requests"),
//// [cache_with](#cache_with "Handle conditional cache requests with explicit validators"),
//// [enable_cross_origin_requests](#enable_cross_origin_requests "Enable cross-origin requests"),
//// [csrf_known_header_protection](#csrf_known_header_protection "Protect against CSRF attacks"),
//// [content_security_policy_protection](#content_security_policy_protection "Protect against XSS attacks"),
//// [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"),
//// [last_modified](#last_modified "Set the Last-Modified header of a response"),
//// [with_extra_headers](#with_extra_headers "Add some headers to a response"),
//// [as_file_download](#as_file_download "Send a response as a file download"),
//// [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_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/bool
import gleam/bytes_tree.{type BytesTree}
import gleam/crypto
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode.{type Decoder}
import gleam/http.{type Method, Get, Head, Options}
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/order
import gleam/result
import gleam/string
import gleam/time/timestamp.{type Timestamp}
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)
/// A parsed multipart form.
pub type FormData {
FormData(
values: List(#(String, String)),
files: List(#(String, UploadedFile)),
)
}
pub type UploadedFile {
UploadedFile(file_name: String, path: String)
}
/// 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(chunks) -> next(bytes_tree.concat_bit_arrays(chunks))
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 result <- promise.await(read(request, max_body_size))
case result {
Ok(chunks) ->
case bit_array_list_to_string(chunks) {
Ok(string) -> next(string)
Error(_) -> send_status(400)
}
Error(_) -> send_status(413)
}
}
/// 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)
}
}
/// Reads and decodes a multipart form 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.
/// The `files_up_to` parameter sets a limit on the size of each uploaded file.
/// If any file exceeds this limit, a 413 (Payload Too Large) response is sent.
/// If the multipart form cannot be parsed, a 422 (Unprocessable Entity)
/// response is sent.
///
/// Uploaded files are written to temporary files. They are available while the
/// callback is running and are cleaned up after it completes.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use values <- smol.read_multipart(
/// request,
/// up_to: 1024 * 1024,
/// files_up_to: 512 * 1024,
/// )
/// // ...
/// }
/// ```
///
pub fn read_multipart(
from request: Request,
up_to max_body_size: Int,
files_up_to max_file_size: Int,
then next: fn(FormData) -> Promise(Response),
) -> Promise(Response) {
read_form_data(request, max_body_size, max_file_size, next, send_status)
}
/// Converts `HEAD` requests to `GET` requests, then calls the provided
/// callback.
///
/// This allows a handler for a `GET` route to also answer `HEAD` requests with
/// the same status and headers. The `x-original-method` header is set to
/// `"HEAD"` on the request passed to the callback.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use request <- smol.handle_head(request)
/// // ...
/// }
/// ```
///
pub fn handle_head(
from request: Request,
then next: fn(Request) -> Promise(Response),
) -> Promise(Response) {
let request = case request.method {
Head ->
request.Request(
..request,
method: Get,
body: from_bit_array(<<>>),
headers: [#("x-original-method", "HEAD"), ..request.headers],
)
_ -> request
}
next(request)
}
/// Handles conditional cache requests.
///
/// This middleware adds `Cache-Control: no-cache` when the response does not
/// already have a `Cache-Control` header, and handles `If-None-Match` and
/// `If-Modified-Since` request headers using the response's `ETag` and
/// `Last-Modified` headers.
///
/// For expensive responses where you can compute the validators before
/// rendering the body, use [`cache_with`](#cache_with) to allow the middleware
/// to return `304 Not Modified` before calling the handler.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use <- smol.cache(request)
///
/// use _error <- smol.send_file(
/// "./priv/public/index.html",
/// offset: 0,
/// limit: option.None,
/// )
///
/// smol.send_status(404)
/// }
/// ```
///
pub fn cache(
from request: Request,
then next: fn() -> Promise(Response),
) -> Promise(Response) {
cache_with(request, etag: None, last_modified: None, then: next)
}
/// Handles conditional cache requests with explicit validators.
///
/// This is the same as [`cache`](#cache), but it can return `304 Not Modified`
/// before calling the handler when the request validators match the explicit
/// `etag` or `last_modified` values.
///
/// The final response is still checked after the handler runs, using its
/// `ETag` and `Last-Modified` headers. If the final response is missing one of
/// the explicit validators, it is added automatically.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use <- smol.cache_with(
/// request,
/// etag: option.None,
/// last_modified: option.Some(post.updated_at),
/// )
///
/// render_post(post)
/// }
/// ```
///
pub fn cache_with(
from request: Request,
etag etag: Option(String),
last_modified last_modified: Option(Timestamp),
then next: fn() -> Promise(Response),
) -> Promise(Response) {
use response <- promise.await(case is_fresh(request, etag, last_modified) {
True -> not_modified([])
False -> {
use response <- promise.await(next())
let etag = option.from_result(response.get_header(response, "etag"))
let last_modified =
response.get_header(response, "last-modified")
|> result.try(parse_http_date)
|> option.from_result
case is_fresh(request, etag, last_modified) {
False -> promise.resolve(response)
True -> {
use _ <- promise.await(cancel_stream(response.body))
not_modified(response.headers)
}
}
}
})
response
|> with_default_header("etag", etag)
|> with_default_header(
"last-modified",
option.map(last_modified, format_http_date),
)
|> with_default_header("cache-control", Some("no-cache"))
|> promise.resolve
}
fn is_fresh(
request: Request,
etag: Option(String),
last_modified: Option(Timestamp),
) -> Bool {
case request.method {
Get | Head ->
case etag, request.get_header(request, "if-none-match") {
Some(etag), Ok(if_none_match) -> etag_matches(if_none_match, etag)
// RFC 9110: "A recipient MUST ignore If-Modified-Since if the request
// contains an If-None-Match header field".
_, Ok(_) -> False
_, Error(_) ->
case request.get_header(request, "if-modified-since"), last_modified {
Ok(if_modified_since), Some(last_modified) ->
not_modified_since(last_modified, if_modified_since)
_, _ -> False
}
}
_ -> False
}
}
fn etag_matches(if_none_match: String, etag: String) -> Bool {
let etag = strip_weak_prefix(etag)
use candidate <- list.any(string.split(if_none_match, on: ","))
let candidate = string.trim(candidate)
candidate == "*" || strip_weak_prefix(candidate) == etag
}
fn strip_weak_prefix(etag) {
case etag {
"W/" <> etag -> etag
_ -> etag
}
}
/// Returns `True` when `last_modified` is not newer than the `If-Modified-Since`
/// date sent by the client, meaning the client's cached copy is still current.
fn not_modified_since(
last_modified: Timestamp,
if_modified_since: String,
) -> Bool {
case parse_http_date(if_modified_since) {
Ok(if_modified_since) ->
case timestamp.compare(last_modified, if_modified_since) {
order.Gt -> False
order.Eq | order.Lt -> True
}
Error(_) -> False
}
}
/// Formats a `Timestamp` as an HTTP date (IMF-fixdate), e.g.
/// `"Sun, 06 Nov 1994 08:49:37 GMT"`. The runtime's `Date` does the formatting;
/// we only hand it the POSIX seconds.
fn format_http_date(timestamp: Timestamp) -> String {
let #(seconds, _) = timestamp.to_unix_seconds_and_nanoseconds(timestamp)
http_date_from_unix(seconds)
}
/// Parses an HTTP date (IMF-fixdate) into a `Timestamp`. The runtime's `Date`
/// does the parsing into POSIX seconds, which we lift back into a `Timestamp`.
fn parse_http_date(date: String) -> Result(Timestamp, Nil) {
date
|> unix_from_http_date
|> result.map(timestamp.from_unix_seconds)
}
@external(javascript, "./smol.ffi.mjs", "http_date_from_unix")
fn http_date_from_unix(seconds: Int) -> String
@external(javascript, "./smol.ffi.mjs", "parse_http_date")
fn unix_from_http_date(date: String) -> Result(Int, Nil)
fn with_default_header(
resp: Response,
key: String,
value: Option(String),
) -> Response {
case value, response.get_header(resp, key) {
Some(value), Error(_) -> response.prepend_header(resp, key, value)
_, _ -> resp
}
}
fn not_modified(headers: List(#(String, String))) -> Promise(Response) {
promise.resolve(Response(status: 304, headers:, body: from_bit_array(<<>>)))
}
/// Configuration for the [enable_cross_origin_requests](#enable_cross_origin_requests) middleware.
///
/// `origins` must contain trusted origin strings such as
/// `"https://app.example.com"`. Origins are compared literally. This middleware
/// does not expand wildcard or suffix patterns. The string `"*"` has no
/// special meaning and only matches an `origin` header with that exact value.
///
pub type CrossOriginPolicy {
CrossOriginPolicy(
/// Exact origins that are allowed to read responses.
origins: List(String),
/// Methods that are allowed for cross-origin requests.
methods: List(Method),
/// Request headers that are allowed in cross-origin preflight requests.
headers: List(String),
/// Response headers that browsers may expose to JavaScript.
expose: List(String),
/// Optional `Access-Control-Max-Age` for accepted preflight requests.
max_age: Option(Int),
/// Whether cookies and other credentials are allowed.
credentials: Bool,
)
}
/// Enables cross-origin requests.
///
/// This middleware adds CORS response headers for requests that match the given
/// policy. Requests that do not match the policy are passed through without
/// CORS allow headers, so browsers will not expose their responses to the
/// calling JavaScript. Preflight requests are answered directly with an empty
/// `204` response.
///
/// CORS controls whether browsers expose responses to scripts from other
/// origins. It does not replace authentication, authorization, or CSRF
/// protection.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use request <- smol.enable_cross_origin_requests(
/// request,
/// using: smol.CrossOriginPolicy(
/// origins: ["https://app.example.com"],
/// methods: [http.Get, http.Post],
/// headers: ["content-type"],
/// expose: [],
/// max_age: option.Some(600),
/// credentials: False,
/// ),
/// )
/// // ...
/// }
/// ```
///
pub fn enable_cross_origin_requests(
from request: Request,
using policy: CrossOriginPolicy,
then next: fn(Request) -> Promise(Response),
) -> Promise(Response) {
use origin <- try_or(request.get_header(request, "origin"), fn(_) {
next(request)
})
case
request.method,
request.get_header(request, "access-control-request-method")
{
Options, Ok(request_method) ->
handle_cross_origin_preflight(request, origin, request_method, policy)
_, _ -> {
let headers = case
list.contains(policy.origins, origin),
list.contains(policy.methods, request.method)
{
True, True -> cross_origin_allowed_headers(origin, policy)
_, _ -> [cross_origin_vary_header()]
}
next(request)
|> with_extra_headers(headers)
}
}
}
fn handle_cross_origin_preflight(
request: Request,
origin: String,
request_method: String,
policy: CrossOriginPolicy,
) -> Promise(Response) {
let origin_allowed = list.contains(policy.origins, origin)
let method_allowed = case http.parse_method(request_method) {
Ok(method) -> list.contains(policy.methods, method)
Error(_) -> False
}
let headers_allowed = case
request.get_header(request, "access-control-request-headers")
{
Error(Nil) -> True
Ok(request_headers) -> {
let allowed_headers = list.map(policy.headers, normalise_header)
request_headers
|> string.split(on: ",")
|> list.all(fn(header) {
let header = normalise_header(header)
header == "" || list.contains(allowed_headers, header)
})
}
}
let headers = case origin_allowed, method_allowed, headers_allowed {
True, True, True -> {
let allowed_headers = string.join(policy.headers, with: ", ")
let allowed_methods =
list.map(policy.methods, http.method_to_string)
|> string.join(with: ", ")
let headers = [
#("access-control-allow-headers", allowed_headers),
#("access-control-allow-methods", allowed_methods),
..cross_origin_allowed_headers(origin, policy)
]
case policy.max_age {
None -> headers
Some(max_age) -> [
#("access-control-max-age", int.to_string(int.max(max_age, 0))),
..headers
]
}
}
_, _, _ -> [cross_origin_vary_header()]
}
send_status(204)
|> with_extra_headers(headers)
}
fn cross_origin_allowed_headers(
origin: String,
policy: CrossOriginPolicy,
) -> List(#(String, String)) {
let headers = [
#("access-control-allow-origin", origin),
cross_origin_vary_header(),
]
let headers = case policy.credentials {
False -> headers
True -> [#("access-control-allow-credentials", "true"), ..headers]
}
case policy.expose {
[] -> headers
expose -> [
#("access-control-expose-headers", string.join(expose, with: ", ")),
..headers
]
}
}
fn cross_origin_vary_header() -> #(String, String) {
#(
"vary",
"Origin, Access-Control-Request-Method, Access-Control-Request-Headers",
)
}
fn normalise_header(header: String) -> String {
header
|> string.trim
|> string.lowercase
}
/// Protects against Cross-Site Request Forgery (CSRF) attacks by checking the
/// `host` request header against the `origin` header or `referer` header.
///
/// - Requests with the `Get` and `Head` methods are accepted.
/// - Requests with no `host` header are rejected with status 400: Bad Request.
/// - Requests with no `origin` or `referer` headers are accepted, but have the
/// `cookie` header removed to prevent CSRF attacks against cookie based
/// sessions.
/// - Requests with origin/referer headers that match their host header are
/// accepted.
/// - Requests with headers that don't match are rejected with status 400: Bad
/// Request.
///
/// This middleware implements the [OWASP Verifying Origin With Standard Headers][1]
/// CSRF defense-in-depth technique. **Do not** allow `Get` or `Head` requests
/// to trigger side effects if relying only on this function and the SameSite
/// cookies feature for CSRF protection.
///
/// This middleware and SameSite cookies typically is sufficient to protect
/// against CSRF attacks, but you may decide to employ [token based mitigation][2]
/// for more complete CSRF defence-in-depth.
///
/// [1]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#using-standard-headers-to-verify-origin
/// [2]: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#token-based-mitigation
///
/// If you have routes that should be accessible from other origins then you
/// should not use this middleware for those routes.
///
/// ## Example
///
/// ```gleam
/// fn handler(request) {
/// use request <- smol.csrf_known_header_protection(request)
/// // ...
/// }
/// ```
///
pub fn csrf_known_header_protection(
from request: Request,
then next: fn(Request) -> Promise(Response),
) -> Promise(Response) {
let is_pure_method = case request.method {
Head | Get -> True
_ -> False
}
// GET and HEAD are pure methods, so they SHOULD NOT perform side effects.
// If there are no side effects then there's no risk of CSRF attacks.
use <- bool.lazy_guard(when: is_pure_method, return: fn() { next(request) })
// The origin and referer headers are set by the browser.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Origin
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referer
let origin = case request.get_header(request, "origin") {
Error(_) -> request.get_header(request, "referer")
Ok(_) as o -> o
}
// The host header is required for HTTP1.1, but not HTTP1.0 or HTTP2/3.
// This would need to be modified to support HTTP3 in future.
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Host
let host = request.get_header(request, "host")
case origin, host {
_, Error(_) -> send_status(400)
// If the request does not have origin headers then we cannot perform an
// origin check, so we must remove the cookie headers to ensure that a CSRF
// attack is not possible.
Error(_), _ -> {
let headers = list.filter(request.headers, fn(h) { h.0 != "cookie" })
next(request.Request(..request, headers:))
}
Ok(origin), Ok(host) -> {
let #(host_host, host_port) = case string.split_once(host, ":") {
Ok(#(host, port)) -> #(Some(host), option.from_result(int.parse(port)))
_ -> #(Some(host), None)
}
let uri.Uri(host: origin_host, port: origin_port, ..) =
uri.parse(origin) |> result.unwrap(uri.empty)
case host_host == origin_host && host_port == origin_port {
True -> next(request)
False -> send_status(400)
}
}
}
}
/// Protects against cross-site-scripting (XSS) attacks using a nonce-based
/// content-security-policy (CSP).
///
/// This middleware will provide a unique single use random string (a nonce) to
/// the handler, and set this CSP header on the response returned by the handler.
///
/// ```txt
/// Content-Security-Policy:
/// script-src 'nonce-{NONCE}' 'strict-dynamic';
/// object-src 'none';
/// base-uri 'none';
/// ```
///
/// This header causes the browser to be restricted in these ways:
///
/// - Any `<script>` tag without a `nonce="..."` property set to the nonce for
/// this request will not be executed. Any scripts created by scripts with
/// the correct `nonce` property will be executed.
///
/// - Any inline JavaScript event handlers on elements will not be evaluated.
/// e.g. `<span onclick="doSomething();">Click me</span>` will do nothing
/// when clicked.
///
/// - Any `<object>` or `<embed>` elements will not be executed.
///
/// - Any use of `<base>` to change the base for relative URLs will be prevented.
///
/// When using this middleware be sure to add the `nonce="..."` property to all
/// `<script>` elements.
///
/// ```gleam
/// use csp_nonce <- smol.content_security_policy_protection()
/// ```
/// ```html
/// <script type="module" nonce="RENDER_YOUR_CSP_NONCE_HERE">
/// console.log("Hello, Joe!")
/// </script>
/// ```
///
/// It is recommended to add this middleware so that it applies to all routes
/// in your application.
///
/// For more information about CSP see these articles:
///
/// - <https://web.dev/articles/strict-csp>
/// - <https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy>
///
pub fn content_security_policy_protection(
then handle_request: fn(String) -> Promise(Response),
) -> Promise(Response) {
let nonce =
crypto.strong_random_bytes(18) |> bit_array.base64_url_encode(False)
let header =
"script-src 'nonce-"
<> nonce
<> "' 'strict-dynamic'; object-src 'none'; base-uri 'none'"
handle_request(nonce)
|> with_extra_headers([#("content-security-policy", header)])
}
/// 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)
|> with_extra_headers([#("allow", http.method_to_string(method))])
}
}
/// 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(List(BitArray), Nil))
@external(javascript, "./smol.ffi.mjs", "bit_array_list_to_string")
fn bit_array_list_to_string(chunks: List(BitArray)) -> Result(String, Nil)
@external(javascript, "./smol.ffi.mjs", "read_form_data")
fn read_form_data(
request: Request,
max_body_size: Int,
max_file_size: Int,
next: fn(FormData) -> Promise(Response),
on_error: fn(Int) -> Promise(Response),
) -> Promise(Response)
// -- 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. `ETag` and `Last-Modified` headers will also be
/// set automatically based on the file size and modification time.
///
/// Use the [`cache`](#cache) middleware to respond with `304 Not Modified`
/// when the request's validators match the generated headers.
///
/// ## 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),
#("etag", generate_etag(file.size, file.mtime)),
#("last-modified", format_http_date(file.mtime)),
]),
)
}
}
}
fn generate_etag(file_size: Int, mtime: Timestamp) -> String {
let #(seconds, _) = timestamp.to_unix_seconds_and_nanoseconds(mtime)
int.to_base16(file_size) <> "-" <> int.to_base16(seconds)
}
@internal
pub type File {
File(
stream: ReadableStream,
size: Int,
mtime: Timestamp,
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
@external(javascript, "./smol.ffi.mjs", "cancel_stream")
fn cancel_stream(stream: ReadableStream) -> Promise(Nil)
/// 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 set the `Last-Modified` header on a response from a
/// `Timestamp`.
///
/// Pairs with the [`cache`](#cache) middleware, which uses the header to respond
/// with `304 Not Modified` for conditional requests.
///
/// ## Example
///
/// ```gleam
/// smol.send_html(render_post(post))
/// |> smol.last_modified(at: post.updated_at)
/// ```
///
pub fn last_modified(
response: Promise(Response),
at timestamp: Timestamp,
) -> Promise(Response) {
with_extra_headers(response, [#("last-modified", format_http_date(timestamp))])
}
/// 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)
let key = string.lowercase(key)
case header_allows_combined_values(key) {
True -> response.prepend_header(response, key, value)
False -> response.set_header(response, key, value)
}
})
}
fn header_allows_combined_values(key: String) -> Bool {
// RFC 9110 section 5.2 permits repeated field lines to be combined only when
// that field's definition allows comma-separated list values. This is a
// conservative allowlist of registered HTTP fields whose specs define
// list-style values, rather than a complete copy of the registry.
//
// https://www.rfc-editor.org/rfc/rfc9110.html#section-5.2
// https://www.iana.org/assignments/http-fields/http-fields.xhtml
case key {
"accept"
| "accept-ch"
| "accept-charset"
| "accept-encoding"
| "accept-language"
| "accept-patch"
| "accept-post"
| "accept-query"
| "accept-ranges"
| "accept-signature"
| "access-control-allow-headers"
| "access-control-allow-methods"
| "access-control-expose-headers"
| "access-control-request-headers"
| "allow"
| "alt-svc"
| "authentication-info"
| "cache-control"
| "cache-status"
| "cdn-cache-control"
| "clear-site-data"
| "client-cert-chain"
| "connection"
| "content-digest"
| "content-encoding"
| "content-language"
| "expect"
| "forwarded"
| "if-match"
| "if-none-match"
| "link"
| "pragma"
| "prefer"
| "priority"
| "proxy-authenticate"
| "proxy-authentication-info"
| "proxy-status"
| "referrer-policy"
| "reporting-endpoints"
| "repr-digest"
| "sec-websocket-extensions"
| "sec-websocket-version"
| "server-timing"
| "signature"
| "signature-input"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "vary"
| "via"
| "want-content-digest"
| "want-digest"
| "want-repr-digest"
| "warning"
| "www-authenticate" -> True
_ -> False
}
}
/// Convenience function to send a response as a file download.
///
/// Sets the `Content-Disposition` header to `attachment` using the given file
/// name.
///
/// ## Example
///
/// ```gleam
/// smol.send_string("{\"ok\":true}")
/// |> smol.as_file_download(named: "blob.json")
/// ```
///
pub fn as_file_download(
response: Promise(Response),
named name: String,
) -> Promise(Response) {
let name = uri.percent_encode(name)
response
|> with_extra_headers([
#("content-disposition", "attachment; filename=\"" <> name <> "\""),
])
}
// -- 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)
}
}
fn try_or(result, on_error, on_ok) {
case result {
Ok(value) -> on_ok(value)
Error(error) -> on_error(error)
}
}