//// //// //// //// **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 ` /// ``` /// /// 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: /// /// - /// - /// 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 = <> 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("

Hello, World!") /// } /// ``` /// pub fn send_html(text: String) -> Promise(Response) { let body = <> 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 = <> 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 = <> 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 = <> 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 = <> 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" <> } /// 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) } }