├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── README.md ├── gleam.toml ├── manifest.toml ├── src ├── cgi.gleam ├── cgi_ffi.erl └── cgi_ffi.mjs └── test └── cgi_test.gleam /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | pull_request: 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: erlef/setup-beam@v1 16 | with: 17 | otp-version: "26.0.2" 18 | gleam-version: "0.33.0" 19 | rebar3-version: "3" 20 | # elixir-version: "1.15.4" 21 | - run: gleam deps download 22 | - run: gleam test 23 | - run: gleam format --check src test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.beam 2 | *.ez 3 | build 4 | erl_crash.dump 5 | /cgi 6 | /cgi-bin 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.0.1 - 2024-01-16 4 | 5 | - Fixed an issue where `handle_request` failed to send a response. 6 | 7 | ## v1.0.0 - 2023-12-28 8 | 9 | - Initial release. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cgi 2 | 3 | Common Gateway Interface (CGI) in Gleam. 4 | 5 | [![Package Version](https://img.shields.io/hexpm/v/cgi)](https://hex.pm/packages/cgi) 6 | [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/cgi/) 7 | 8 | CGI is not commonly used these days, but there scenarios where it is still a 9 | good choice. As CGI programs only run when there is a request to handle they do 10 | not use any system resources when there is no traffic. This makes them very 11 | efficient for low traffic websites. 12 | 13 | It also make deployment really simple! Just copy the new version of the 14 | compiled CGI program onto the server and it'll be used for future requests. 15 | 16 | ```sh 17 | gleam add cgi 18 | ``` 19 | ```gleam 20 | import cgi 21 | import gleam/string 22 | import gleam/http/response.{Response} 23 | 24 | pub fn main() { 25 | use request <- cgi.handle_request 26 | let headers = [#("content-type", "text/plain")] 27 | let body = "Hello! You send me this request:\n\n" <> string.inspect(request) 28 | Response(201, headers, body) 29 | } 30 | ``` 31 | 32 | For your CGI server to run your program you may wish to [compile your Gleam 33 | program to an escript][gleescript] if using the Erlang target, or bundling it 34 | into a single JavaScript file if using the JavaScript target. 35 | 36 | [gleescript]: https://github.com/lpil/gleescript 37 | 38 | Further documentation can be found at . 39 | 40 | Thank you to Steven vanZyl for [plug_cgi][1], which was used as a 41 | reference. Why is there so little information on the CGI protcol online 42 | these days? 43 | 44 | [1]: https://github.com/rushsteve1/plug_cgi 45 | -------------------------------------------------------------------------------- /gleam.toml: -------------------------------------------------------------------------------- 1 | name = "cgi" 2 | version = "1.0.1" 3 | description = "Common Gateway Interface (CGI) in Gleam" 4 | licences = ["Apache-2.0"] 5 | repository = { type = "github", user = "lpil", repo = "cgi" } 6 | links = [ 7 | { title = "Website", href = "https://gleam.run" }, 8 | { title = "Sponsor", href = "https://github.com/sponsors/lpil" }, 9 | ] 10 | 11 | [dependencies] 12 | gleam_stdlib = "~> 0.32 or ~> 1.0" 13 | gleam_http = "~> 3.5" 14 | envoy = "~> 1.0" 15 | 16 | [dev-dependencies] 17 | gleeunit = "~> 1.0" 18 | -------------------------------------------------------------------------------- /manifest.toml: -------------------------------------------------------------------------------- 1 | # This file was generated by Gleam 2 | # You typically do not need to edit this file 3 | 4 | packages = [ 5 | { name = "envoy", version = "1.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "F9D3AFCF8627E8CBD0FA7296D7187BD024B8DBCF56A152E111A8ECEE27E5E45D" }, 6 | { name = "gleam_http", version = "3.5.3", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "C2FC3322203B16F897C1818D9810F5DEFCE347F0751F3B44421E1261277A7373" }, 7 | { name = "gleam_stdlib", version = "0.34.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1FB8454D2991E9B4C0C804544D8A9AD0F6184725E20D63C3155F0AEB4230B016" }, 8 | { name = "gleeunit", version = "1.0.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "D364C87AFEB26BDB4FB8A5ABDE67D635DC9FA52D6AB68416044C35B096C6882D" }, 9 | ] 10 | 11 | [requirements] 12 | envoy = { version = "~> 1.0" } 13 | gleam_http = { version = "~> 3.5" } 14 | gleam_stdlib = { version = "~> 0.32 or ~> 1.0" } 15 | gleeunit = { version = "~> 1.0" } 16 | -------------------------------------------------------------------------------- /src/cgi.gleam: -------------------------------------------------------------------------------- 1 | // Thank you to Steven vanZyl for [plug_cgi][1], which was used as a 2 | // reference. Why is there so little information on the CGI protcol online 3 | // these days? 4 | // 5 | // [1]: https://github.com/rushsteve1/plug_cgi 6 | 7 | import envoy 8 | import gleam/io 9 | import gleam/int 10 | import gleam/dict 11 | import gleam/list 12 | import gleam/string 13 | import gleam/result 14 | import gleam/option 15 | import gleam/http 16 | import gleam/http/request.{type Request, Request} 17 | import gleam/http/response.{type Response, Response} 18 | 19 | /// Load a CGI HTTP request and dispatch a response. 20 | /// 21 | /// CGI works over stdin and stdout, so be sure your code does not use them for 22 | /// this will likely cause the program to fail. 23 | /// 24 | pub fn handle_request(f: fn(Request(BitArray)) -> Response(String)) -> Nil { 25 | use request <- with_request_body(load_request()) 26 | f(request) 27 | |> send_response 28 | } 29 | 30 | /// Send a CGI HTTP response by printing it to stdout. 31 | /// 32 | pub fn send_response(response: Response(String)) -> Nil { 33 | let s = response.status 34 | io.println("status: " <> int.to_string(s) <> " " <> status_phrase(s)) 35 | 36 | let l = string.byte_size(response.body) 37 | io.println("content-length: " <> int.to_string(l)) 38 | 39 | list.each(response.headers, fn(header) { 40 | io.println(header.0 <> ": " <> header.1) 41 | }) 42 | io.println("") 43 | io.println(response.body) 44 | } 45 | 46 | /// Load a CGI HTTP request from the environment. 47 | /// 48 | /// The body of the request is not loaded. Use `read_body` or 49 | /// `with_request_body` to load the body into the request. 50 | /// 51 | pub fn load_request() -> Request(BitArray) { 52 | let env = envoy.all() 53 | 54 | let method = 55 | env 56 | |> dict.get("REQUEST_METHOD") 57 | |> result.try(http.parse_method) 58 | |> result.unwrap(http.Get) 59 | 60 | let port = 61 | env 62 | |> dict.get("SERVER_PORT") 63 | |> result.try(int.parse) 64 | |> option.from_result 65 | 66 | let scheme = case dict.get(env, "HTTPS") { 67 | Ok(_) -> http.Https 68 | _ -> http.Http 69 | } 70 | 71 | let query = option.from_result(dict.get(env, "QUERY_STRING")) 72 | let host = 73 | dict.get(env, "SERVER_NAME") 74 | |> result.unwrap("localhost") 75 | 76 | let path = 77 | dict.get(env, "PATH_INFO") 78 | |> result.unwrap("/") 79 | 80 | let headers = 81 | env 82 | |> dict.to_list 83 | |> list.filter_map(fn(pair) { 84 | case pair.0 { 85 | "CONTENT_TYPE" -> Ok(#("content-type", pair.1)) 86 | "CONTENT_LENGTH" -> Ok(#("content-length", pair.1)) 87 | "HTTP_" <> name -> 88 | Ok(#(string.replace(string.lowercase(name), "_", "-"), pair.1)) 89 | _ -> Error(Nil) 90 | } 91 | }) 92 | 93 | let body = <<>> 94 | 95 | Request( 96 | method: method, 97 | headers: headers, 98 | body: body, 99 | scheme: scheme, 100 | host: host, 101 | port: port, 102 | path: path, 103 | query: query, 104 | ) 105 | } 106 | 107 | /// Load the body of a request from stdin. 108 | /// 109 | /// Due to how IO works in JavaScript may not always work on JavaScript, 110 | /// especially if the body ir larger or on Windows. Consider using the 111 | /// `with_request_body` function instead, which works on all platforms. 112 | /// 113 | pub fn read_body(request: Request(BitArray)) -> Request(BitArray) { 114 | let body = read_body_sync(request_content_length(request)) 115 | Request(..request, body: body) 116 | } 117 | 118 | fn request_content_length(request: Request(BitArray)) -> Int { 119 | request 120 | |> request.get_header("content-length") 121 | |> result.try(int.parse) 122 | |> result.unwrap(0) 123 | } 124 | 125 | @external(erlang, "cgi_ffi", "read_body_sync") 126 | @external(javascript, "./cgi_ffi.mjs", "read_body_sync") 127 | fn read_body_sync(length: Int) -> BitArray 128 | 129 | /// Load the body of a request from stdin, running a callback with the 130 | /// response with the body. 131 | /// 132 | pub fn with_request_body( 133 | request: Request(BitArray), 134 | handle: fn(Request(BitArray)) -> anything, 135 | ) -> Nil { 136 | read_body_async(request_content_length(request), fn(body) { 137 | handle(Request(..request, body: body)) 138 | }) 139 | } 140 | 141 | @external(javascript, "./cgi_ffi.mjs", "read_body_async") 142 | fn read_body_async(length: Int, handle: fn(BitArray) -> anything) -> Nil { 143 | let body = read_body_sync(length) 144 | handle(body) 145 | Nil 146 | } 147 | 148 | fn status_phrase(status: Int) -> String { 149 | case status { 150 | 100 -> "Continue" 151 | 101 -> "Switching Protocols" 152 | 102 -> "Processing" 153 | 103 -> "Early Hints" 154 | 200 -> "OK" 155 | 201 -> "Created" 156 | 202 -> "Accepted" 157 | 203 -> "Non-Authoritative Information" 158 | 204 -> "No Content" 159 | 205 -> "Reset Content" 160 | 206 -> "Partial Content" 161 | 207 -> "Multi-Status" 162 | 208 -> "Already Reported" 163 | 226 -> "IM Used" 164 | 300 -> "Multiple Choices" 165 | 301 -> "Moved Permanently" 166 | 302 -> "Found" 167 | 303 -> "See Other" 168 | 304 -> "Not Modified" 169 | 305 -> "Use Proxy" 170 | 306 -> "Switch Proxy" 171 | 307 -> "Temporary Redirect" 172 | 308 -> "Permanent Redirect" 173 | 400 -> "Bad Request" 174 | 401 -> "Unauthorized" 175 | 402 -> "Payment Required" 176 | 403 -> "Forbidden" 177 | 404 -> "Not Found" 178 | 405 -> "Method Not Allowed" 179 | 406 -> "Not Acceptable" 180 | 407 -> "Proxy Authentication Required" 181 | 408 -> "Request Timeout" 182 | 409 -> "Conflict" 183 | 410 -> "Gone" 184 | 411 -> "Length Required" 185 | 412 -> "Precondition Failed" 186 | 413 -> "Request Entity Too Large" 187 | 414 -> "Request-URI Too Long" 188 | 415 -> "Unsupported Media Type" 189 | 416 -> "Requested Range Not Satisfiable" 190 | 417 -> "Expectation Failed" 191 | 418 -> "I'm a teapot" 192 | 421 -> "Misdirected Request" 193 | 422 -> "Unprocessable Entity" 194 | 423 -> "Locked" 195 | 424 -> "Failed Dependency" 196 | 425 -> "Too Early" 197 | 426 -> "Upgrade Required" 198 | 428 -> "Precondition Required" 199 | 429 -> "Too Many Requests" 200 | 431 -> "Request Header Fields Too Large" 201 | 451 -> "Unavailable For Legal Reasons" 202 | 500 -> "Internal Server Error" 203 | 501 -> "Not Implemented" 204 | 502 -> "Bad Gateway" 205 | 503 -> "Service Unavailable" 206 | 504 -> "Gateway Timeout" 207 | 505 -> "HTTP Version Not Supported" 208 | 506 -> "Variant Also Negotiates" 209 | 507 -> "Insufficient Storage" 210 | 508 -> "Loop Detected" 211 | 510 -> "Not Extended" 212 | 511 -> "Network Authentication Required" 213 | _ -> "" 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/cgi_ffi.erl: -------------------------------------------------------------------------------- 1 | -module(cgi_ffi). 2 | -export([read_body_sync/1]). 3 | 4 | read_body_sync(Length) -> 5 | try 6 | io:get_chars(standard_io, "", Length) 7 | catch 8 | _:_ -> <<>> 9 | end. 10 | -------------------------------------------------------------------------------- /src/cgi_ffi.mjs: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import { BitArray } from "./gleam.mjs"; 3 | import { readFileSync } from "node:fs"; 4 | import { Buffer } from "node:buffer"; 5 | 6 | export function read_body_sync(size) { 7 | const buf = new Uint8Array(size); 8 | fs.readSync(process.stdin.fd, buf, 0, size); 9 | return new BitArray(buf); 10 | } 11 | 12 | export function read_body_async(size, next) { 13 | const buffer = new Uint8Array(size); 14 | let remaining = size; 15 | let offset = 0; 16 | 17 | const finish = () => { 18 | process.stdin.removeListener("end", finish); 19 | process.stdin.removeListener("error", finish); 20 | process.stdin.removeListener("data", read); 21 | next(new BitArray(buffer)); 22 | }; 23 | 24 | const read = (data) => { 25 | let chunk = new Uint8Array(data); 26 | if (remaining < chunk.length) chunk = chunk.slice(0, remaining); 27 | buffer.set(chunk, offset); 28 | offset += chunk.length; 29 | remaining -= chunk.length; 30 | if (remaining <= 0) finish(); 31 | }; 32 | 33 | process.stdin.on("end", finish); 34 | process.stdin.on("error", finish); 35 | process.stdin.on("data", read); 36 | } 37 | -------------------------------------------------------------------------------- /test/cgi_test.gleam: -------------------------------------------------------------------------------- 1 | import gleeunit 2 | import gleeunit/should 3 | 4 | pub fn main() { 5 | gleeunit.main() 6 | } 7 | 8 | pub fn build_request_test() { 9 | 1 10 | |> should.equal(1) 11 | } 12 | --------------------------------------------------------------------------------