@assetsart/nylon-mesh 1.0.1
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/.github/workflows/release.yml +98 -0
- package/Cargo.lock +2965 -0
- package/Cargo.toml +33 -0
- package/README.md +104 -0
- package/bin/nylon-mesh.js +213 -0
- package/bun.lock +360 -0
- package/docs/content/docs/caching.mdx +85 -0
- package/docs/content/docs/configuration.mdx +115 -0
- package/docs/content/docs/index.mdx +58 -0
- package/docs/content/docs/load-balancing.mdx +69 -0
- package/docs/content/docs/meta.json +9 -0
- package/docs/next.config.mjs +11 -0
- package/docs/package-lock.json +6099 -0
- package/docs/package.json +32 -0
- package/docs/postcss.config.mjs +7 -0
- package/docs/source.config.ts +23 -0
- package/docs/src/app/(home)/layout.tsx +6 -0
- package/docs/src/app/(home)/page.tsx +125 -0
- package/docs/src/app/api/search/route.ts +9 -0
- package/docs/src/app/docs/[[...slug]]/page.tsx +46 -0
- package/docs/src/app/docs/layout.tsx +11 -0
- package/docs/src/app/global.css +7 -0
- package/docs/src/app/layout.tsx +31 -0
- package/docs/src/app/llms-full.txt/route.ts +10 -0
- package/docs/src/app/llms.txt/route.ts +13 -0
- package/docs/src/app/og/docs/[...slug]/route.tsx +27 -0
- package/docs/src/components/ai/page-actions.tsx +240 -0
- package/docs/src/components/architecture-diagram.tsx +88 -0
- package/docs/src/components/benchmark.tsx +129 -0
- package/docs/src/components/configuration.tsx +107 -0
- package/docs/src/components/copy-button.tsx +29 -0
- package/docs/src/components/footer.tsx +37 -0
- package/docs/src/components/framework-logos.tsx +35 -0
- package/docs/src/lib/cn.ts +1 -0
- package/docs/src/lib/layout.shared.tsx +23 -0
- package/docs/src/lib/source.ts +27 -0
- package/docs/src/mdx-components.tsx +9 -0
- package/docs/tsconfig.json +46 -0
- package/nylon-mesh.yaml +41 -0
- package/package.json +23 -0
- package/scripts/publish.mjs +18 -0
- package/scripts/release.mjs +52 -0
- package/src/config.rs +91 -0
- package/src/main.rs +214 -0
- package/src/proxy/cache.rs +304 -0
- package/src/proxy/handlers.rs +76 -0
- package/src/proxy/load_balancer.rs +23 -0
- package/src/proxy/mod.rs +232 -0
- package/src/tls_accept.rs +119 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
use bytes::Bytes;
|
|
2
|
+
use http::StatusCode;
|
|
3
|
+
use pingora::Result;
|
|
4
|
+
use pingora::http::ResponseHeader;
|
|
5
|
+
use pingora_proxy::Session;
|
|
6
|
+
|
|
7
|
+
use super::MeshProxy;
|
|
8
|
+
|
|
9
|
+
impl MeshProxy {
|
|
10
|
+
pub async fn serve_probe_response(
|
|
11
|
+
session: &mut Session,
|
|
12
|
+
status: StatusCode,
|
|
13
|
+
msg: &'static str,
|
|
14
|
+
) -> Result<()> {
|
|
15
|
+
let mut header = ResponseHeader::build(status, None).unwrap();
|
|
16
|
+
let _ = header.insert_header("Content-Length", msg.len().to_string());
|
|
17
|
+
session
|
|
18
|
+
.write_response_header(Box::new(header), true)
|
|
19
|
+
.await?;
|
|
20
|
+
session
|
|
21
|
+
.write_response_body(Some(Bytes::from(msg)), true)
|
|
22
|
+
.await?;
|
|
23
|
+
Ok(())
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub async fn handle_probes(&self, session: &mut Session, uri: &str) -> Result<Option<bool>> {
|
|
27
|
+
if let Some(liveness_path) = &self.config.liveness_path {
|
|
28
|
+
if uri == liveness_path {
|
|
29
|
+
Self::serve_probe_response(session, StatusCode::OK, "OK").await?;
|
|
30
|
+
return Ok(Some(true));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if let Some(readiness_path) = &self.config.readiness_path {
|
|
35
|
+
if uri == readiness_path {
|
|
36
|
+
if crate::is_shutting_down() {
|
|
37
|
+
Self::serve_probe_response(
|
|
38
|
+
session,
|
|
39
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
40
|
+
"Service is shutting down",
|
|
41
|
+
)
|
|
42
|
+
.await?;
|
|
43
|
+
} else {
|
|
44
|
+
Self::serve_probe_response(session, StatusCode::OK, "OK").await?;
|
|
45
|
+
}
|
|
46
|
+
return Ok(Some(true));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
Ok(None)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pub fn should_bypass_cache(&self, method: &str, uri: &str) -> bool {
|
|
54
|
+
if method != "GET" {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if let Some(bypass) = &self.config.bypass {
|
|
59
|
+
if let Some(paths) = &bypass.paths {
|
|
60
|
+
for p in paths {
|
|
61
|
+
if uri.starts_with(p) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if let Some(exts) = &bypass.extensions {
|
|
67
|
+
for ext in exts {
|
|
68
|
+
if uri.ends_with(ext) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
false
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
use pingora_load_balancing::{
|
|
2
|
+
LoadBalancer,
|
|
3
|
+
selection::{Random, RoundRobin},
|
|
4
|
+
};
|
|
5
|
+
use std::sync::Arc;
|
|
6
|
+
|
|
7
|
+
pub enum MeshLoadBalancer {
|
|
8
|
+
RoundRobin(Arc<LoadBalancer<RoundRobin>>),
|
|
9
|
+
Random(Arc<LoadBalancer<Random>>),
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
impl MeshLoadBalancer {
|
|
13
|
+
pub fn select(
|
|
14
|
+
&self,
|
|
15
|
+
key: &[u8],
|
|
16
|
+
max_iterations: usize,
|
|
17
|
+
) -> Option<pingora_load_balancing::Backend> {
|
|
18
|
+
match self {
|
|
19
|
+
Self::RoundRobin(lb) => lb.select(key, max_iterations),
|
|
20
|
+
Self::Random(lb) => lb.select(key, max_iterations),
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/proxy/mod.rs
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
pub mod cache;
|
|
2
|
+
pub mod handlers;
|
|
3
|
+
pub mod load_balancer;
|
|
4
|
+
|
|
5
|
+
pub use load_balancer::MeshLoadBalancer;
|
|
6
|
+
|
|
7
|
+
use async_trait::async_trait;
|
|
8
|
+
use bytes::Bytes;
|
|
9
|
+
use moka::future::Cache;
|
|
10
|
+
use pingora::Result;
|
|
11
|
+
use pingora::http::ResponseHeader;
|
|
12
|
+
use pingora::upstreams::peer::HttpPeer;
|
|
13
|
+
use pingora_proxy::{ProxyHttp, Session};
|
|
14
|
+
use std::sync::Arc;
|
|
15
|
+
use std::time::Duration;
|
|
16
|
+
|
|
17
|
+
use crate::config::Config;
|
|
18
|
+
|
|
19
|
+
pub struct MeshProxy {
|
|
20
|
+
pub config: Arc<Config>,
|
|
21
|
+
pub load_balancer: Arc<MeshLoadBalancer>,
|
|
22
|
+
pub tier1_cache: Cache<String, (ResponseHeader, Bytes)>,
|
|
23
|
+
pub encoding_hits: Arc<std::collections::HashMap<&'static str, std::sync::atomic::AtomicU64>>,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
pub struct ProxyCtx {
|
|
27
|
+
pub should_cache: bool,
|
|
28
|
+
pub cache_key: String,
|
|
29
|
+
pub host: String,
|
|
30
|
+
pub response_body: Vec<u8>,
|
|
31
|
+
pub response_header: Option<ResponseHeader>,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
#[async_trait]
|
|
35
|
+
impl ProxyHttp for MeshProxy {
|
|
36
|
+
type CTX = ProxyCtx;
|
|
37
|
+
|
|
38
|
+
fn new_ctx(&self) -> Self::CTX {
|
|
39
|
+
ProxyCtx {
|
|
40
|
+
should_cache: false,
|
|
41
|
+
cache_key: String::new(),
|
|
42
|
+
host: String::new(),
|
|
43
|
+
response_body: Vec::new(),
|
|
44
|
+
response_header: None,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async fn upstream_peer(
|
|
49
|
+
&self,
|
|
50
|
+
_session: &mut Session,
|
|
51
|
+
_ctx: &mut Self::CTX,
|
|
52
|
+
) -> Result<Box<HttpPeer>> {
|
|
53
|
+
let upstream = self
|
|
54
|
+
.load_balancer
|
|
55
|
+
.select(b"", 256) // Use empty hash for round robin
|
|
56
|
+
.ok_or_else(|| {
|
|
57
|
+
pingora::Error::explain(
|
|
58
|
+
pingora::ErrorType::HTTPStatus(502),
|
|
59
|
+
"No upstream available",
|
|
60
|
+
)
|
|
61
|
+
})?;
|
|
62
|
+
|
|
63
|
+
let peer = HttpPeer::new(upstream.clone(), false, String::new());
|
|
64
|
+
Ok(Box::new(peer))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async fn request_filter(&self, session: &mut Session, ctx: &mut Self::CTX) -> Result<bool> {
|
|
68
|
+
let uri = session.req_header().uri.path().to_string();
|
|
69
|
+
|
|
70
|
+
if let Some(handled) = self.handle_probes(session, &uri).await? {
|
|
71
|
+
return Ok(handled);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let method = session.req_header().method.as_str().to_string();
|
|
75
|
+
|
|
76
|
+
if self.should_bypass_cache(&method, &uri) {
|
|
77
|
+
return Ok(false);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let host = session
|
|
81
|
+
.req_header()
|
|
82
|
+
.headers
|
|
83
|
+
.get("Host")
|
|
84
|
+
.map(|v| v.to_str().unwrap_or("localhost"))
|
|
85
|
+
.unwrap_or("localhost")
|
|
86
|
+
.to_string();
|
|
87
|
+
|
|
88
|
+
ctx.host = host.clone();
|
|
89
|
+
|
|
90
|
+
let accept_encoding = session
|
|
91
|
+
.req_header()
|
|
92
|
+
.headers
|
|
93
|
+
.get("accept-encoding")
|
|
94
|
+
.map(|hv| hv.to_str().unwrap_or(""))
|
|
95
|
+
.unwrap_or("")
|
|
96
|
+
.to_string();
|
|
97
|
+
|
|
98
|
+
let encodings_to_check = self.determine_encodings_to_check(&accept_encoding);
|
|
99
|
+
let query = session
|
|
100
|
+
.req_header()
|
|
101
|
+
.uri
|
|
102
|
+
.query()
|
|
103
|
+
.map_or(String::new(), |q| format!("?{}", q));
|
|
104
|
+
let now_secs = std::time::SystemTime::now()
|
|
105
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
106
|
+
.unwrap_or(std::time::Duration::from_secs(0))
|
|
107
|
+
.as_secs();
|
|
108
|
+
|
|
109
|
+
self.fetch_from_cache(
|
|
110
|
+
session,
|
|
111
|
+
ctx,
|
|
112
|
+
&host,
|
|
113
|
+
&uri,
|
|
114
|
+
&query,
|
|
115
|
+
&encodings_to_check,
|
|
116
|
+
now_secs,
|
|
117
|
+
)
|
|
118
|
+
.await
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async fn response_filter(
|
|
122
|
+
&self,
|
|
123
|
+
session: &mut Session,
|
|
124
|
+
resp: &mut ResponseHeader,
|
|
125
|
+
ctx: &mut Self::CTX,
|
|
126
|
+
) -> Result<()> {
|
|
127
|
+
let req_uri = session.req_header().uri.path();
|
|
128
|
+
|
|
129
|
+
if let Some(rules) = &self.config.cache_control {
|
|
130
|
+
for rule in rules {
|
|
131
|
+
let mut matches = false;
|
|
132
|
+
if let Some(paths) = &rule.paths {
|
|
133
|
+
for p in paths {
|
|
134
|
+
if req_uri.starts_with(p) {
|
|
135
|
+
matches = true;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if !matches {
|
|
141
|
+
if let Some(exts) = &rule.extensions {
|
|
142
|
+
for ext in exts {
|
|
143
|
+
if req_uri.ends_with(ext) {
|
|
144
|
+
matches = true;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if matches {
|
|
151
|
+
let _ = resp.remove_header("Cache-Control");
|
|
152
|
+
let _ = resp.insert_header("Cache-Control", &rule.value);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let content_type = resp
|
|
159
|
+
.headers
|
|
160
|
+
.get("Content-Type")
|
|
161
|
+
.map(|hv| hv.to_str().unwrap_or(""))
|
|
162
|
+
.unwrap_or("");
|
|
163
|
+
|
|
164
|
+
let default_statuses = vec![200];
|
|
165
|
+
let default_content_types = vec!["text/html".to_string()];
|
|
166
|
+
|
|
167
|
+
let valid_statuses = self
|
|
168
|
+
.config
|
|
169
|
+
.cache
|
|
170
|
+
.as_ref()
|
|
171
|
+
.and_then(|c| c.status.as_ref())
|
|
172
|
+
.unwrap_or(&default_statuses);
|
|
173
|
+
|
|
174
|
+
let valid_content_types = self
|
|
175
|
+
.config
|
|
176
|
+
.cache
|
|
177
|
+
.as_ref()
|
|
178
|
+
.and_then(|c| c.content_types.as_ref())
|
|
179
|
+
.unwrap_or(&default_content_types);
|
|
180
|
+
|
|
181
|
+
let has_valid_status = valid_statuses.contains(&resp.status.as_u16());
|
|
182
|
+
let has_valid_content_type = valid_content_types
|
|
183
|
+
.iter()
|
|
184
|
+
.any(|ct| content_type.contains(ct));
|
|
185
|
+
|
|
186
|
+
if has_valid_status && has_valid_content_type && !ctx.cache_key.is_empty() {
|
|
187
|
+
ctx.should_cache = true;
|
|
188
|
+
ctx.response_header = Some(resp.clone());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
Ok(())
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
fn response_body_filter(
|
|
195
|
+
&self,
|
|
196
|
+
_session: &mut Session,
|
|
197
|
+
body: &mut Option<Bytes>,
|
|
198
|
+
end_of_stream: bool,
|
|
199
|
+
ctx: &mut Self::CTX,
|
|
200
|
+
) -> Result<Option<Duration>> {
|
|
201
|
+
if ctx.should_cache {
|
|
202
|
+
if let Some(b) = body {
|
|
203
|
+
ctx.response_body.extend_from_slice(b);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if end_of_stream {
|
|
207
|
+
let cache_key = ctx.cache_key.clone();
|
|
208
|
+
let html_bytes = Bytes::from(ctx.response_body.clone());
|
|
209
|
+
let redis_url_opt = self.config.redis_url.clone();
|
|
210
|
+
let t2_ttl = self
|
|
211
|
+
.config
|
|
212
|
+
.cache
|
|
213
|
+
.as_ref()
|
|
214
|
+
.and_then(|c| c.tier2_ttl_seconds)
|
|
215
|
+
.unwrap_or(60);
|
|
216
|
+
|
|
217
|
+
if let Some(header) = ctx.response_header.clone() {
|
|
218
|
+
let host = ctx.host.clone();
|
|
219
|
+
self.spawn_cache_save(
|
|
220
|
+
host,
|
|
221
|
+
cache_key,
|
|
222
|
+
header,
|
|
223
|
+
html_bytes,
|
|
224
|
+
redis_url_opt,
|
|
225
|
+
t2_ttl,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
Ok(None)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
use async_trait::async_trait;
|
|
2
|
+
use openssl::{pkey::PKey, ssl::NameType, x509::X509};
|
|
3
|
+
use pingora::{
|
|
4
|
+
listeners::{TlsAccept, tls::TlsSettings},
|
|
5
|
+
tls::ext,
|
|
6
|
+
};
|
|
7
|
+
use std::collections::HashMap;
|
|
8
|
+
use tracing::error;
|
|
9
|
+
|
|
10
|
+
pub struct TlsCertificate {
|
|
11
|
+
cert: X509,
|
|
12
|
+
key: PKey<openssl::pkey::Private>,
|
|
13
|
+
chain: Vec<X509>,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub struct DynamicCertificate {
|
|
17
|
+
// Maps SNI hostname to certificate
|
|
18
|
+
certs: HashMap<String, TlsCertificate>,
|
|
19
|
+
default_cert: Option<TlsCertificate>,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl DynamicCertificate {
|
|
23
|
+
pub fn new() -> Self {
|
|
24
|
+
Self {
|
|
25
|
+
certs: HashMap::new(),
|
|
26
|
+
default_cert: None,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
pub fn new_tls_settings(
|
|
32
|
+
certs_config: Vec<crate::config::CertificateConfig>,
|
|
33
|
+
) -> Result<TlsSettings, Box<pingora_core::BError>> {
|
|
34
|
+
let mut dynamic_cert = DynamicCertificate::new();
|
|
35
|
+
|
|
36
|
+
for cfg in certs_config {
|
|
37
|
+
let cert_pem = std::fs::read(&cfg.cert_path).map_err(|e| {
|
|
38
|
+
pingora_core::Error::because(
|
|
39
|
+
pingora_core::ErrorType::Custom("TLSConfError"),
|
|
40
|
+
format!("Failed to read cert file {}: {}", cfg.cert_path, e),
|
|
41
|
+
e,
|
|
42
|
+
)
|
|
43
|
+
})?;
|
|
44
|
+
let key_pem = std::fs::read(&cfg.key_path).map_err(|e| {
|
|
45
|
+
pingora_core::Error::because(
|
|
46
|
+
pingora_core::ErrorType::Custom("TLSConfError"),
|
|
47
|
+
format!("Failed to read key file {}: {}", cfg.key_path, e),
|
|
48
|
+
e,
|
|
49
|
+
)
|
|
50
|
+
})?;
|
|
51
|
+
|
|
52
|
+
let cert = X509::from_pem(&cert_pem).map_err(|e| {
|
|
53
|
+
pingora_core::Error::because(
|
|
54
|
+
pingora_core::ErrorType::Custom("TLSConfError"),
|
|
55
|
+
"Failed to parse cert",
|
|
56
|
+
e,
|
|
57
|
+
)
|
|
58
|
+
})?;
|
|
59
|
+
let key = PKey::private_key_from_pem(&key_pem).map_err(|e| {
|
|
60
|
+
pingora_core::Error::because(
|
|
61
|
+
pingora_core::ErrorType::Custom("TLSConfError"),
|
|
62
|
+
"Failed to parse private key",
|
|
63
|
+
e,
|
|
64
|
+
)
|
|
65
|
+
})?;
|
|
66
|
+
|
|
67
|
+
let tls_cert = TlsCertificate {
|
|
68
|
+
cert,
|
|
69
|
+
key,
|
|
70
|
+
chain: vec![], // Extend logic later to handle fullchains if necessary
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if cfg.host == "default" {
|
|
74
|
+
dynamic_cert.default_cert = Some(tls_cert);
|
|
75
|
+
} else {
|
|
76
|
+
dynamic_cert.certs.insert(cfg.host, tls_cert);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let tls =
|
|
81
|
+
TlsSettings::with_callbacks(Box::new(dynamic_cert)).map_err(|e: Box<pingora::Error>| {
|
|
82
|
+
pingora_core::Error::because(
|
|
83
|
+
pingora_core::ErrorType::Custom("TLSConfError"),
|
|
84
|
+
"TlsSettings wrapper error",
|
|
85
|
+
e,
|
|
86
|
+
)
|
|
87
|
+
})?;
|
|
88
|
+
Ok(tls)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#[async_trait]
|
|
92
|
+
impl TlsAccept for DynamicCertificate {
|
|
93
|
+
async fn certificate_callback(&self, ssl: &mut pingora::protocols::tls::TlsRef) {
|
|
94
|
+
let server_name = ssl.servername(NameType::HOST_NAME).unwrap_or("localhost");
|
|
95
|
+
|
|
96
|
+
let cert_info = if let Some(cert) = self.certs.get(server_name) {
|
|
97
|
+
cert
|
|
98
|
+
} else if let Some(cert) = &self.default_cert {
|
|
99
|
+
cert
|
|
100
|
+
} else {
|
|
101
|
+
error!("No certificate found for SNI: {}", server_name);
|
|
102
|
+
return;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
if let Err(e) = ext::ssl_use_certificate(ssl, &cert_info.cert) {
|
|
106
|
+
error!("Failed to use certificate: {}", e);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if let Err(e) = ext::ssl_use_private_key(ssl, &cert_info.key) {
|
|
110
|
+
error!("Failed to use private key: {}", e);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for chain_cert in &cert_info.chain {
|
|
114
|
+
if let Err(e) = ext::ssl_add_chain_cert(ssl, chain_cert) {
|
|
115
|
+
error!("Failed to add chain certificate: {}", e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|