diff --git a/sprout.hml b/sprout.hml index 5a2316f..525b336 100644 --- a/sprout.hml +++ b/sprout.hml @@ -121,6 +121,30 @@ fn rune_to_string(r) { return s; } +fn parse_int(s) { + let result = 0; + let negative = false; + let start = 0; + if (s.length > 0 && s.char_at(0) == '-') { + negative = true; + start = 1; + } + for (let i = start; i < s.length; i = i + 1) { + let ch = s.char_at(i); + let ch_code: i32 = ch; + let zero_code: i32 = '0'; + if (ch >= '0' && ch <= '9') { + result = result * 10 + (ch_code - zero_code); + } else { + break; + } + } + if (negative) { + return -result; + } + return result; +} + fn buffer_to_string(buf) { if (buf == null || buf.length == 0) { return ""; @@ -507,11 +531,45 @@ export fn create_request(method, url, headers, body, app_options) { protocol = to_lower(headers["x-forwarded-proto"]); } + // Get hostname from Host header + let hostname = ""; + let subdomains = []; + let host_header = headers["host"]; + if (app_options.trust_proxy && headers["x-forwarded-host"] != null) { + host_header = headers["x-forwarded-host"]; + } + if (host_header != null) { + // Remove port if present + let colon_idx = host_header.find(":"); + if (colon_idx != -1) { + hostname = host_header.substr(0, colon_idx); + } else { + hostname = host_header; + } + // Parse subdomains (all parts except last 2) + let parts = hostname.split("."); + if (parts.length > 2) { + for (let i = 0; i < parts.length - 2; i = i + 1) { + subdomains.push(parts[i]); + } + } + } + + // Check if XHR request + let xhr = false; + let x_requested = headers["x-requested-with"]; + if (x_requested != null && to_lower(x_requested) == "xmlhttprequest") { + xhr = true; + } + return { method: method, path: path, url: url, protocol: protocol, + hostname: hostname, + subdomains: subdomains, + xhr: xhr, headers: headers, query: query, cookies: cookies, @@ -567,6 +625,52 @@ export fn create_request(method, url, headers, body, app_options) { return accept.contains("text/plain") || accept.contains("text/*") || accept.contains("*/*"); } return accept.contains(type_name); + }, + + // Check if request is fresh (client cache is valid) + // Pass the ETag and/or last_modified that will be sent in response + fresh: fn(etag, last_modified) { + // Only GET and HEAD requests can be fresh + if (self.method != "GET" && self.method != "HEAD") { + return false; + } + + // Check If-None-Match header against ETag + let if_none_match = self.headers["if-none-match"]; + if (if_none_match != null && etag != null) { + // Handle weak ETags and wildcards + if (if_none_match == "*") { + return true; + } + // Remove W/ prefix for comparison + let client_etag = if_none_match; + if (client_etag.starts_with("W/")) { + client_etag = client_etag.substr(2, client_etag.length - 2); + } + let server_etag = etag; + if (server_etag.starts_with("W/")) { + server_etag = server_etag.substr(2, server_etag.length - 2); + } + if (client_etag == server_etag) { + return true; + } + } + + // Check If-Modified-Since header + let if_modified_since = self.headers["if-modified-since"]; + if (if_modified_since != null && last_modified != null) { + // Simple string comparison (works for RFC 2822 dates) + if (if_modified_since == last_modified) { + return true; + } + } + + return false; + }, + + // Check if request is stale (opposite of fresh) + stale: fn(etag, last_modified) { + return !self.fresh(etag, last_modified); } }; } @@ -586,6 +690,7 @@ export fn create_response(stream) { _sent: false, _cookies: [], _chunks: [], + locals: {}, // Set status code (chainable) status: fn(code) { @@ -593,6 +698,14 @@ export fn create_response(stream) { return self; }, + // Send status code with status text as body + sendStatus: fn(code) { + self._status = code; + let text = get_status_text(code); + self._headers["content-type"] = "text/plain; charset=utf-8"; + self._send_response(text); + }, + // Set header (chainable) set: fn(name, value) { if (typeof(name) == "object") { @@ -618,6 +731,24 @@ export fn create_response(stream) { return self.set(name, value); }, + // Append value to header (creates or appends) + append: fn(name, value) { + let lower_name = to_lower(name); + let existing = self._headers[lower_name]; + if (existing == null || existing == "") { + self._headers[lower_name] = value; + } else { + self._headers[lower_name] = existing + ", " + value; + } + return self; + }, + + // Set Location header without redirect + location: fn(url) { + self._headers["location"] = url; + return self; + }, + // Set content type type: fn(content_type) { if (!content_type.contains("/")) { @@ -628,6 +759,18 @@ export fn create_response(stream) { return self; }, + // Set ETag header + etag: fn(value) { + self._headers["etag"] = value; + return self; + }, + + // Set Last-Modified header + lastModified: fn(value) { + self._headers["last-modified"] = value; + return self; + }, + // Set cookie cookie: fn(name, value, options) { let cookie_str = name + "=" + value; @@ -671,6 +814,29 @@ export fn create_response(stream) { return self; }, + // Set Link header for pagination/relations + // Usage: res.links({ next: "/page/2", prev: "/page/1" }) + links: fn(links_obj) { + let parts = []; + // Handle common link relations + if (links_obj["next"] != null) { + parts.push("<" + links_obj["next"] + ">; rel=\"next\""); + } + if (links_obj["prev"] != null) { + parts.push("<" + links_obj["prev"] + ">; rel=\"prev\""); + } + if (links_obj["first"] != null) { + parts.push("<" + links_obj["first"] + ">; rel=\"first\""); + } + if (links_obj["last"] != null) { + parts.push("<" + links_obj["last"] + ">; rel=\"last\""); + } + if (parts.length > 0) { + self._headers["link"] = parts.join(", "); + } + return self; + }, + // Send response send: fn(body) { if (self._sent) { @@ -843,6 +1009,12 @@ export fn create_response(stream) { if (self._headers["cache-control"] != null) { response = response + "Cache-Control: " + self._headers["cache-control"] + "\r\n"; } + if (self._headers["etag"] != null) { + response = response + "ETag: " + self._headers["etag"] + "\r\n"; + } + if (self._headers["last-modified"] != null) { + response = response + "Last-Modified: " + self._headers["last-modified"] + "\r\n"; + } if (self._headers["vary"] != null) { response = response + "Vary: " + self._headers["vary"] + "\r\n"; } @@ -870,6 +1042,9 @@ export fn create_response(stream) { if (self._headers["x-custom"] != null) { response = response + "X-Custom: " + self._headers["x-custom"] + "\r\n"; } + if (self._headers["link"] != null) { + response = response + "Link: " + self._headers["link"] + "\r\n"; + } // Add cookies for (let cookie in self._cookies) { @@ -1018,7 +1193,8 @@ export fn App(options) { let app_options = { trust_proxy: false, case_sensitive_routing: false, - strict_routing: false + strict_routing: false, + body_limit: 102400 }; if (options["trust_proxy"] == true) { @@ -1030,6 +1206,9 @@ export fn App(options) { if (options["strict_routing"] == true) { app_options.strict_routing = true; } + if (options["body_limit"] != null) { + app_options.body_limit = options["body_limit"]; + } let app = { _options: app_options, @@ -1038,7 +1217,10 @@ export fn App(options) { _error_handlers: [], _mounted_routers: [], _static_routes: [], + _param_handlers: {}, + _not_found_handler: null, _listener: null, + locals: {}, // Add route for specific method _add_route: fn(method, path, handlers) { @@ -1180,6 +1362,19 @@ export fn App(options) { return self; }, + // Set custom 404 not found handler + notFound: fn(handler) { + self._not_found_handler = handler; + return self; + }, + + // Register parameter preprocessing handler + // Usage: app.param("id", fn(req, res, next, value, name) { ... }) + param: fn(name, handler) { + self._param_handlers[name] = handler; + return self; + }, + // Get underlying server server: fn() { return self._listener; @@ -1227,8 +1422,9 @@ export fn App(options) { // Handle a single connection synchronously _handle_connection_sync: fn(stream) { try { - // Read request data (returns buffer) - let raw_data = stream.read(65536); + // Read request data (returns buffer) - use body_limit + header overhead + let read_limit = self._options.body_limit + 8192; + let raw_data = stream.read(read_limit); if (raw_data == null || raw_data.length == 0) { stream.close(); return; @@ -1247,6 +1443,28 @@ export fn App(options) { return; } + // Check content-length against body_limit + let content_length = req_data.headers["content-length"]; + if (content_length != null) { + let cl_num = 0; + try { + cl_num = parse_int(content_length); + } catch (e) { + cl_num = 0; + } + if (cl_num > self._options.body_limit) { + // Payload too large + let error_response = "HTTP/1.1 413 Payload Too Large\r\n"; + error_response = error_response + "Content-Type: text/plain\r\n"; + error_response = error_response + "Content-Length: 16\r\n"; + error_response = error_response + "Connection: close\r\n\r\n"; + error_response = error_response + "Payload Too Large"; + stream.write(error_response); + stream.close(); + return; + } + } + // Create request and response objects let req = create_request( req_data.method, @@ -1485,8 +1703,16 @@ export fn App(options) { // Look for error handler self._handle_error(error, req, res); } else if (!res._sent) { - // No handler found - 404 - res.status(404).send("Not Found"); + // No handler found - use custom 404 handler or default + if (self._not_found_handler != null) { + try { + self._not_found_handler(req, res); + } catch (e) { + res.status(404).send("Not Found"); + } + } else { + res.status(404).send("Not Found"); + } } return; } @@ -1497,6 +1723,31 @@ export fn App(options) { // Update request params req.params = current.params; + // Run param handlers for any matched params + let param_names = ["id", "userId", "postId", "slug", "name", "uuid", "key", "token"]; + for (let pname in param_names) { + if (current.params[pname] != null && self._param_handlers[pname] != null) { + try { + let param_handler = self._param_handlers[pname]; + let pvalue = current.params[pname]; + let continue_chain = true; + let param_next = fn(err) { + if (err != null) { + error = err; + continue_chain = false; + } + }; + param_handler(req, res, param_next, pvalue, pname); + if (!continue_chain || res._sent) { + return; + } + } catch (e) { + next(e); + return; + } + } + } + // Execute handler try { if (error != null) { diff --git a/test_sprout.hml b/test_sprout.hml index f3cf3f5..377fe32 100644 --- a/test_sprout.hml +++ b/test_sprout.hml @@ -982,6 +982,324 @@ describe("error_handler", fn() { }); }); +// ============================================================================ +// New Feature Tests +// ============================================================================ + +describe("res.sendStatus", fn() { + let mock_stream = null; + let res = null; + + before_each(fn() { + mock_stream = { + write: fn(data) { self._data = data; }, + _data: "" + }; + res = create_response(mock_stream); + }); + + test("sends status code with status text as body", fn() { + res.sendStatus(404); + expect(res._sent).to_be_true(); + expect(res._status).to_equal(404); + expect(mock_stream._data.contains("Not Found")).to_be_true(); + }); + + test("sends 200 OK", fn() { + res.sendStatus(200); + expect(res._status).to_equal(200); + expect(mock_stream._data.contains("OK")).to_be_true(); + }); + + test("sends 500 Internal Server Error", fn() { + res.sendStatus(500); + expect(res._status).to_equal(500); + expect(mock_stream._data.contains("Internal Server Error")).to_be_true(); + }); +}); + +describe("res.locals", fn() { + test("response has locals object", fn() { + let mock_stream = { write: fn(data) {} }; + let res = create_response(mock_stream); + expect(res.locals).to_not_be_null(); + }); + + test("can set and get locals values", fn() { + let mock_stream = { write: fn(data) {} }; + let res = create_response(mock_stream); + res.locals["user"] = "test_user"; + expect(res.locals["user"]).to_equal("test_user"); + }); +}); + +describe("app.locals", fn() { + test("app has locals object", fn() { + let app = App(null); + expect(app.locals).to_not_be_null(); + }); + + test("can set and get app locals values", fn() { + let app = App(null); + app.locals["appName"] = "TestApp"; + expect(app.locals["appName"]).to_equal("TestApp"); + }); +}); + +describe("res.append", fn() { + let mock_stream = null; + let res = null; + + before_each(fn() { + mock_stream = { write: fn(data) {} }; + res = create_response(mock_stream); + }); + + test("sets header if not exists", fn() { + res.append("X-Custom", "value1"); + expect(res._headers["x-custom"]).to_equal("value1"); + }); + + test("appends to existing header", fn() { + res.append("X-Custom", "value1"); + res.append("X-Custom", "value2"); + expect(res._headers["x-custom"]).to_equal("value1, value2"); + }); + + test("returns self for chaining", fn() { + let result = res.append("X-Test", "test"); + expect(result).to_equal(res); + }); +}); + +describe("res.location", fn() { + let mock_stream = null; + let res = null; + + before_each(fn() { + mock_stream = { write: fn(data) {} }; + res = create_response(mock_stream); + }); + + test("sets location header", fn() { + res.location("/new-path"); + expect(res._headers["location"]).to_equal("/new-path"); + }); + + test("returns self for chaining", fn() { + let result = res.location("/test"); + expect(result).to_equal(res); + }); + + test("does not send response", fn() { + res.location("/path"); + expect(res._sent).to_be_false(); + }); +}); + +describe("res.links", fn() { + let mock_stream = null; + let res = null; + + before_each(fn() { + mock_stream = { write: fn(data) {} }; + res = create_response(mock_stream); + }); + + test("sets link header with next", fn() { + res.links({ next: "/page/2" }); + expect(res._headers["link"]).to_equal("; rel=\"next\""); + }); + + test("sets link header with prev", fn() { + res.links({ prev: "/page/1" }); + expect(res._headers["link"]).to_equal("; rel=\"prev\""); + }); + + test("sets multiple link relations", fn() { + res.links({ next: "/page/2", prev: "/page/1" }); + let link = res._headers["link"]; + expect(link.contains("; rel=\"next\"")).to_be_true(); + expect(link.contains("; rel=\"prev\"")).to_be_true(); + }); + + test("returns self for chaining", fn() { + let result = res.links({ next: "/test" }); + expect(result).to_equal(res); + }); +}); + +describe("req.hostname", fn() { + test("extracts hostname from host header", fn() { + let headers = make_headers("host", "example.com:3000", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.hostname).to_equal("example.com"); + }); + + test("handles host without port", fn() { + let headers = make_headers("host", "example.com", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.hostname).to_equal("example.com"); + }); + + test("uses x-forwarded-host when trust_proxy enabled", fn() { + let headers = make_headers("host", "localhost", "x-forwarded-host", "real.example.com", null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: true }); + expect(req.hostname).to_equal("real.example.com"); + }); +}); + +describe("req.subdomains", fn() { + test("extracts subdomains from hostname", fn() { + let headers = make_headers("host", "api.v2.example.com", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.subdomains.length).to_equal(2); + expect(req.subdomains[0]).to_equal("api"); + expect(req.subdomains[1]).to_equal("v2"); + }); + + test("returns empty array for two-part hostname", fn() { + let headers = make_headers("host", "example.com", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.subdomains.length).to_equal(0); + }); +}); + +describe("req.xhr", fn() { + test("returns true for XMLHttpRequest", fn() { + let headers = make_headers("x-requested-with", "XMLHttpRequest", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.xhr).to_be_true(); + }); + + test("returns false without x-requested-with header", fn() { + let headers = make_headers(null, null, null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.xhr).to_be_false(); + }); + + test("is case insensitive", fn() { + let headers = make_headers("x-requested-with", "xmlhttprequest", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.xhr).to_be_true(); + }); +}); + +describe("app.notFound", fn() { + test("sets not found handler", fn() { + let app = App(null); + let handler = fn(req, res) {}; + app.notFound(handler); + expect(app._not_found_handler).to_equal(handler); + }); + + test("returns self for chaining", fn() { + let app = App(null); + let result = app.notFound(fn(req, res) {}); + expect(result).to_equal(app); + }); +}); + +describe("app.param", fn() { + test("registers parameter handler", fn() { + let app = App(null); + let handler = fn(req, res, next, val, name) {}; + app.param("id", handler); + expect(app._param_handlers["id"]).to_equal(handler); + }); + + test("returns self for chaining", fn() { + let app = App(null); + let result = app.param("id", fn(req, res, next, val, name) {}); + expect(result).to_equal(app); + }); +}); + +describe("app body_limit option", fn() { + test("uses default body limit", fn() { + let app = App(null); + expect(app._options.body_limit).to_equal(102400); + }); + + test("accepts custom body limit", fn() { + let app = App({ body_limit: 50000 }); + expect(app._options.body_limit).to_equal(50000); + }); +}); + +describe("req.fresh and req.stale", fn() { + test("fresh returns false for non-GET/HEAD requests", fn() { + let headers = make_headers(null, null, null, null, null, null); + let req = create_request("POST", "/", headers, "", { trust_proxy: false }); + expect(req.fresh("\"abc123\"", null)).to_be_false(); + }); + + test("fresh returns true when etag matches", fn() { + let headers = make_headers("if-none-match", "\"abc123\"", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.fresh("\"abc123\"", null)).to_be_true(); + }); + + test("fresh handles weak etag comparison", fn() { + let headers = make_headers("if-none-match", "W/\"abc123\"", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.fresh("\"abc123\"", null)).to_be_true(); + }); + + test("fresh returns true for wildcard etag", fn() { + let headers = make_headers("if-none-match", "*", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.fresh("\"abc123\"", null)).to_be_true(); + }); + + test("stale returns opposite of fresh", fn() { + let headers = make_headers("if-none-match", "\"abc123\"", null, null, null, null); + let req = create_request("GET", "/", headers, "", { trust_proxy: false }); + expect(req.stale("\"abc123\"", null)).to_be_false(); + expect(req.stale("\"different\"", null)).to_be_true(); + }); +}); + +describe("res.etag", fn() { + let mock_stream = null; + let res = null; + + before_each(fn() { + mock_stream = { write: fn(data) {} }; + res = create_response(mock_stream); + }); + + test("sets etag header", fn() { + res.etag("\"abc123\""); + expect(res._headers["etag"]).to_equal("\"abc123\""); + }); + + test("returns self for chaining", fn() { + let result = res.etag("\"test\""); + expect(result).to_equal(res); + }); +}); + +describe("res.lastModified", fn() { + let mock_stream = null; + let res = null; + + before_each(fn() { + mock_stream = { write: fn(data) {} }; + res = create_response(mock_stream); + }); + + test("sets last-modified header", fn() { + res.lastModified("Wed, 21 Oct 2015 07:28:00 GMT"); + expect(res._headers["last-modified"]).to_equal("Wed, 21 Oct 2015 07:28:00 GMT"); + }); + + test("returns self for chaining", fn() { + let result = res.lastModified("Wed, 21 Oct 2015 07:28:00 GMT"); + expect(result).to_equal(res); + }); +}); + // ============================================================================ // Run Tests // ============================================================================