Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions Docs/04-advanced-features/file-io.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,67 @@ display updated
display "=== Demo Complete ==="
```

## Binary Files

Text reads (`read content`) require valid UTF-8, so they corrupt or reject
non-text files such as fonts, images, PDFs, or compressed archives. For those,
use WFL's **binary** file operations, which preserve every byte exactly.

### Reading Binary Content

```wfl
open file at "logo.png" for reading binary as image
store bytes as read binary from image
close file image

display "Read " with length of bytes with " bytes"
```

**Syntax:**
```wfl
open file at "<path>" for reading binary as <variable>
store <variable> as read binary from <handle>
close file <handle>
```

Read only the first N bytes (for example, to sniff a file header):

```wfl
open file at "logo.png" for reading binary as image
store header as read 8 bytes from image
close file image
```

### Writing Binary Content

`write binary` accepts either a binary value (from `read binary`) or a list of
byte numbers (each 0–255):

```wfl
create list bytes:
add 137
add 80
add 78
add 71
end list

open file at "signature.bin" for writing binary as out_file
write binary bytes into out_file
close file out_file
```

**Syntax:**
```wfl
open file at "<path>" for writing binary as <variable>
write binary <binary-or-byte-list> into <handle>
close file <handle>
```

> **Note:** `data` is a reserved keyword, so choose another variable name
> (e.g. `bytes`, `payload`, `content_bytes`) when storing binary content.

Binary reads and writes are capped at 50 MB per operation as a safety limit.

## Directory Operations

### Listing Files
Expand Down
58 changes: 58 additions & 0 deletions Docs/04-advanced-features/web-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,26 @@ store user_agent as header "User-Agent" from req
display "User agent: " with user_agent
```

### Request Body

The request body is available two ways:

- `body` — the body decoded as text (lossy UTF-8). Use this for form posts,
JSON, and other text payloads.
- `body_bytes` — the **raw bytes** of the body, preserved exactly. Use this for
binary uploads (file uploads, images, etc.); it can be written straight to
disk with `write binary` or echoed back with `respond to`.

```wfl
wait for request comes in on server as req

open file at "uploads/received.bin" for writing binary as out_file
write binary body_bytes into out_file
close file out_file

respond to req with "Uploaded"
```

## Response Options

### With Status Code
Expand Down Expand Up @@ -217,6 +237,44 @@ respond to <request> with <content> and content_type <type>
- `text/css` - CSS stylesheets
- `application/javascript` - JavaScript files

### Serving Binary Files (Fonts, Images, etc.)

`respond to` carries **binary** content losslessly, so you can serve fonts,
images, favicons, PDFs, and other non-text assets straight from disk. Read the
file with `read binary` (see [File I/O](file-io.md#binary-files)) and respond
with the resulting bytes:

```wfl
open file at "public/fonts/Alegreya-Regular.ttf" for reading binary as f
store font_bytes as read binary from f
close file f

respond to req with font_bytes and content_type "font/ttf"
```

If you omit `content_type` for binary content, it defaults to
`application/octet-stream` (rather than `text/plain`).

To pick the content type automatically from a file name, use the `mime_type`
helper — handy for a static-file route:

```wfl
store asset_path as "public/fonts/Alegreya-Regular.ttf"
open file at asset_path for reading binary as f
store asset_bytes as read binary from f
close file f

respond to req with asset_bytes and content_type (mime_type of asset_path)
```

`mime_type of <name>` maps a file name or path to a content type by its
extension (`.ttf`→`font/ttf`, `.woff2`→`font/woff2`, `.png`→`image/png`,
`.svg`→`image/svg+xml`, `.ico`→`image/x-icon`, `.css`, `.js`, `.json`, …),
falling back to `application/octet-stream` for unknown extensions.

> **Note:** `data` is a reserved keyword — name the variable holding the bytes
> something else (e.g. `font_bytes`, `payload`).

### With Custom Headers

Set extra response headers by passing a map to `and headers`. This mirrors the
Expand Down
71 changes: 71 additions & 0 deletions TestPrograms/binary_file_and_mime_test.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// Binary file I/O and MIME type support (issue #573)
// Demonstrates that WFL handles binary content losslessly at the language
// level and can pick content types for static assets. Self-terminating
// (no web server): the end-to-end serving path is covered by the Rust
// integration test tests/web_server_binary_test.rs.

display "=== Binary file + MIME test ==="

// Build a small binary payload as a list of byte values (0-255), including
// values above 0x7F that are not valid standalone UTF-8.
create list payload:
add 0
add 127
add 128
add 200
add 255
end list

// Write the raw bytes, then read them back.
open file at "binary_roundtrip.bin" for writing binary as out_file
write binary payload into out_file
close file out_file

open file at "binary_roundtrip.bin" for reading binary as in_file
store loaded as read binary from in_file
store loaded_size as file size of in_file
store loaded_length as length of loaded
close file in_file

check if loaded_size is equal to 5:
display "PASS: round-tripped 5 bytes losslessly (file size)"
otherwise:
display "FAIL: expected 5 bytes, got " with loaded_size
end check

check if loaded_length is equal to 5:
display "PASS: read binary returned 5 bytes"
otherwise:
display "FAIL: expected 5 bytes from read binary, got " with loaded_length
end check

// The mime_type helper maps a file name/path to an HTTP content type so
// static assets (`respond to req with <bytes> and content_type ...`) serve
// with the right type out of the box.
store ttf_type as mime_type of "Alegreya-Regular.ttf"
store png_type as mime_type of "favicon.png"
store unknown_type as mime_type of "notes"

display "mime_type of Alegreya-Regular.ttf: " with ttf_type
check if ttf_type is equal to "font/ttf":
display "PASS: .ttf maps to font/ttf"
otherwise:
display "FAIL: .ttf mapped to " with ttf_type
end check

check if png_type is equal to "image/png":
display "PASS: .png maps to image/png"
otherwise:
display "FAIL: .png mapped to " with png_type
end check

check if unknown_type is equal to "application/octet-stream":
display "PASS: unknown extension falls back to application/octet-stream"
otherwise:
display "FAIL: unknown mapped to " with unknown_type
end check

// Clean up the temporary file.
delete file at "binary_roundtrip.bin"

display "=== Binary file + MIME test complete ==="
1 change: 1 addition & 0 deletions src/analyzer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1578,6 +1578,7 @@ impl Analyzer {
("path", Type::Text),
("client_ip", Type::Text),
("body", Type::Text),
("body_bytes", Type::Binary),
("headers", Type::Custom("Headers".to_string())),
];

Expand Down
3 changes: 3 additions & 0 deletions src/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const BUILTIN_FUNCTIONS: &[&str] = &[
// Web routing helpers (implemented in stdlib/web.rs)
"path_params",
"path_matches",
"mime_type",
// Math functions (implemented in stdlib/math.rs)
"min",
"max",
Expand Down Expand Up @@ -285,6 +286,8 @@ pub fn get_function_arity(name: &str) -> usize {
// === WEB ROUTING HELPERS ===
// Two argument functions: (path, template)
"path_params" | "path_matches" => 2,
// Single argument: (name)
"mime_type" => 1,

// === TEXT FUNCTIONS ===
// Single argument functions
Expand Down
58 changes: 41 additions & 17 deletions src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,20 @@ pub struct WflHttpRequest {
pub method: String,
pub path: String,
pub client_ip: String,
pub body: String,
/// Raw request body bytes. Exposed to WFL both as a lossy-UTF-8 `body`
/// text variable (backward compatible) and as a lossless `body_bytes`
/// binary value, so binary uploads survive intact.
pub body: Vec<u8>,
pub headers: HashMap<String, String>,
pub response_sender: Arc<tokio::sync::Mutex<Option<oneshot::Sender<WflHttpResponse>>>>,
}

#[derive(Debug, Clone)]
pub struct WflHttpResponse {
pub content: String,
/// Raw response body bytes. Text responses store their UTF-8 encoding;
/// binary responses (`Value::Binary`) store their bytes verbatim, so
/// fonts/images/etc. are served losslessly.
pub content: Vec<u8>,
pub status: u16,
pub content_type: String,
pub headers: HashMap<String, String>,
Expand Down Expand Up @@ -5147,8 +5153,10 @@ impl Interpreter {
}
}

// Convert body to string
let body_str = String::from_utf8_lossy(&body).to_string();
// Keep the raw body bytes so binary uploads survive;
// WFL exposes both a lossy-text `body` and a lossless
// `body_bytes` view of these bytes.
let body_bytes = body.to_vec();

// Create response channel
let (response_sender, response_receiver) =
Expand All @@ -5160,7 +5168,7 @@ impl Interpreter {
method: method.to_string(),
path: path.as_str().to_string(),
client_ip,
body: body_str,
body: body_bytes,
headers: header_map,
response_sender: Arc::new(tokio::sync::Mutex::new(Some(
response_sender,
Expand All @@ -5181,9 +5189,11 @@ impl Interpreter {
warp::http::StatusCode::from_u16(response.status)
.unwrap_or(warp::http::StatusCode::OK);

// Convert content to bytes for accurate Content-Length calculation
// HTTP Content-Length must match exact byte count of body
let content_bytes = response.content.into_bytes();
// Content is already raw bytes (text responses
// stored their UTF-8 encoding, binary responses
// their verbatim bytes), so Content-Length is the
// exact byte count of the body.
let content_bytes = response.content;
let content_length = content_bytes.len();

let mut reply_builder = warp::http::Response::builder()
Expand Down Expand Up @@ -5643,10 +5653,15 @@ impl Interpreter {
"client_ip".to_string(),
Value::Text(Arc::from(request.client_ip.clone())),
);
// `body` is a lossy-UTF-8 text view (backward compatible);
// `body_bytes` is the lossless binary view for binary uploads.
let body_text = String::from_utf8_lossy(&request.body).into_owned();
let body_binary = Value::Binary(Arc::from(request.body.as_slice()));
request_properties.insert(
"body".to_string(),
Value::Text(Arc::from(request.body.clone())),
Value::Text(Arc::from(body_text.as_str())),
);
request_properties.insert("body_bytes".to_string(), body_binary.clone());
request_properties.insert("headers".to_string(), headers_object.clone());
let request_object = Value::Object(Rc::new(RefCell::new(request_properties)));

Expand All @@ -5664,7 +5679,8 @@ impl Interpreter {
Value::Text(Arc::from(request.client_ip.clone())),
);

env_mut.define_or_replace("body", Value::Text(Arc::from(request.body.clone())));
env_mut.define_or_replace("body", Value::Text(Arc::from(body_text.as_str())));
env_mut.define_or_replace("body_bytes", body_binary);

env_mut.define_or_replace("headers", headers_object);

Expand Down Expand Up @@ -5715,13 +5731,17 @@ impl Interpreter {
}
};

// Evaluate response content
// Evaluate response content. Binary values are carried through
// as raw bytes so fonts/images/etc. serve losslessly; text and
// scalar values keep their existing UTF-8 rendering.
let content_val = self.evaluate_expression(content, Rc::clone(&env)).await?;
let content_str = match &content_val {
Value::Text(text) => text.as_ref().to_string(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => format!("{:?}", content_val),
let is_binary = matches!(content_val, Value::Binary(_));
let content_bytes: Vec<u8> = match &content_val {
Value::Text(text) => text.as_bytes().to_vec(),
Value::Number(n) => n.to_string().into_bytes(),
Value::Bool(b) => b.to_string().into_bytes(),
Value::Binary(bytes) => bytes.to_vec(),
_ => format!("{content_val:?}").into_bytes(),
};

// Evaluate status code (optional)
Expand Down Expand Up @@ -5756,6 +5776,10 @@ impl Interpreter {
));
}
}
} else if is_binary {
// Binary responses default to a generic binary media type
// rather than text/plain so browsers don't misinterpret them.
"application/octet-stream".to_string()
} else {
"text/plain".to_string() // Default content type
};
Expand Down Expand Up @@ -5819,7 +5843,7 @@ impl Interpreter {

// Create response
let response = WflHttpResponse {
content: content_str,
content: content_bytes,
status: status_code,
content_type: content_type_str,
headers: custom_headers,
Expand Down
8 changes: 7 additions & 1 deletion src/stdlib/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,14 @@ pub fn native_length(args: Vec<Value>) -> Result<Value, RuntimeError> {
match &args[0] {
Value::List(list) => Ok(Value::Number(list.borrow().len() as f64)),
Value::Text(text) => Ok(Value::Number(text.chars().count() as f64)),
// Binary length is the number of bytes, letting `length of bytes`
// report the size of binary content (e.g. from `read binary`).
Value::Binary(bytes) => Ok(Value::Number(bytes.len() as f64)),
_ => Err(RuntimeError::new(
format!("length expects a list or text, got {}", args[0].type_name()),
format!(
"length expects a list, text, or binary, got {}",
args[0].type_name()
),
0,
0,
)),
Expand Down
Loading
Loading