@resapi/express 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Seaside Logic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,11 @@
1
- # Temporary Holding Version
1
+ # @resapi/express
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Express](https://expressjs.com) 5 server adapter for [resAPI](https://github.com/Seaside-Logic/resapi) contracts.
4
+
5
+ ```sh
6
+ npm install @resapi/core @resapi/express express
7
+ ```
8
+
9
+ Add `"@resapi/core"` and `"@resapi/express"` to `dependencies` in `rescript.json`. Modules are under the `ResApiExpress` namespace. Requires Express 5 (tested with 5.2).
10
+
11
+ See the [README](https://github.com/Seaside-Logic/resapi#readme) and [API reference](https://github.com/Seaside-Logic/resapi/blob/main/docs/api.md).
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@resapi/express",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Express server adapter for resAPI contracts.",
5
+ "license": "MIT",
6
+ "homepage": "https://resapi.dev",
7
+ "bugs": {
8
+ "url": "https://github.com/Seaside-Logic/resapi/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Seaside-Logic/resapi.git",
13
+ "directory": "packages/express"
14
+ },
15
+ "keywords": [
16
+ "rescript",
17
+ "http",
18
+ "api",
19
+ "contract",
20
+ "typed-client",
21
+ "express",
22
+ "node"
23
+ ],
24
+ "type": "module",
25
+ "files": [
26
+ "src/*.res",
27
+ "src/*.resi",
28
+ "rescript.json"
29
+ ],
30
+ "peerDependencies": {
31
+ "express": "^5.2.1",
32
+ "rescript": "^12.3.1"
33
+ },
34
+ "dependencies": {
35
+ "@resapi/core": "^0.1.0"
36
+ },
37
+ "devDependencies": {
38
+ "express": "5.2.1"
39
+ }
40
+ }
package/rescript.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "@resapi/express",
3
+ "sources": {"dir": "src"},
4
+ "dependencies": ["@resapi/core"],
5
+ "package-specs": {"module": "esmodule", "in-source": true},
6
+ "suffix": ".res.mjs",
7
+ "namespace": "ResApiExpress"
8
+ }
@@ -0,0 +1,39 @@
1
+ // Turns a contract into a route: the request runs through core's Dispatch pipeline, and the result is
2
+ // sent with `res.status().json()`. The handler's types are closed over, so routes of any contract types share one type.
3
+ open ResApi
4
+
5
+ type input<'params, 'body> = Dispatch.input<'params, 'body> = {params: 'params, body: 'body}
6
+
7
+ type handler<'params, 'body, 'response, 'error> = (
8
+ Req.t,
9
+ input<'params, 'body>,
10
+ ) => promise<result<'response, 'error>>
11
+
12
+ type route = {
13
+ method: Endpoint.method,
14
+ path: string,
15
+ run: (Req.t, Res.t) => promise<unit>,
16
+ }
17
+
18
+ // Router.register mounts POST routes behind `express.text`, so the body is the raw string. A body
19
+ // already parsed by an upstream `express.json()` is used as it is. No body at all parses as "".
20
+ let rawBody = req =>
21
+ switch req->Req.body->Nullable.toOption {
22
+ | None => Dispatch.Text(Promise.resolve(""))
23
+ | Some(JSON.String(text)) => Text(Promise.resolve(text))
24
+ | Some(json) => Parsed(json)
25
+ }
26
+
27
+ let make = (contract: Endpoint.t<_, _, _, _>, handler: handler<_, _, _, _>) => {
28
+ method: contract.method,
29
+ path: contract.path,
30
+ run: async (req, res) => {
31
+ let {status, body} = await Dispatch.run(
32
+ contract,
33
+ ~params=req->Req.params,
34
+ ~readBody=() => rawBody(req),
35
+ ~handle=input => handler(req, input),
36
+ )
37
+ res->Res.status(status)->Res.json(body)
38
+ },
39
+ }
@@ -0,0 +1,24 @@
1
+ // Turns a contract into a route: the request runs through core's Dispatch pipeline, and the result is
2
+ // sent with `res.status().json()`.
3
+
4
+ type input<'params, 'body> = ResApi.Dispatch.input<'params, 'body> = {params: 'params, body: 'body}
5
+
6
+ /** `Error(e)` must be one of the contract's declared application errors (see `Endpoint.withErrors`). */
7
+ type handler<'params, 'body, 'response, 'error> = (
8
+ Req.t,
9
+ input<'params, 'body>,
10
+ ) => promise<result<'response, 'error>>
11
+
12
+ /** Mount with `Router.register`. */
13
+ type route = private {
14
+ method: ResApi.Endpoint.method,
15
+ path: string,
16
+ run: (Req.t, Res.t) => promise<unit>,
17
+ }
18
+
19
+ /** Invalid input is a 400, and any exception from a codec or the handler is logged and answered with a
20
+ 500 that reveals nothing. */
21
+ let make: (
22
+ ResApi.Endpoint.t<'params, 'body, 'response, 'error>,
23
+ handler<'params, 'body, 'response, 'error>,
24
+ ) => route
package/src/Req.res ADDED
@@ -0,0 +1,12 @@
1
+ // Express's request object.
2
+ type t
3
+
4
+ /** Path parameters, already percent-decoded by Express. */
5
+ @get external params: t => dict<string> = "params"
6
+
7
+ /** Set by body-parsing middleware: a string from `express.text`, parsed JSON from `express.json`. */
8
+ @get external body: t => Nullable.t<JSON.t> = "body"
9
+
10
+ @send @return(nullable) external header: (t, string) => option<string> = "get"
11
+ @get external method: t => string = "method"
12
+ @get external path: t => string = "path"
package/src/Res.res ADDED
@@ -0,0 +1,7 @@
1
+ // Express's response object.
2
+ type t
3
+
4
+ @send external status: (t, int) => t = "status"
5
+ @send external json: (t, JSON.t) => unit = "json"
6
+ @send external send: (t, string) => unit = "send"
7
+ @send external setHeader: (t, string, string) => t = "set"
package/src/Router.res ADDED
@@ -0,0 +1,75 @@
1
+ // A router is the Express app itself, so any Express API missing here can be bound against `t` with one
2
+ // more external. Express 5 sends a rejected handler promise to its error handler (by default: log, then
3
+ // a 500; outside production the body shows the stack, as Express always does).
4
+
5
+ type t
6
+
7
+ type handler = (Req.t, Res.t) => promise<unit>
8
+
9
+ type next = unit => unit
10
+
11
+ type middleware = (Req.t, Res.t, next) => unit
12
+
13
+ type server
14
+
15
+ @module("express") external make: unit => t = "default"
16
+ @module("express") external text: {"type": string => bool} => middleware = "text"
17
+ @send external expressGet: (t, string, handler) => unit = "get"
18
+ @send external expressPost: (t, string, handler) => unit = "post"
19
+ @send external expressPostWith: (t, string, middleware, handler) => unit = "post"
20
+ @send external expressUse: (t, middleware) => unit = "use"
21
+ @send external expressUseAt: (t, string, middleware) => unit = "use"
22
+
23
+ @send external listen: (t, int, unit => unit) => server = "listen"
24
+ @send external close: server => unit = "close"
25
+
26
+ // Express's error handler logs `err.stack`, which a raw ReScript exception lacks: it logs as
27
+ // "[object Object]". Rethrowing it as an Error keeps the log readable. JS errors pass untouched.
28
+ let rethrowAsError = exn =>
29
+ switch exn {
30
+ | JsExn(e) => JsExn.throw(e)
31
+ | exn =>
32
+ JsError.throwWithMessage(
33
+ `Uncaught ReScript exception: ${exn->JSON.stringifyAny->Option.getOr("unknown")}`,
34
+ )
35
+ }
36
+
37
+ let guard = (handler: handler): handler =>
38
+ async (req, res) =>
39
+ try await handler(req, res) catch {
40
+ | exn => rethrowAsError(exn)
41
+ }
42
+
43
+ let get = (router, path, handler) => {
44
+ router->expressGet(path, guard(handler))
45
+ router
46
+ }
47
+
48
+ let post = (router, path, handler) => {
49
+ router->expressPost(path, guard(handler))
50
+ router
51
+ }
52
+
53
+ let use = (router, ~path=?, middleware: middleware) => {
54
+ let guarded = (req, res, next) =>
55
+ try middleware(req, res, next) catch {
56
+ | exn => rethrowAsError(exn)
57
+ }
58
+ switch path {
59
+ | None => router->expressUse(guarded)
60
+ | Some(path) => router->expressUseAt(path, guarded)
61
+ }
62
+ router
63
+ }
64
+
65
+ // Reads any request body as raw text, so Dispatch reports malformed JSON itself. It skips a body an
66
+ // upstream parser already read.
67
+ let rawText = text({"type": _ => true})
68
+
69
+ let register = (router, route: EndpointHandler.route) => {
70
+ switch route.method {
71
+ | Get => router->expressGet(route.path, route.run)
72
+ | Post => router->expressPostWith(route.path, rawText, route.run)
73
+ }
74
+ router
75
+ }
@@ -0,0 +1,32 @@
1
+ // A router is the Express app itself, so any Express API missing here can be bound against `t` with one
2
+ // more external.
3
+
4
+ type t
5
+
6
+ /** Handlers are always async: `async (_req, res) => res->Res.send("hi")`. */
7
+ type handler = (Req.t, Res.t) => promise<unit>
8
+
9
+ type next = unit => unit
10
+
11
+ /** Native Express middleware: call `next()` to continue, or answer through `res`. Express's own
12
+ middleware (`express.json()`, `express.static(...)`) binds to this type directly. */
13
+ type middleware = (Req.t, Res.t, next) => unit
14
+
15
+ type server
16
+
17
+ @module("express") external make: unit => t = "default"
18
+
19
+ /** Starts a Node HTTP server. Port 0 picks a free port. */
20
+ @send external listen: (t, int, unit => unit) => server = "listen"
21
+ @send external close: server => unit = "close"
22
+
23
+ /** A rejected handler goes to Express's error handler. */
24
+ let get: (t, string, handler) => t
25
+ let post: (t, string, handler) => t
26
+
27
+ /** Runs for every request whose path starts with `~path` (default all), including unmatched routes.
28
+ The first `use` is the outermost layer. */
29
+ let use: (t, ~path: string=?, middleware) => t
30
+
31
+ /** POST endpoints get a raw-text body parser (100kb limit). A body already parsed upstream is used as is. */
32
+ let register: (t, EndpointHandler.route) => t