@zap-js/server 0.0.1 → 0.0.4

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/src/static.rs DELETED
@@ -1,572 +0,0 @@
1
- //! Static file serving functionality for ZapServer
2
- //!
3
- //! Provides high-performance static file serving with:
4
- //! - ETag generation (weak or strong)
5
- //! - Last-Modified headers
6
- //! - Conditional request handling (304 Not Modified)
7
- //! - Cache-Control configuration
8
- //! - Content-Type detection
9
- //! - Directory traversal protection
10
-
11
- use std::collections::HashMap;
12
- use std::path::PathBuf;
13
- use std::time::SystemTime;
14
- use zap_core::{Response, StatusCode};
15
- use crate::error::ZapError;
16
- use crate::response::ZapResponse;
17
-
18
- /// ETag generation strategy
19
- #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
20
- pub enum ETagStrategy {
21
- /// Weak ETag from mtime + size (fast, no hashing)
22
- /// Format: W/"size-mtime_hex"
23
- #[default]
24
- Weak,
25
- /// Strong ETag using SHA256 hash (slower but precise)
26
- /// Format: "sha256_hex"
27
- Strong,
28
- /// Disable ETag generation
29
- None,
30
- }
31
-
32
- /// Static file handler configuration
33
- #[derive(Debug, Clone)]
34
- pub struct StaticHandler {
35
- /// URL prefix (e.g., "/assets")
36
- pub prefix: String,
37
- /// Local directory path
38
- pub directory: PathBuf,
39
- /// Options for static serving
40
- pub options: StaticOptions,
41
- }
42
-
43
- /// Static file serving options
44
- #[derive(Debug, Clone)]
45
- pub struct StaticOptions {
46
- /// Enable directory listing
47
- pub directory_listing: bool,
48
- /// Set Cache-Control header
49
- pub cache_control: Option<String>,
50
- /// Custom headers
51
- pub headers: HashMap<String, String>,
52
- /// Enable compression
53
- pub compress: bool,
54
- /// ETag generation strategy (default: Weak)
55
- pub etag_strategy: ETagStrategy,
56
- /// Enable Last-Modified header (default: true)
57
- pub enable_last_modified: bool,
58
- }
59
-
60
- impl Default for StaticOptions {
61
- fn default() -> Self {
62
- Self {
63
- directory_listing: false,
64
- cache_control: Some("public, max-age=3600".to_string()),
65
- headers: HashMap::new(),
66
- compress: true,
67
- etag_strategy: ETagStrategy::default(),
68
- enable_last_modified: true,
69
- }
70
- }
71
- }
72
-
73
- /// File metadata for caching headers
74
- #[derive(Debug, Clone)]
75
- struct FileMetadata {
76
- size: u64,
77
- modified: SystemTime,
78
- }
79
-
80
- impl StaticHandler {
81
- /// Create a new static handler
82
- pub fn new<P: Into<PathBuf>>(prefix: &str, directory: P) -> Self {
83
- Self {
84
- prefix: prefix.to_string(),
85
- directory: directory.into(),
86
- options: StaticOptions::default(),
87
- }
88
- }
89
-
90
- /// Create a new static handler with options
91
- pub fn new_with_options<P: Into<PathBuf>>(
92
- prefix: &str,
93
- directory: P,
94
- options: StaticOptions,
95
- ) -> Self {
96
- Self {
97
- prefix: prefix.to_string(),
98
- directory: directory.into(),
99
- options,
100
- }
101
- }
102
-
103
- /// Handle a static file request with conditional request support
104
- pub async fn handle(&self, path: &str) -> Result<Option<ZapResponse>, ZapError> {
105
- self.handle_with_headers(path, &HashMap::new()).await
106
- }
107
-
108
- /// Handle a static file request with request headers for conditional handling
109
- pub async fn handle_with_headers(
110
- &self,
111
- path: &str,
112
- request_headers: &HashMap<String, String>,
113
- ) -> Result<Option<ZapResponse>, ZapError> {
114
- if !path.starts_with(&self.prefix) {
115
- return Ok(None);
116
- }
117
-
118
- let file_path = path.strip_prefix(&self.prefix).unwrap_or("");
119
- // Handle empty path or root
120
- let file_path = if file_path.is_empty() || file_path == "/" {
121
- "index.html"
122
- } else {
123
- file_path.trim_start_matches('/')
124
- };
125
- let full_path = self.directory.join(file_path);
126
-
127
- // Security check: ensure path doesn't escape the directory
128
- let canonical_dir = self.directory.canonicalize().unwrap_or_else(|_| self.directory.clone());
129
- let canonical_path = full_path.canonicalize();
130
-
131
- if let Ok(canonical) = &canonical_path {
132
- if !canonical.starts_with(&canonical_dir) {
133
- return Ok(Some(ZapResponse::Custom(Response::forbidden("Access denied"))));
134
- }
135
- }
136
-
137
- // Get file metadata
138
- let metadata = match tokio::fs::metadata(&full_path).await {
139
- Ok(m) if m.is_file() => m,
140
- _ => return Ok(None),
141
- };
142
-
143
- let file_meta = FileMetadata {
144
- size: metadata.len(),
145
- modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
146
- };
147
-
148
- // Generate ETag if enabled
149
- let etag = self.generate_etag(&file_meta, &full_path).await;
150
-
151
- // Generate Last-Modified header value
152
- let last_modified = if self.options.enable_last_modified {
153
- Some(format_http_date(file_meta.modified))
154
- } else {
155
- None
156
- };
157
-
158
- // Check conditional request headers
159
- if let Some(ref etag_value) = etag {
160
- // Check If-None-Match
161
- if let Some(if_none_match) = request_headers.get("if-none-match")
162
- .or_else(|| request_headers.get("If-None-Match"))
163
- {
164
- if etags_match(if_none_match, etag_value) {
165
- return Ok(Some(self.not_modified_response(&etag, &last_modified)));
166
- }
167
- }
168
- }
169
-
170
- // Check If-Modified-Since
171
- if let Some(ref last_mod) = last_modified {
172
- if let Some(if_modified_since) = request_headers.get("if-modified-since")
173
- .or_else(|| request_headers.get("If-Modified-Since"))
174
- {
175
- if let Some(since_time) = parse_http_date(if_modified_since) {
176
- // File not modified since the specified time
177
- if file_meta.modified <= since_time {
178
- return Ok(Some(self.not_modified_response(&etag, &Some(last_mod.clone()))));
179
- }
180
- }
181
- }
182
- }
183
-
184
- // Read file and serve
185
- match tokio::fs::read(&full_path).await {
186
- Ok(contents) => {
187
- let content_type = mime_guess::from_path(&full_path)
188
- .first_or_octet_stream()
189
- .to_string();
190
-
191
- let mut response = Response::new()
192
- .status(StatusCode::OK)
193
- .content_type(content_type)
194
- .body(contents);
195
-
196
- // Add cache control if specified
197
- if let Some(cache_control) = &self.options.cache_control {
198
- response = response.cache_control(cache_control);
199
- }
200
-
201
- // Add ETag header
202
- if let Some(etag_value) = etag {
203
- response = response.header("ETag", etag_value);
204
- }
205
-
206
- // Add Last-Modified header
207
- if let Some(last_mod) = last_modified {
208
- response = response.header("Last-Modified", last_mod);
209
- }
210
-
211
- // Add custom headers
212
- for (key, value) in &self.options.headers {
213
- response = response.header(key, value);
214
- }
215
-
216
- Ok(Some(ZapResponse::Custom(response)))
217
- }
218
- Err(_) => Ok(Some(ZapResponse::Custom(
219
- Response::internal_server_error("Failed to read file"),
220
- ))),
221
- }
222
- }
223
-
224
- /// Generate ETag based on configured strategy
225
- async fn generate_etag(&self, meta: &FileMetadata, path: &PathBuf) -> Option<String> {
226
- match self.options.etag_strategy {
227
- ETagStrategy::Weak => {
228
- // Weak ETag from size + mtime
229
- let mtime_secs = meta
230
- .modified
231
- .duration_since(SystemTime::UNIX_EPOCH)
232
- .map(|d| d.as_secs())
233
- .unwrap_or(0);
234
- Some(format!("W/\"{:x}-{:x}\"", meta.size, mtime_secs))
235
- }
236
- ETagStrategy::Strong => {
237
- // Strong ETag using SHA256 hash of content
238
- match tokio::fs::read(path).await {
239
- Ok(contents) => {
240
- use sha2::{Digest, Sha256};
241
- let mut hasher = Sha256::new();
242
- hasher.update(&contents);
243
- let hash = hasher.finalize();
244
- // Use first 16 bytes (32 hex chars) for reasonable length
245
- Some(format!("\"{}\"", hex::encode(&hash[..16])))
246
- }
247
- Err(_) => None,
248
- }
249
- }
250
- ETagStrategy::None => None,
251
- }
252
- }
253
-
254
- /// Generate a 304 Not Modified response
255
- fn not_modified_response(
256
- &self,
257
- etag: &Option<String>,
258
- last_modified: &Option<String>,
259
- ) -> ZapResponse {
260
- let mut response = Response::new().status(StatusCode::NOT_MODIFIED);
261
-
262
- // Add cache control if specified
263
- if let Some(cache_control) = &self.options.cache_control {
264
- response = response.cache_control(cache_control);
265
- }
266
-
267
- // Add ETag header
268
- if let Some(etag_value) = etag {
269
- response = response.header("ETag", etag_value);
270
- }
271
-
272
- // Add Last-Modified header
273
- if let Some(last_mod) = last_modified {
274
- response = response.header("Last-Modified", last_mod);
275
- }
276
-
277
- ZapResponse::Custom(response)
278
- }
279
- }
280
-
281
- /// Handle static file requests from a list of handlers
282
- pub async fn handle_static_files(
283
- handlers: &[StaticHandler],
284
- path: &str,
285
- ) -> Result<Option<ZapResponse>, ZapError> {
286
- handle_static_files_with_headers(handlers, path, &HashMap::new()).await
287
- }
288
-
289
- /// Handle static file requests with request headers for conditional handling
290
- pub async fn handle_static_files_with_headers(
291
- handlers: &[StaticHandler],
292
- path: &str,
293
- request_headers: &HashMap<String, String>,
294
- ) -> Result<Option<ZapResponse>, ZapError> {
295
- for handler in handlers {
296
- if let Some(response) = handler.handle_with_headers(path, request_headers).await? {
297
- return Ok(Some(response));
298
- }
299
- }
300
- Ok(None)
301
- }
302
-
303
- // ============================================================================
304
- // HTTP Date Formatting (RFC 7231)
305
- // ============================================================================
306
-
307
- /// Format a SystemTime as an HTTP-date (RFC 7231)
308
- /// Example: "Wed, 21 Oct 2015 07:28:00 GMT"
309
- fn format_http_date(time: SystemTime) -> String {
310
- use std::time::UNIX_EPOCH;
311
-
312
- let duration = time.duration_since(UNIX_EPOCH).unwrap_or_default();
313
- let secs = duration.as_secs() as i64;
314
-
315
- // Calculate date components
316
- // Using a simplified algorithm for days since epoch
317
- let days_since_epoch = secs / 86400;
318
- let time_of_day = secs % 86400;
319
-
320
- let hours = time_of_day / 3600;
321
- let minutes = (time_of_day % 3600) / 60;
322
- let seconds = time_of_day % 60;
323
-
324
- // Calculate year, month, day using a simplified algorithm
325
- // This is accurate for dates after 1970
326
- let (year, month, day, weekday) = days_to_ymd(days_since_epoch);
327
-
328
- let weekday_name = match weekday {
329
- 0 => "Thu", // Jan 1, 1970 was a Thursday
330
- 1 => "Fri",
331
- 2 => "Sat",
332
- 3 => "Sun",
333
- 4 => "Mon",
334
- 5 => "Tue",
335
- 6 => "Wed",
336
- _ => "???",
337
- };
338
-
339
- let month_name = match month {
340
- 1 => "Jan",
341
- 2 => "Feb",
342
- 3 => "Mar",
343
- 4 => "Apr",
344
- 5 => "May",
345
- 6 => "Jun",
346
- 7 => "Jul",
347
- 8 => "Aug",
348
- 9 => "Sep",
349
- 10 => "Oct",
350
- 11 => "Nov",
351
- 12 => "Dec",
352
- _ => "???",
353
- };
354
-
355
- format!(
356
- "{}, {:02} {} {} {:02}:{:02}:{:02} GMT",
357
- weekday_name, day, month_name, year, hours, minutes, seconds
358
- )
359
- }
360
-
361
- /// Convert days since epoch to year, month, day, weekday
362
- fn days_to_ymd(days: i64) -> (i32, u32, u32, u32) {
363
- // Weekday: Thursday = 0 for Jan 1, 1970
364
- let weekday = ((days % 7) + 7) % 7;
365
-
366
- // Algorithm adapted from Howard Hinnant's date algorithms
367
- let z = days + 719468;
368
- let era = if z >= 0 { z } else { z - 146096 } / 146097;
369
- let doe = (z - era * 146097) as u32;
370
- let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
371
- let y = yoe as i64 + era * 400;
372
- let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
373
- let mp = (5 * doy + 2) / 153;
374
- let d = doy - (153 * mp + 2) / 5 + 1;
375
- let m = if mp < 10 { mp + 3 } else { mp - 9 };
376
- let year = if m <= 2 { y + 1 } else { y };
377
-
378
- (year as i32, m, d, weekday as u32)
379
- }
380
-
381
- /// Parse an HTTP-date (RFC 7231) to SystemTime
382
- /// Supports: "Wed, 21 Oct 2015 07:28:00 GMT"
383
- fn parse_http_date(date_str: &str) -> Option<SystemTime> {
384
- use std::time::{Duration, UNIX_EPOCH};
385
-
386
- // Parse format: "Wed, 21 Oct 2015 07:28:00 GMT"
387
- let parts: Vec<&str> = date_str.split_whitespace().collect();
388
- if parts.len() < 5 {
389
- return None;
390
- }
391
-
392
- // Parse day (skip weekday)
393
- let day: u32 = parts[1].trim_end_matches(',').parse().ok()?;
394
-
395
- // Parse month
396
- let month: u32 = match parts[2].to_lowercase().as_str() {
397
- "jan" => 1,
398
- "feb" => 2,
399
- "mar" => 3,
400
- "apr" => 4,
401
- "may" => 5,
402
- "jun" => 6,
403
- "jul" => 7,
404
- "aug" => 8,
405
- "sep" => 9,
406
- "oct" => 10,
407
- "nov" => 11,
408
- "dec" => 12,
409
- _ => return None,
410
- };
411
-
412
- // Parse year
413
- let year: i32 = parts[3].parse().ok()?;
414
-
415
- // Parse time
416
- let time_parts: Vec<&str> = parts[4].split(':').collect();
417
- if time_parts.len() != 3 {
418
- return None;
419
- }
420
- let hours: u32 = time_parts[0].parse().ok()?;
421
- let minutes: u32 = time_parts[1].parse().ok()?;
422
- let seconds: u32 = time_parts[2].parse().ok()?;
423
-
424
- // Convert to seconds since epoch
425
- let days = ymd_to_days(year, month, day)?;
426
- let total_secs = days as u64 * 86400 + hours as u64 * 3600 + minutes as u64 * 60 + seconds as u64;
427
-
428
- Some(UNIX_EPOCH + Duration::from_secs(total_secs))
429
- }
430
-
431
- /// Convert year, month, day to days since epoch
432
- fn ymd_to_days(year: i32, month: u32, day: u32) -> Option<i64> {
433
- // Algorithm adapted from Howard Hinnant's date algorithms
434
- let y = if month <= 2 { year - 1 } else { year } as i64;
435
- let m = if month <= 2 { month + 12 } else { month } as i64;
436
- let era = if y >= 0 { y } else { y - 399 } / 400;
437
- let yoe = y - era * 400;
438
- let doy = (153 * (m - 3) + 2) / 5 + day as i64 - 1;
439
- let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
440
- let days = era * 146097 + doe - 719468;
441
- Some(days)
442
- }
443
-
444
- // ============================================================================
445
- // ETag Matching
446
- // ============================================================================
447
-
448
- /// Check if an If-None-Match header value matches an ETag
449
- /// Handles multiple ETags separated by commas and weak/strong comparison
450
- fn etags_match(if_none_match: &str, etag: &str) -> bool {
451
- // Handle wildcard
452
- if if_none_match.trim() == "*" {
453
- return true;
454
- }
455
-
456
- // Normalize for weak comparison
457
- let normalize = |s: &str| -> String {
458
- let s = s.trim();
459
- // Strip W/ prefix for weak ETags
460
- let s = s.strip_prefix("W/").unwrap_or(s);
461
- // Remove surrounding quotes
462
- s.trim_matches('"').to_string()
463
- };
464
-
465
- let etag_normalized = normalize(etag);
466
-
467
- // Check each ETag in the If-None-Match header
468
- for candidate in if_none_match.split(',') {
469
- if normalize(candidate) == etag_normalized {
470
- return true;
471
- }
472
- }
473
-
474
- false
475
- }
476
-
477
- // ============================================================================
478
- // Tests
479
- // ============================================================================
480
-
481
- #[cfg(test)]
482
- mod tests {
483
- use super::*;
484
-
485
- #[test]
486
- fn test_etag_strategy_default() {
487
- assert_eq!(ETagStrategy::default(), ETagStrategy::Weak);
488
- }
489
-
490
- #[test]
491
- fn test_static_options_default() {
492
- let opts = StaticOptions::default();
493
- assert!(!opts.directory_listing);
494
- assert!(opts.compress);
495
- assert!(opts.enable_last_modified);
496
- assert_eq!(opts.etag_strategy, ETagStrategy::Weak);
497
- assert_eq!(opts.cache_control, Some("public, max-age=3600".to_string()));
498
- }
499
-
500
- #[test]
501
- fn test_etags_match() {
502
- // Exact match
503
- assert!(etags_match("\"abc123\"", "\"abc123\""));
504
-
505
- // Weak ETag comparison
506
- assert!(etags_match("W/\"abc123\"", "\"abc123\""));
507
- assert!(etags_match("\"abc123\"", "W/\"abc123\""));
508
- assert!(etags_match("W/\"abc123\"", "W/\"abc123\""));
509
-
510
- // Multiple ETags
511
- assert!(etags_match("\"other\", \"abc123\"", "\"abc123\""));
512
- assert!(etags_match("\"abc123\", \"other\"", "\"abc123\""));
513
-
514
- // Wildcard
515
- assert!(etags_match("*", "\"anything\""));
516
-
517
- // No match
518
- assert!(!etags_match("\"different\"", "\"abc123\""));
519
- }
520
-
521
- #[test]
522
- fn test_format_http_date() {
523
- use std::time::{Duration, UNIX_EPOCH};
524
-
525
- // Test a known date: Jan 1, 1970 00:00:00 GMT (epoch)
526
- let epoch = UNIX_EPOCH;
527
- let date_str = format_http_date(epoch);
528
- assert!(date_str.contains("1970"));
529
- assert!(date_str.contains("Jan"));
530
- assert!(date_str.contains("GMT"));
531
-
532
- // Test a later date
533
- let later = UNIX_EPOCH + Duration::from_secs(1445412480); // Oct 21, 2015 07:28:00
534
- let date_str = format_http_date(later);
535
- assert!(date_str.contains("2015"));
536
- assert!(date_str.contains("Oct"));
537
- }
538
-
539
- #[test]
540
- fn test_parse_http_date() {
541
- // Test parsing
542
- let parsed = parse_http_date("Wed, 21 Oct 2015 07:28:00 GMT");
543
- assert!(parsed.is_some());
544
-
545
- // Invalid format
546
- let invalid = parse_http_date("invalid date");
547
- assert!(invalid.is_none());
548
- }
549
-
550
- #[test]
551
- fn test_static_handler_creation() {
552
- let handler = StaticHandler::new("/assets", "./public");
553
- assert_eq!(handler.prefix, "/assets");
554
- assert_eq!(handler.directory, PathBuf::from("./public"));
555
- }
556
-
557
- #[test]
558
- fn test_static_handler_with_options() {
559
- let options = StaticOptions {
560
- directory_listing: true,
561
- cache_control: Some("no-cache".to_string()),
562
- etag_strategy: ETagStrategy::Strong,
563
- enable_last_modified: false,
564
- ..Default::default()
565
- };
566
-
567
- let handler = StaticHandler::new_with_options("/downloads", "./files", options);
568
- assert!(handler.options.directory_listing);
569
- assert_eq!(handler.options.etag_strategy, ETagStrategy::Strong);
570
- assert!(!handler.options.enable_last_modified);
571
- }
572
- }
package/src/types.js DELETED
@@ -1,21 +0,0 @@
1
- /**
2
- * @zap-js/server/types
3
- *
4
- * TypeScript types for server-side features
5
- */
6
-
7
- // RPC types
8
- export type {
9
- RpcMessage,
10
- RpcCallMessage,
11
- RpcResponseMessage,
12
- RpcErrorMessage,
13
- } from '../../client/internal/runtime/src/types.js';
14
-
15
- // IPC types
16
- export type {
17
- IpcMessage,
18
- InvokeHandlerMessage,
19
- HandlerResponseMessage,
20
- ErrorMessage,
21
- } from '../../client/internal/runtime/src/types.js';
package/src/utils.rs DELETED
@@ -1,18 +0,0 @@
1
- //! Utility functions for ZapServer
2
-
3
- use zap_core::Method;
4
- use crate::error::ZapError;
5
-
6
- /// Convert hyper Method to our Method enum
7
- pub fn convert_method(method: &hyper::Method) -> Result<Method, ZapError> {
8
- match *method {
9
- hyper::Method::GET => Ok(Method::GET),
10
- hyper::Method::POST => Ok(Method::POST),
11
- hyper::Method::PUT => Ok(Method::PUT),
12
- hyper::Method::PATCH => Ok(Method::PATCH),
13
- hyper::Method::DELETE => Ok(Method::DELETE),
14
- hyper::Method::HEAD => Ok(Method::HEAD),
15
- hyper::Method::OPTIONS => Ok(Method::OPTIONS),
16
- _ => Err(ZapError::http(format!("Unsupported method: {}", method))),
17
- }
18
- }