From 68a17919667976db23317f0efe8c5ad8489de4ac Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:12:48 +0000 Subject: [PATCH] Add type-safe HTTP server for ReScript using Deno.serve - Add HttpServer.res with FFI bindings to Deno.serve - Add HttpServer.resi interface with full API surface - Add routing helpers (get, post, put, patch, delete) - Add middleware support (cors, logging) - Add response helpers (respond, respondJson, respondHtml, etc.) - Add deno.json and rescript.json configurations - Update .gitignore with ReScript build artifacts --- .gitignore | 3 + deno.json | 13 ++ rescript.json | 8 ++ src/HttpServer.res | 285 ++++++++++++++++++++++++++++++++++++++++++++ src/HttpServer.resi | 80 +++++++++++++ 5 files changed, 389 insertions(+) create mode 100644 deno.json create mode 100644 rescript.json create mode 100644 src/HttpServer.res create mode 100644 src/HttpServer.resi diff --git a/.gitignore b/.gitignore index 0338461..cbdd3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,11 @@ erl_crash.dump /Manifest.toml # ReScript +lib/ /lib/bs/ /.bsb.lock +*.res.js +.merlin # Python (SaltStack only) __pycache__/ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..cf95308 --- /dev/null +++ b/deno.json @@ -0,0 +1,13 @@ +{ + "name": "@hyperpolymath/rescript-http-server", + "version": "0.1.0", + "exports": "./src/HttpServer.res.js", + "tasks": { + "build": "rescript build", + "clean": "rescript clean", + "dev": "rescript build -w" + }, + "compilerOptions": { + "lib": ["deno.ns", "deno.unstable"] + } +} diff --git a/rescript.json b/rescript.json new file mode 100644 index 0000000..4f0d416 --- /dev/null +++ b/rescript.json @@ -0,0 +1,8 @@ +{ + "name": "@hyperpolymath/rescript-http-server", + "sources": [{"dir": "src", "subdirs": true}], + "package-specs": [{"module": "esmodule", "in-source": true}], + "suffix": ".res.js", + "bs-dependencies": ["@rescript/core"], + "bsc-flags": ["-open RescriptCore"] +} diff --git a/src/HttpServer.res b/src/HttpServer.res new file mode 100644 index 0000000..bac71e1 --- /dev/null +++ b/src/HttpServer.res @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Hyperpolymath + +@@uncurried + +/** + * Type-safe HTTP server for ReScript using Deno.serve. + */ + +/** HTTP method */ +type method = + | @as("GET") Get + | @as("POST") Post + | @as("PUT") Put + | @as("PATCH") Patch + | @as("DELETE") Delete + | @as("HEAD") Head + | @as("OPTIONS") Options + +/** Deno server instance */ +type server + +/** Request object */ +type request = Fetch.Request.t + +/** Response object */ +type response = Fetch.Response.t + +/** Response init */ +type responseInit = { + status?: int, + statusText?: string, + headers?: Dict.t, +} + +/** Server options */ +type serveOptions = { + port?: int, + hostname?: string, + onListen?: {"port": int, "hostname": string} => unit, + onError?: Error.t => response, +} + +/** Handler function type */ +type handler = request => promise + +/** Info about listening server */ +type listenInfo = { + port: int, + hostname: string, +} + +// FFI bindings +@val @scope("Deno") +external serve: (serveOptions, handler) => server = "serve" + +@val @scope("Deno") +external serveHandler: handler => server = "serve" + +@send +external shutdown: server => promise = "shutdown" + +@send +external finished: server => promise = "finished" + +@get +external addr: server => listenInfo = "addr" + +// Request helpers +@get external requestMethod: request => string = "method" +@get external requestUrl: request => string = "url" +@get external requestHeaders: request => Fetch.Headers.t = "headers" +@send external requestText: request => promise = "text" +@send external requestJson: request => promise = "json" +@send external requestArrayBuffer: request => promise = "arrayBuffer" +@send external requestFormData: request => promise = "formData" + +/** Get the HTTP method */ +let method = (req: request): method => { + switch requestMethod(req) { + | "GET" => Get + | "POST" => Post + | "PUT" => Put + | "PATCH" => Patch + | "DELETE" => Delete + | "HEAD" => Head + | "OPTIONS" => Options + | _ => Get + } +} + +/** Get URL path */ +let path = (req: request): string => { + let url = requestUrl(req) + try { + let urlObj = URL.make(url) + urlObj->URL.pathname + } catch { + | _ => url + } +} + +/** Get URL search params */ +let searchParams = (req: request): URLSearchParams.t => { + let url = requestUrl(req) + try { + let urlObj = URL.make(url) + urlObj->URL.searchParams + } catch { + | _ => URLSearchParams.make("") + } +} + +/** Get a header value */ +let header = (req: request, name: string): option => { + requestHeaders(req)->Fetch.Headers.get(name) +} + +/** Read body as text */ +let text = (req: request): promise => requestText(req) + +/** Read body as JSON */ +let json = (req: request): promise => requestJson(req) + +// Response helpers +@new external makeResponse: (string, responseInit) => response = "Response" +@new external makeResponseWithBody: (string) => response = "Response" + +/** Create a text response */ +let respond = (~status: int=200, ~headers: Dict.t=Dict.make(), body: string): response => { + headers->Dict.set("Content-Type", "text/plain; charset=utf-8") + makeResponse(body, {status, headers}) +} + +/** Create a JSON response */ +let respondJson = (~status: int=200, ~headers: Dict.t=Dict.make(), data: JSON.t): response => { + headers->Dict.set("Content-Type", "application/json; charset=utf-8") + makeResponse(JSON.stringify(data), {status, headers}) +} + +/** Create an HTML response */ +let respondHtml = (~status: int=200, ~headers: Dict.t=Dict.make(), html: string): response => { + headers->Dict.set("Content-Type", "text/html; charset=utf-8") + makeResponse(html, {status, headers}) +} + +/** Create a redirect response */ +let redirect = (~status: int=302, location: string): response => { + makeResponse("", {status, headers: Dict.fromArray([("Location", location)])}) +} + +/** Create a 404 response */ +let notFound = (~body: string="Not Found"): response => { + respond(~status=404, body) +} + +/** Create a 400 response */ +let badRequest = (~body: string="Bad Request"): response => { + respond(~status=400, body) +} + +/** Create a 500 response */ +let serverError = (~body: string="Internal Server Error"): response => { + respond(~status=500, body) +} + +/** Create a 401 response */ +let unauthorized = (~body: string="Unauthorized"): response => { + respond(~status=401, body) +} + +/** Create a 403 response */ +let forbidden = (~body: string="Forbidden"): response => { + respond(~status=403, body) +} + +/** Route definition */ +type route = { + method: method, + pattern: string, + handler: request => promise, +} + +/** Simple path matcher */ +let matchPath = (pattern: string, requestPath: string): bool => { + if pattern == requestPath { + true + } else if pattern->String.endsWith("*") { + let prefix = pattern->String.slice(~start=0, ~end=-1) + requestPath->String.startsWith(prefix) + } else { + false + } +} + +/** Create a simple router */ +let router = (routes: array): handler => { + async request => { + let reqMethod = method(request) + let reqPath = path(request) + + let matchedRoute = routes->Array.find(route => { + route.method == reqMethod && matchPath(route.pattern, reqPath) + }) + + switch matchedRoute { + | Some(route) => await route.handler(request) + | None => notFound() + } + } +} + +/** Route helper for GET */ +let get = (pattern: string, handler: request => promise): route => { + {method: Get, pattern, handler} +} + +/** Route helper for POST */ +let post = (pattern: string, handler: request => promise): route => { + {method: Post, pattern, handler} +} + +/** Route helper for PUT */ +let put = (pattern: string, handler: request => promise): route => { + {method: Put, pattern, handler} +} + +/** Route helper for PATCH */ +let patch = (pattern: string, handler: request => promise): route => { + {method: Patch, pattern, handler} +} + +/** Route helper for DELETE */ +let delete = (pattern: string, handler: request => promise): route => { + {method: Delete, pattern, handler} +} + +/** Middleware type */ +type middleware = (request, handler) => promise + +/** Apply middleware to a handler */ +let withMiddleware = (handler: handler, middleware: middleware): handler => { + async request => { + await middleware(request, handler) + } +} + +/** CORS middleware */ +let cors = ( + ~origin: string="*", + ~methods: string="GET, POST, PUT, PATCH, DELETE, OPTIONS", + ~headers: string="Content-Type, Authorization", +): middleware => { + async (request, next) => { + if method(request) == Options { + makeResponse( + "", + { + status: 204, + headers: Dict.fromArray([ + ("Access-Control-Allow-Origin", origin), + ("Access-Control-Allow-Methods", methods), + ("Access-Control-Allow-Headers", headers), + ]), + }, + ) + } else { + let response = await next(request) + response + } + } +} + +/** Logging middleware */ +let logging = (): middleware => { + async (request, next) => { + let start = Date.now() + let response = await next(request) + let duration = Date.now() -. start + Console.log( + `${requestMethod(request)} ${path(request)} - ${Float.toString(duration)}ms`, + ) + response + } +} diff --git a/src/HttpServer.resi b/src/HttpServer.resi new file mode 100644 index 0000000..7104cff --- /dev/null +++ b/src/HttpServer.resi @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Hyperpolymath + +type method = + | @as("GET") Get + | @as("POST") Post + | @as("PUT") Put + | @as("PATCH") Patch + | @as("DELETE") Delete + | @as("HEAD") Head + | @as("OPTIONS") Options + +type server +type request = Fetch.Request.t +type response = Fetch.Response.t + +type responseInit = { + status?: int, + statusText?: string, + headers?: Dict.t, +} + +type serveOptions = { + port?: int, + hostname?: string, + onListen?: {"port": int, "hostname": string} => unit, + onError?: Error.t => response, +} + +type handler = request => promise + +type listenInfo = { + port: int, + hostname: string, +} + +let serve: (serveOptions, handler) => server +let serveHandler: handler => server +let shutdown: server => promise +let finished: server => promise +let addr: server => listenInfo + +let method: request => method +let path: request => string +let searchParams: request => URLSearchParams.t +let header: (request, string) => option +let text: request => promise +let json: request => promise +let requestUrl: request => string +let requestMethod: request => string +let requestHeaders: request => Fetch.Headers.t + +let respond: (~status: int=?, ~headers: Dict.t=?, string) => response +let respondJson: (~status: int=?, ~headers: Dict.t=?, JSON.t) => response +let respondHtml: (~status: int=?, ~headers: Dict.t=?, string) => response +let redirect: (~status: int=?, string) => response +let notFound: (~body: string=?) => response +let badRequest: (~body: string=?) => response +let serverError: (~body: string=?) => response +let unauthorized: (~body: string=?) => response +let forbidden: (~body: string=?) => response + +type route = { + method: method, + pattern: string, + handler: request => promise, +} + +let router: array => handler +let matchPath: (string, string) => bool +let get: (string, request => promise) => route +let post: (string, request => promise) => route +let put: (string, request => promise) => route +let patch: (string, request => promise) => route +let delete: (string, request => promise) => route + +type middleware = (request, handler) => promise +let withMiddleware: (handler, middleware) => handler +let cors: (~origin: string=?, ~methods: string=?, ~headers: string=?) => middleware +let logging: unit => middleware