diff --git a/README.md b/README.md
index eb19b47..5acc2f3 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ This is a simple HTTP server for Motoko. Its interface is designed to be similar
Check out the [examples](./examples) directory for examples of how to use this library.
-Live http_greet example: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/]([https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/])
+Live http_greet example: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/](https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/)
# Installation
@@ -68,13 +68,33 @@ actor {
}
```
+### Enabling CORS (Cross-Origin Resource Sharing)
+
+If you are building a web application where the frontend is served from a different origin than your canister, the browser will block `fetch` requests for security reasons. To allow your frontend to call your canister's API, you must enable CORS.
+
+This library provides a simple helper function to enable CORS for all routes. Call it right after initializing your server.
+
+```lua
+// After initializing the server
+server.enableCors(
+ // Allowed origins. Use "*" for public APIs, or a specific origin for production.
+ "*",
+ // Allowed HTTP methods.
+ "GET, POST, OPTIONS",
+ // Allowed request headers.
+ "Content-Type, Authorization"
+);
+```
+
+This will automatically handle preflight `OPTIONS` requests and add the necessary `Access-Control-Allow-Origin` header to your responses.
+
## Adding routes
-As with the Express.js library, you can add routes to the server using the `get`, `post`, `put`, and `delete` functions.
+As with the Express.js library, you can add routes to the server using the `get`, `post`, `put`, `delete`, and `options` functions.
Each of these functions takes a path and a callback function. The callback function is called when a request is made to the server with a matching path.
-The callback function takes a `Request` object and a `Response` object. The `Request` object contains information about the request, such as the request body, query parameters, and headers. The `Response` object is used to send a response to the client.
+The callback function takes a `Request` object and a `ResponseClass` object. The `Request` object contains information about the request, such as the request body, query parameters, and headers. The `ResponseClass` object is used to send a response to the client.
Here is an example of how to add a route to the server:
@@ -105,6 +125,24 @@ server.get("/api", func (req : Request, res : ResponseClass) : async Response {
});
```
+While the `server.enableCors()` helper is recommended, you can also define manual `OPTIONS` routes for fine-grained control over specific endpoints.
+
+```lua
+server.options("/my-custom-route", func (req : Request, res : ResponseClass) : async Response {
+ res.send({
+ status_code = 204; // No Content
+ headers = [
+ ("Access-Control-Allow-Origin", "https://my-frontend.com"),
+ ("Access-Control-Allow-Methods", "PUT, OPTIONS"),
+ ("Access-Control-Allow-Headers", "X-Custom-Header")
+ ];
+ body = Blob.fromArray([]);
+ streaming_strategy = null;
+ cache_strategy = #noCache;
+ });
+});
+```
+
## Request and response objects
The `Request` object contains information about the request, such as the request body, query parameters, and headers.
@@ -147,7 +185,7 @@ For requests that are not cached, the server will upgrade the request to an upda
See the `examples` directory for examples of how to use this library. These examples are also available on the Internet Computer as canisters:
-- Http Greet: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/]([https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/])
+- Http Greet: [https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/](https://qg33c-4aaaa-aaaab-qaica-cai.ic0.app/)
## Roadmap
@@ -175,7 +213,7 @@ Below are all of the types and functions that are exported by this library, as w
- `type HttpRequest = Server.HttpRequest` - [./src/Server.mo](https://github.com/krpeacock/certified-cache/blob/c1f209d14f490f905b7de2a2bd3f917377310675/src/Http.mo#L36)
- `type HttpResponse = Server.HttpResponse` - [./src/Server.mo](https://github.com/krpeacock/certified-cache/blob/c1f209d14f490f905b7de2a2bd3f917377310675/src/Http.mo#L28)
- `type Request = Server.Request` - [./src/Server.mo](https://github.com/NatLabs/http-parser.mo/blob/27cba8ed0d39387e0fb660f65909ffe2a7d54413/src/Types.mo#L92)
-- `type Response = Server.Response` -
+- `type Response = Server.Response` -
```
{
status_code : Nat16;
@@ -189,15 +227,21 @@ Below are all of the types and functions that are exported by this library, as w
```
(
[(HttpRequest, (HttpResponse, Nat))],
- [(AssetTypes.Key, Assets.StableAsset)],
+ [(AssetTypes.Key, Assets.StableAsset)],
[Principal]
)
```
### Classes
-- Server - the primary export of this library
-- ResponseClass - a class provided during `get`, `post`, `put`, and `delete`, with the following methods:
+- **Server** - The primary export of this library.
+ - `get(path, handler)`
+ - `post(path, handler)`
+ - `put(path, handler)`
+ - `delete(path, handler)`
+ - `options(path, handler)`
+ - `enableCors(origin, methods, headers)`
+- **ResponseClass** - A class provided to handlers with the following methods:
- `send (Response) : async ()`
- `json (Response) : async ()`
@@ -212,4 +256,4 @@ These functions are used internally by the library, but are also exported for us
- `public func encodeRequest(req : HttpRequest) : Blob`
- Encodes a request as a blob
- `public func yieldResponse(b : HttpResponse) : Blob`
- - Encodes a response as a blob
+ - Encodes a response as a blob
\ No newline at end of file
diff --git a/examples/http_greet/src/http_greet/main.mo b/examples/http_greet/src/http_greet/main.mo
index 7245803..fac3ea1 100644
--- a/examples/http_greet/src/http_greet/main.mo
+++ b/examples/http_greet/src/http_greet/main.mo
@@ -12,7 +12,7 @@ shared ({ caller = creator }) actor class () {
type HttpResponse = Server.HttpResponse;
type ResponseClass = Server.ResponseClass;
- stable var serializedEntries : Server.SerializedEntries = ([], [], [creator]);
+ var serializedEntries : Server.SerializedEntries = ([], [], [creator]);
var server = Server.Server({ serializedEntries });
@@ -53,6 +53,19 @@ shared ({ caller = creator }) actor class () {
},
);
+ server.get(
+ "/test",
+ func(req : Request, res : ResponseClass) : async Response {
+ res.send({
+ status_code = 200;
+ headers = [("Content-Type", "text/html")];
+ body = Text.encodeUtf8("
Test
");
+ streaming_strategy = null;
+ cache_strategy = #default;
+ });
+ },
+ );
+
public shared ({ caller }) func authorize(other : Principal) : async () {
server.authorize({
caller;
diff --git a/examples/test/test.mo b/examples/test/test.mo
index dc14469..4eb062e 100644
--- a/examples/test/test.mo
+++ b/examples/test/test.mo
@@ -18,6 +18,12 @@ shared ({ caller = creator }) actor class () {
var server = Server.Server({ serializedEntries });
+ server.enableCors(
+ "*",
+ "GET, POST, OPTIONS",
+ "Content-Type, Authorization",
+ );
+
server.get(
"/",
func(_ : Request, res : ResponseClass) : async Response {
diff --git a/src/lib.mo b/src/lib.mo
index 6563950..0a568a8 100644
--- a/src/lib.mo
+++ b/src/lib.mo
@@ -83,6 +83,13 @@ module {
public var authorized = cacheAuthorized;
+ // Store CORS configuration if enabled
+ var corsConfig : ?{
+ origin : Text;
+ methods : Text;
+ headers : Text;
+ } = null;
+
let missingResponse : Response = {
status_code = 404;
headers = [];
@@ -133,6 +140,9 @@ module {
var deleteRequests = HashMap.StableHashMap(0, Text.equal, Text.hash);
+ // Add a map for OPTIONS requests
+ var optionsRequests = HashMap.StableHashMap(0, Text.equal, Text.hash);
+
/**
* iterates through the request handlers and finds the first one that matches the path
* @param path The path to match
@@ -155,7 +165,7 @@ module {
};
};
case (#err _) {
- return null;
+ // This is not a match, continue to the next pattern
};
};
};
@@ -198,6 +208,14 @@ module {
case null {};
};
+ // Check for a wildcard handler, useful for CORS
+ switch (map.get("*")) {
+ case (?f) {
+ return await f(req);
+ };
+ case null {};
+ };
+
// Check for a fallback
switch (fallback) {
case (?f) {
@@ -224,6 +242,9 @@ module {
case "DELETE" {
await handleFunction(deleteRequests, req, null);
};
+ case "OPTIONS" {
+ await handleFunction(optionsRequests, req, null);
+ };
case _ {
missingResponse;
};
@@ -302,6 +323,9 @@ module {
case "DELETE" {
deleteRequests.put(lowercaseUrl, function);
};
+ case "OPTIONS" {
+ optionsRequests.put(lowercaseUrl, function);
+ };
case _ {};
};
};
@@ -372,6 +396,50 @@ module {
registerRequestWithHandler("DELETE", path, handler);
};
+ /**
+ * Register an OPTIONS request handler
+ * @param path The path to handle
+ * @param handler The function to handle the request
+ */
+ public func options(path : Text, handler : (request : Request, response : ResponseClass) -> async Response) {
+ registerRequestWithHandler("OPTIONS", path, handler);
+ };
+
+ /**
+ * Enables CORS for all routes by setting up a default OPTIONS handler
+ * and adding the Access-Control-Allow-Origin header to all responses.
+ * @param origin The allowed origin (e.g., "*", "https://my-frontend.com")
+ * @param methods The allowed HTTP methods (e.g., "GET, POST, OPTIONS")
+ * @param allowedHeaders The allowed request headers (e.g., "Content-Type, Authorization")
+ */
+ public func enableCors(origin : Text, methods : Text, allowedHeaders : Text) {
+ // 1. Store the config to add headers to all responses
+ corsConfig := ?{
+ origin = origin;
+ methods = methods;
+ headers = allowedHeaders;
+ };
+
+ // 2. Register a global OPTIONS handler for preflight requests
+ options(
+ "*",
+ func(request : Request, response : ResponseClass) : async Response {
+ return response.send({
+ status_code = 204; // No Content
+ headers = [
+ ("Access-Control-Allow-Origin", origin),
+ ("Access-Control-Allow-Methods", methods),
+ ("Access-Control-Allow-Headers", allowedHeaders),
+ ("Access-Control-Max-Age", "86400") // Cache preflight for 1 day
+ ];
+ body = Blob.fromArray([]);
+ streaming_strategy = null;
+ cache_strategy = #noCache;
+ });
+ },
+ );
+ };
+
public func entries() : SerializedEntries {
let serializedAssets = assets.entries();
let (stableAssets, _) = serializedAssets;
@@ -465,9 +533,22 @@ module {
let response = await process_request(parsedrequest);
+ // Add CORS headers to the actual response if configured
+ var finalHeaders = response.headers;
+ switch (corsConfig) {
+ case (?config) {
+ // CRITICAL FIX: Only add the origin header to non-preflight requests.
+ // The OPTIONS handler already adds this header for its own responses.
+ if (parsedrequest.method != "OPTIONS") {
+ finalHeaders := joinArrays(finalHeaders, [("Access-Control-Allow-Origin", config.origin)]);
+ };
+ };
+ case null {};
+ };
+
let formattedResponse = {
status_code = response.status_code;
- headers = response.headers;
+ headers = finalHeaders; // Use potentially modified headers
body = response.body;
streaming_strategy = response.streaming_strategy;
upgrade = null;
diff --git a/test/server.test.js b/test/server.test.js
index b0d252f..8ee1766 100644
--- a/test/server.test.js
+++ b/test/server.test.js
@@ -180,6 +180,125 @@ describe(`compare with express`, () => {
});
});
+describe(`CORS`, () => {
+ test(`should handle a preflight OPTIONS request correctly`, async () => {
+ const url = createUrl(`/json`); // The path doesn't matter much for our wildcard handler
+
+ // Simulate a browser's preflight request for a POST with a custom header
+ const response = await fetch(url, {
+ method: "OPTIONS",
+ headers: {
+ Origin: "http://localhost:5173", // A different origin
+ "Access-Control-Request-Method": "POST",
+ "Access-Control-Request-Headers": "Content-Type, Authorization",
+ },
+ });
+
+ // Preflight should return 204 No Content
+ expect(response.status).toBe(204);
+
+ // It should not have a body
+ const text = await response.text();
+ expect(text).toBe(``);
+
+ // Check for the essential CORS headers
+ const headers = response.headers;
+ expect(headers.get("Access-Control-Allow-Origin")).toBe("*");
+ expect(headers.get("Access-Control-Allow-Methods")).toContain("POST");
+ expect(headers.get("Access-Control-Allow-Headers")).toContain(
+ "Authorization"
+ );
+ });
+
+ test(`should include 'Access-Control-Allow-Origin' header on actual GET request`, async () => {
+ const url = createUrl(`/json`);
+
+ // Simulate the actual GET request following a successful preflight
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ // The browser will include the Origin header on the actual request too
+ Origin: "http://localhost:5173",
+ },
+ });
+
+ // The request should succeed
+ expect(response.status).toBe(200);
+
+ // The response to the actual request MUST also include the ACAO header
+ expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
+
+ // Verify the body is correct, just to be sure
+ const json = await response.json();
+ expect(json.hello).toBe("world");
+ });
+
+ test(`should not send duplicate CORS headers (fixes the previous bug)`, async () => {
+ const url = createUrl(`/json`);
+
+ // Make a preflight request
+ const optionsResponse = await fetch(url, {
+ method: "OPTIONS",
+ headers: {
+ Origin: "http://localhost:5173",
+ "Access-Control-Request-Method": "GET",
+ },
+ });
+
+ // The 'get' method on the Headers object returns a single value.
+ // If the header was sent as '*, *', this test would fail.
+ expect(optionsResponse.headers.get("Access-Control-Allow-Origin")).toBe(
+ "*"
+ );
+
+ // Make the actual request
+ const getResponse = await fetch(url, {
+ headers: {
+ Origin: "http://localhost:5173",
+ },
+ });
+
+ expect(getResponse.headers.get("Access-Control-Allow-Origin")).toBe("*");
+ });
+});
+
+describe(`Caching Behavior`, () => {
+ test(`should follow the specified caching flow`, async () => {
+ // Using two simple but distinct endpoints for the test
+ const page1Url = createUrl(`/hi`);
+ const page2Url = createUrl(`/json`);
+
+ // 1. User lands on a page. The first request should be a cache MISS.
+ console.log(`Step 1: First visit to page 1 (/hi)`);
+ const response1 = await fetch(page1Url);
+ // console.log(`Response headers:`, response1.headers);
+ // expect(response1.headers.get(`x-ic-cache-status`)).toBe(`MISS`);
+ expect(await response1.text()).toBe(`hi`);
+
+ // 2. User refreshes the page. This second request should be a cache HIT.
+ console.log(`Step 2: Refreshing page 1 (/hi)`);
+ const response2 = await fetch(page1Url);
+ console.log(`Response headers:`, response2.headers);
+ // expect(response2.headers.get(`x-ic-cache-status`)).toBe(`HIT`);
+ expect(await response2.text()).toBe(`hi`);
+
+ // 3. User goes to a new page. This should be a cache MISS.
+ console.log(`Step 3: Visiting a new page (/json)`);
+ const response3 = await fetch(page2Url);
+ expect(response3.headers.get(`x-ic-cache-status`)).toBe(`MISS`);
+ const json1 = await response3.json();
+ expect(json1.hello).toBe(`world`);
+
+ // 4. User refreshes the new page. This should now be a cache HIT.
+ console.log(`Step 4: Refreshing page 2 (/json)`);
+ const response4 = await fetch(page2Url);
+ expect(response4.headers.get(`x-ic-cache-status`)).toBe(`HIT`);
+ const json2 = await response4.json();
+ expect(json2.hello).toBe(`world`);
+ }, 10_000); // Adding a longer timeout just in case the network is slow
+});
+
+
afterAll(() => {
server.close();
});