Skip to content

Commit 6e96738

Browse files
committed
perf: reduce allocations across hot paths
1 parent 7e9803f commit 6e96738

4 files changed

Lines changed: 81 additions & 57 deletions

File tree

src/client.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,44 @@ pub fn build_client() -> WreqClient {
6666
.expect("Should always be able to build a client")
6767
}
6868

69+
/// Convert a wreq Response into a hyper Response<Body>.
70+
/// This bridge lets the rest of the codebase stay unchanged.
71+
trait IntoHyperResponse {
72+
async fn into_hyper(self) -> Result<Response<Body>, String>;
73+
}
74+
75+
impl IntoHyperResponse for wreq::Response {
76+
async fn into_hyper(self) -> Result<Response<Body>, String> {
77+
// wreq uses http v1.x; hyper uses http v0.2.x. Convert via primitives.
78+
let status_u16 = self.status().as_u16();
79+
let status = hyper::StatusCode::from_u16(status_u16).map_err(|e| e.to_string())?;
80+
81+
// Snapshot headers before consuming self (bytes() moves self).
82+
// Use SmallVec-style stack storage: most responses have < 32 headers.
83+
let mut builder = hyper::Response::builder().status(status);
84+
for (k, v) in self.headers() {
85+
// Skip headers that hyper rejects (e.g. non-ASCII bytes in CDN headers)
86+
// rather than aborting the entire response.
87+
if let (Ok(key), Ok(val)) = (
88+
hyper::header::HeaderName::from_bytes(k.as_str().as_bytes()),
89+
hyper::header::HeaderValue::from_bytes(v.as_bytes()),
90+
) {
91+
builder = builder.header(key, val);
92+
}
93+
}
94+
95+
let bytes = self.bytes().await.map_err(|e| e.to_string())?;
96+
// wreq already decompresses; remove the content-encoding header so
97+
// downstream code doesn't try to decompress again.
98+
if let Some(h) = builder.headers_mut() {
99+
h.remove(header::CONTENT_ENCODING);
100+
h.insert(header::CONTENT_LENGTH, bytes.len().into());
101+
}
102+
103+
builder.body(Body::from(bytes)).map_err(|e| e.to_string())
104+
}
105+
}
106+
69107
/// Gets the canonical path for a resource on Reddit. This is accomplished by
70108
/// making a `HEAD` request to Reddit at the path given in `path`.
71109
///

src/post.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ use crate::config::get_setting;
44
use crate::server::RequestExt;
55
use crate::subreddit::{can_access_quarantine, quarantine};
66
use crate::utils::{
7-
error, format_num, get_filters, nsfw_landing, param, parse_post, rewrite_emotes, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
7+
error, format_num, nsfw_landing, param, parse_post, rewrite_emotes, setting, template, time, val, Author, Awards, Comment, Flair, FlairPart, Post, Preferences,
88
};
99
use askama::Template;
1010
use hyper::{Body, Request, Response};
1111
use regex::Regex;
12-
use std::collections::{HashMap, HashSet};
12+
use std::collections::HashSet;
1313
use std::sync::LazyLock;
1414

1515
// STRUCTS
@@ -76,13 +76,13 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
7676
None => String::new(),
7777
};
7878

79-
let query_string = format!("q={query_body}&type=comment");
80-
let form = url::form_urlencoded::parse(query_string.as_bytes()).collect::<HashMap<_, _>>();
81-
let query = form.get("q").unwrap().clone().to_string();
79+
let query = query_body;
8280

81+
let prefs = Preferences::new(&req);
82+
let filters: HashSet<String> = prefs.filters.iter().cloned().collect();
8383
let comments = match query.as_str() {
84-
"" => parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &req),
85-
_ => query_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req), &query, &req),
84+
"" => parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &filters, &req),
85+
_ => query_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &filters, &query, &req),
8686
};
8787

8888
// Use the Post and Comment structs to generate a website to show users
@@ -91,7 +91,7 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
9191
post,
9292
url_without_query: url.clone().trim_end_matches(&format!("?q={query}&type=comment")).to_string(),
9393
sort,
94-
prefs: Preferences::new(&req),
94+
prefs,
9595
single_thread,
9696
url: req_url,
9797
comment_query: query,
@@ -170,8 +170,10 @@ fn build_comment(
170170
req: &Request<Body>,
171171
) -> Comment {
172172
let id = val(comment, "id");
173+
let comment_author = val(comment, "author");
174+
let comment_body = val(comment, "body");
173175

174-
let body = if (val(comment, "author") == "[deleted]" && val(comment, "body") == "[removed]") || val(comment, "body") == "[ Removed by Reddit ]" {
176+
let body = if (comment_author == "[deleted]" && comment_body == "[removed]") || comment_body == "[ Removed by Reddit ]" {
175177
format!(
176178
"<div class=\"md\"><p>[removed] — <a href=\"https://{}{post_link}{id}\">view removed comment</a></p></div>",
177179
get_setting("REDLIB_PUSHSHIFT_FRONTEND").unwrap_or_else(|| String::from(crate::config::DEFAULT_PUSHSHIFT_FRONTEND)),
@@ -203,7 +205,7 @@ fn build_comment(
203205
let highlighted = id == highlighted_comment;
204206

205207
let author = Author {
206-
name: val(comment, "author"),
208+
name: comment_author,
207209
flair: Flair {
208210
flair_parts: FlairPart::parse(
209211
data["author_flair_type"].as_str().unwrap_or_default(),

src/user.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#![allow(clippy::cmp_owned)]
22
use crate::client::json;
33
use crate::server::RequestExt;
4-
use crate::utils::{error, filter_posts, format_url, get_filters, nsfw_landing, param, setting, template, Post, Preferences, User};
4+
use crate::utils::{error, filter_posts, format_url, nsfw_landing, param, setting, template, Post, Preferences, User};
55
use crate::{config, utils};
66
use askama::Template;
77
use chrono::DateTime;
@@ -60,15 +60,16 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
6060
return Ok(nsfw_landing(req, req_url).await.unwrap_or_default());
6161
}
6262

63-
let filters = get_filters(&req);
63+
let prefs = Preferences::new(&req);
64+
let filters: std::collections::HashSet<String> = prefs.filters.iter().cloned().collect();
6465
if filters.contains(&["u_", &username].concat()) {
6566
Ok(template(&UserTemplate {
6667
user,
6768
posts: Vec::new(),
6869
sort: (sort, param(&path, "t").unwrap_or_default()),
6970
ends: (param(&path, "after").unwrap_or_default(), String::new()),
7071
listing,
71-
prefs: Preferences::new(&req),
72+
prefs,
7273
url,
7374
redirect_url,
7475
is_filtered: true,
@@ -89,7 +90,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
8990
sort: (sort, param(&path, "t").unwrap_or_default()),
9091
ends: (param(&path, "after").unwrap_or_default(), after),
9192
listing,
92-
prefs: Preferences::new(&req),
93+
prefs,
9394
url,
9495
redirect_url,
9596
is_filtered: false,

src/utils.rs

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,9 @@ use rust_embed::RustEmbed;
1414
use serde::{Deserialize, Deserializer, Serialize, Serializer};
1515
use serde_json::Value;
1616
use serde_json_path::{JsonPath, JsonPathExt};
17-
use std::collections::{HashMap, HashSet};
17+
use std::collections::HashSet;
1818
use std::env;
1919
use std::io::{Read, Write};
20-
use std::str::FromStr;
2120
use std::string::ToString;
2221
use std::sync::LazyLock;
2322
use time::{macros::format_description, Duration, OffsetDateTime};
@@ -522,22 +521,16 @@ impl std::fmt::Display for Awards {
522521
impl Awards {
523522
/// Convert Reddit awards JSON to Awards struct
524523
pub fn parse(items: &Value) -> Self {
525-
let parsed = items.as_array().unwrap_or(&Vec::new()).iter().fold(Vec::new(), |mut awards, item| {
526-
let name = item["name"].as_str().unwrap_or_default().to_string();
527-
let icon_url = format_url(item["resized_icons"][0]["url"].as_str().unwrap_or_default());
528-
let description = item["description"].as_str().unwrap_or_default().to_string();
529-
let count: i64 = i64::from_str(&item["count"].to_string()).unwrap_or(1);
530-
531-
awards.push(Award {
532-
name,
533-
icon_url,
534-
description,
535-
count,
524+
let arr = items.as_array().map(|v| v.as_slice()).unwrap_or_default();
525+
let mut parsed = Vec::with_capacity(arr.len());
526+
for item in arr {
527+
parsed.push(Award {
528+
name: item["name"].as_str().unwrap_or_default().to_string(),
529+
icon_url: format_url(item["resized_icons"][0]["url"].as_str().unwrap_or_default()),
530+
description: item["description"].as_str().unwrap_or_default().to_string(),
531+
count: item["count"].as_i64().unwrap_or(1),
536532
});
537-
538-
awards
539-
});
540-
533+
}
541534
Self(parsed)
542535
}
543536
}
@@ -896,15 +889,10 @@ pub async fn parse_post(post: &Value) -> Post {
896889

897890
/// Grab a query parameter from a url
898891
pub fn param(path: &str, value: &str) -> Option<String> {
899-
Some(
900-
Url::parse(format!("https://libredd.it/{path}").as_str())
901-
.ok()?
902-
.query_pairs()
903-
.into_owned()
904-
.collect::<HashMap<_, _>>()
905-
.get(value)?
906-
.clone(),
907-
)
892+
let query = path.splitn(2, '?').nth(1).unwrap_or_default();
893+
url::form_urlencoded::parse(query.as_bytes())
894+
.find(|(k, _)| k == value)
895+
.map(|(_, v)| v.into_owned())
908896
}
909897

910898
/// Retrieve the value of a setting by name
@@ -1092,25 +1080,18 @@ pub fn rewrite_urls(input_text: &str) -> String {
10921080
// Rewrite Reddit links to Redlib
10931081
REDDIT_REGEX.replace_all(input_text, r#"href="/"#).to_string();
10941082

1095-
loop {
1096-
if REDDIT_EMOJI_REGEX.find(&text1).is_none() {
1097-
break;
1098-
} else {
1099-
text1 = REDDIT_EMOJI_REGEX
1100-
.replace_all(&text1, format_url(REDDIT_EMOJI_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default()))
1101-
.to_string()
1102-
}
1103-
}
1083+
text1 = REDDIT_EMOJI_REGEX.replace_all(&text1, |caps: &regex::Captures| format_url(&caps[0])).to_string();
11041084

11051085
// Remove (html-encoded) "\" from URLs.
11061086
text1 = text1.replace("%5C", "").replace("\\_", "_");
11071087

11081088
// Rewrite external media previews to Redlib
11091089
loop {
1110-
if REDDIT_PREVIEW_REGEX.find(&text1).is_none() {
1090+
let Some(caps) = REDDIT_PREVIEW_REGEX.captures(&text1) else {
11111091
return text1;
1112-
} else {
1113-
let formatted_url = format_url(REDDIT_PREVIEW_REGEX.find(&text1).map(|x| x.as_str()).unwrap_or_default());
1092+
};
1093+
{
1094+
let formatted_url = format_url(caps.get(0).map_or("", |m| m.as_str()));
11141095

11151096
let image_url = REDLIB_PREVIEW_LINK_REGEX.find(&formatted_url).map_or("", |m| m.as_str());
11161097
let mut image_caption = REDLIB_PREVIEW_TEXT_REGEX.find(&formatted_url).map_or("", |m| m.as_str());
@@ -1139,7 +1120,7 @@ pub fn rewrite_urls(input_text: &str) -> String {
11391120

11401121
/* In order to know if we're dealing with a normal or external preview we need to take a look at the first capture group of REDDIT_PREVIEW_REGEX
11411122
if it's preview we're dealing with something that needs /preview/pre, external-preview is /preview/external-pre, and i is /img */
1142-
let reddit_preview_regex_capture = REDDIT_PREVIEW_REGEX.captures(&text1).unwrap().get(1).map_or("", |m| m.as_str());
1123+
let reddit_preview_regex_capture = caps.get(1).map_or("", |m| m.as_str());
11431124

11441125
let _preview_type = match reddit_preview_regex_capture {
11451126
"preview" => "/preview/pre",
@@ -1389,12 +1370,14 @@ pub async fn nsfw_landing(req: Request<Body>, req_url: String) -> Result<Respons
13891370

13901371
// Determine from the request URL if the resource is a subreddit, a user
13911372
// page, or a post.
1392-
let resource: String = if !req.param("name").unwrap_or_default().is_empty() {
1373+
let name = req.param("name").unwrap_or_default();
1374+
let id = req.param("id").unwrap_or_default();
1375+
let resource: String = if !name.is_empty() {
13931376
res_type = ResourceType::User;
1394-
req.param("name").unwrap_or_default()
1395-
} else if !req.param("id").unwrap_or_default().is_empty() {
1377+
name
1378+
} else if !id.is_empty() {
13961379
res_type = ResourceType::Post;
1397-
req.param("id").unwrap_or_default()
1380+
id
13981381
} else {
13991382
res_type = ResourceType::Subreddit;
14001383
req.param("sub").unwrap_or_default()

0 commit comments

Comments
 (0)