diff --git a/.gitignore b/.gitignore
index 87917cf..1c25fdb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,4 +46,5 @@ temp/
# Chunk upload temp files
chunks/
-.kiro
\ No newline at end of file
+.kiro
+*.def
\ No newline at end of file
diff --git a/README.md b/README.md
index dda2e65..e7abb0b 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
- 📤 **File Upload** - Chunked file upload support
- 🗄️ **Database** - SQLite integration for data persistence
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
+- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
## Installation
@@ -784,6 +785,251 @@ fn main() {
| 1009 | `ws_close_message_too_big` | Message too big |
| 1011 | `ws_close_internal_error` | Internal server error |
+### 9. SSE Streaming Helper
+
+Server-Sent Events (SSE) and streaming response support for real-time data push.
+
+#### Basic Binary Streaming
+
+```v
+import meiseayoung.hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ // Basic stream - binary data streaming
+ // Sets Transfer-Encoding: chunked header
+ app.get('/stream', fn (mut c hono.Context) http.Response {
+ return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
+ // Register abort callback for client disconnection
+ stream.on_abort(fn () {
+ println('Client disconnected')
+ })
+
+ // Stream binary data in chunks
+ for i in 0 .. 5 {
+ data := 'Chunk ${i + 1}: Hello from stream!\n'
+ stream.write(data.bytes())!
+ stream.sleep(500) // 500ms delay between chunks
+ }
+ })
+ })
+
+ app.listen(':3000')
+}
+```
+
+#### Text Streaming
+
+```v
+import meiseayoung.hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ // Text stream - for streaming text content
+ // Sets Content-Type: text/plain; charset=utf-8
+ // Sets Transfer-Encoding: chunked
+ // Sets X-Content-Type-Options: nosniff
+ app.get('/stream-text', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
+ stream.writeln('=== Text Streaming Demo ===')!
+ stream.sleep(300)
+
+ for i in 0 .. 5 {
+ stream.write_string('Processing item ${i + 1}... ')!
+ stream.sleep(200)
+ stream.writeln('Done!')!
+ }
+ })
+ })
+
+ app.listen(':3000')
+}
+```
+
+#### Server-Sent Events (SSE)
+
+```v
+import meiseayoung.hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ // SSE stream - for Server-Sent Events
+ // Sets Content-Type: text/event-stream
+ // Sets Cache-Control: no-cache
+ // Sets Connection: keep-alive
+ app.get('/sse', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Send initial connection event
+ stream.write_sse(hono.SSEEvent{
+ data: 'Connected to SSE stream'
+ event: 'connect'
+ id: '0'
+ })!
+
+ // Send periodic updates
+ for i in 1 .. 6 {
+ stream.sleep(1000) // 1 second delay
+
+ stream.write_sse(hono.SSEEvent{
+ data: 'Update ${i}'
+ event: 'update'
+ id: '${i}'
+ })!
+ }
+
+ // Send completion event
+ stream.write_sse(hono.SSEEvent{
+ data: 'Stream completed'
+ event: 'complete'
+ id: '999'
+ })!
+ })
+ })
+
+ app.listen(':3000')
+}
+```
+
+#### SSE with Multi-line Data
+
+```v
+import meiseayoung.hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ app.get('/sse-json', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Send JSON data (multi-line formatted)
+ json_data := '{\n "name": "v-hono",\n "version": "1.0.0"\n}'
+ stream.write_sse(hono.SSEEvent{
+ data: json_data
+ event: 'json'
+ id: '1'
+ })!
+ })
+ })
+
+ app.listen(':3000')
+}
+```
+
+#### SSE with Error Handling
+
+```v
+import meiseayoung.hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ app.get('/sse-error', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ stream.write_sse(hono.SSEEvent{
+ data: 'Starting...'
+ event: 'start'
+ })!
+
+ // Simulate an error
+ return error('Something went wrong')
+ }, fn (err IError, mut stream hono.StreamContext) {
+ // Custom error handler
+ stream.write_sse(hono.SSEEvent{
+ data: 'Error: ${err.msg()}'
+ event: 'error'
+ }) or {}
+ })
+ })
+
+ app.listen(':3000')
+}
+```
+
+#### SSE with Retry Field
+
+```v
+import meiseayoung.hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ app.get('/sse-retry', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Send event with retry field (client reconnects after 3 seconds)
+ stream.write_sse(hono.SSEEvent{
+ data: 'This event includes a retry field'
+ event: 'message'
+ id: '1'
+ retry: 3000 // 3 seconds
+ })!
+ })
+ })
+
+ app.listen(':3000')
+}
+```
+
+#### StreamContext Methods
+
+| Method | Description |
+|--------|-------------|
+| `write(data []u8)` | Write raw bytes to stream |
+| `write_string(data string)` | Write string to stream |
+| `writeln(data string)` | Write string with newline |
+| `write_sse(event SSEEvent)` | Write SSE event |
+| `sleep(ms int)` | Pause execution for milliseconds |
+| `pipe(data []u8)` | Pipe data to stream |
+| `on_abort(callback fn())` | Register abort callback |
+| `close()` | Close the stream |
+| `is_open()` | Check if stream is still open |
+
+#### SSEEvent Fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `data` | `string` | Yes | Event data (supports multi-line) |
+| `event` | `string` | No | Event type name |
+| `id` | `string` | No | Event ID for client reconnection |
+| `retry` | `int` | No | Reconnection interval in milliseconds |
+
+#### Streaming Functions
+
+| Function | Headers Set | Use Case |
+|----------|-------------|----------|
+| `c_stream()` | `Transfer-Encoding: chunked` | Binary data streaming |
+| `c_stream_text()` | `Content-Type: text/plain`, `Transfer-Encoding: chunked`, `X-Content-Type-Options: nosniff` | Text streaming |
+| `c_stream_sse()` | `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive` | Server-Sent Events |
+
+#### Client-Side JavaScript Example
+
+```html
+
+```
+
## Route Grouping
```v
@@ -889,8 +1135,9 @@ v-hono/
├── rate_limit.v # Rate limiting middleware
├── validator.v # Request validation
├── websocket.v # WebSocket helper (RFC 6455)
-├── picoev_server.v # Picoev backend with WebSocket support
-├── usockets_server.v # uSockets backend with WebSocket support
+├── streaming.v # SSE streaming helper
+├── picoev_server.v # Picoev backend with WebSocket/SSE support
+├── usockets_server.v # uSockets backend with WebSocket/SSE support
├── v.mod # Module definition
├── README.md # Documentation
├── examples/ # Example applications
diff --git a/examples/sse_demo.v b/examples/sse_demo.v
new file mode 100644
index 0000000..17b7bc6
--- /dev/null
+++ b/examples/sse_demo.v
@@ -0,0 +1,554 @@
+// SSE Streaming Demo - Server-Sent Events Example for v-hono
+// This example demonstrates all SSE streaming helper functions:
+// - stream() - Basic binary streaming
+// - stream_text() - Text streaming
+// - stream_sse() - Server-Sent Events streaming
+//
+// Run with: v -enable-globals run examples/sse_demo.v
+// Then open a browser and navigate to http://127.0.0.1:3000
+// Or use curl to test the endpoints:
+// curl http://127.0.0.1:3000/stream
+// curl http://127.0.0.1:3000/stream-text
+// curl http://127.0.0.1:3000/sse
+//
+// Features demonstrated:
+// - Basic binary streaming with stream()
+// - Text streaming with stream_text()
+// - SSE event streaming with stream_sse()
+// - Error handling with custom error handlers
+// - SSE event fields (data, event, id, retry)
+// - Multi-line data in SSE events
+// - Sleep/delay between stream writes
+// - onAbort callback for client disconnection
+module main
+
+import net.http
+import os
+import hono
+
+fn main() {
+ mut app := hono.Hono.new()
+
+ // =========================================================================
+ // Static HTML page for testing SSE
+ // =========================================================================
+
+ app.get('/', fn (mut c hono.Context) http.Response {
+ return c.html(get_sse_test_page())
+ })
+
+ // =========================================================================
+ // Basic Binary Streaming Example
+ // =========================================================================
+
+ // Basic stream - demonstrates binary data streaming
+ // Sets Transfer-Encoding: chunked header
+ app.get('/stream', fn (mut c hono.Context) http.Response {
+ return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
+ // Register abort callback for client disconnection
+ stream.on_abort(fn () {
+ println('[Stream] Client disconnected')
+ })
+
+ // Stream binary data in chunks
+ for i in 0 .. 5 {
+ data := 'Chunk ${i + 1}: Hello from binary stream!\n'
+ stream.write(data.bytes())!
+ stream.sleep(500) // 500ms delay between chunks
+ }
+
+ stream.write('Stream complete!\n'.bytes())!
+ })
+ })
+
+ // =========================================================================
+ // Text Streaming Example
+ // =========================================================================
+
+ // Text stream - demonstrates text data streaming
+ // Sets Content-Type: text/plain; charset=utf-8
+ // Sets Transfer-Encoding: chunked
+ // Sets X-Content-Type-Options: nosniff
+ app.get('/stream-text', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
+ // Register abort callback
+ stream.on_abort(fn () {
+ println('[StreamText] Client disconnected')
+ })
+
+ // Stream text data with writeln (adds newline automatically)
+ stream.writeln('=== Text Streaming Demo ===')!
+ stream.sleep(300)
+
+ for i in 0 .. 5 {
+ stream.write_string('Processing item ${i + 1}... ')!
+ stream.sleep(200)
+ stream.writeln('Done!')!
+ stream.sleep(300)
+ }
+
+ stream.writeln('')!
+ stream.writeln('All items processed successfully!')!
+ })
+ })
+
+ // =========================================================================
+ // SSE (Server-Sent Events) Streaming Example
+ // =========================================================================
+
+ // SSE stream - demonstrates Server-Sent Events
+ // Sets Content-Type: text/event-stream
+ // Sets Cache-Control: no-cache
+ // Sets Connection: keep-alive
+ app.get('/sse', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Register abort callback
+ stream.on_abort(fn () {
+ println('[SSE] Client disconnected')
+ })
+
+ // Send initial connection event
+ stream.write_sse(hono.SSEEvent{
+ data: 'Connected to SSE stream'
+ event: 'connect'
+ id: '0'
+ })!
+
+ // Send periodic updates
+ for i in 1 .. 6 {
+ stream.sleep(1000) // 1 second delay
+
+ stream.write_sse(hono.SSEEvent{
+ data: 'Update ${i}: Current time is ${get_timestamp()}'
+ event: 'update'
+ id: '${i}'
+ })!
+ }
+
+ // Send completion event
+ stream.write_sse(hono.SSEEvent{
+ data: 'Stream completed'
+ event: 'complete'
+ id: '999'
+ })!
+ })
+ })
+
+ // =========================================================================
+ // SSE with Multi-line Data Example
+ // =========================================================================
+
+ // SSE with multi-line data - demonstrates multi-line data handling
+ app.get('/sse-multiline', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Send event with multi-line data
+ stream.write_sse(hono.SSEEvent{
+ data: 'Line 1: First line of data\nLine 2: Second line of data\nLine 3: Third line of data'
+ event: 'multiline'
+ id: '1'
+ })!
+
+ stream.sleep(1000)
+
+ // Send JSON data (multi-line formatted)
+ json_data := '{\n "name": "v-hono",\n "version": "1.0.0",\n "features": ["SSE", "WebSocket", "Streaming"]\n}'
+ stream.write_sse(hono.SSEEvent{
+ data: json_data
+ event: 'json'
+ id: '2'
+ })!
+ })
+ })
+
+ // =========================================================================
+ // SSE with Retry Field Example
+ // =========================================================================
+
+ // SSE with retry - demonstrates retry field for reconnection
+ app.get('/sse-retry', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Send event with retry field (tells client to reconnect after 3 seconds)
+ stream.write_sse(hono.SSEEvent{
+ data: 'This event includes a retry field'
+ event: 'message'
+ id: '1'
+ retry: 3000 // 3 seconds
+ })!
+
+ stream.sleep(1000)
+
+ // Send another event without retry
+ stream.write_sse(hono.SSEEvent{
+ data: 'This event does not include retry'
+ event: 'message'
+ id: '2'
+ })!
+ })
+ })
+
+ // =========================================================================
+ // SSE with Error Handling Example
+ // =========================================================================
+
+ // SSE with error handling - demonstrates custom error handler
+ app.get('/sse-error', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ // Send initial event
+ stream.write_sse(hono.SSEEvent{
+ data: 'Starting stream with potential error...'
+ event: 'start'
+ id: '1'
+ })!
+
+ stream.sleep(1000)
+
+ // Simulate an error condition
+ // In real applications, this could be a database error, API failure, etc.
+ should_error := true
+ if should_error {
+ return error('Simulated error for demonstration')
+ }
+
+ // This won't be reached due to the error above
+ stream.write_sse(hono.SSEEvent{
+ data: 'This message will not be sent'
+ event: 'message'
+ id: '2'
+ })!
+ }, fn (err IError, mut stream hono.StreamContext) {
+ // Custom error handler - send error event to client
+ println('[SSE Error Handler] Error occurred: ${err.msg()}')
+
+ // Try to send error event to client before closing
+ stream.write_sse(hono.SSEEvent{
+ data: 'Error: ${err.msg()}'
+ event: 'error'
+ id: 'error'
+ }) or {
+ println('[SSE Error Handler] Failed to send error event: ${err}')
+ }
+ })
+ })
+
+ // =========================================================================
+ // Real-time Counter Example
+ // =========================================================================
+
+ // Real-time counter - demonstrates continuous SSE updates
+ app.get('/sse-counter', fn (mut c hono.Context) http.Response {
+ return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+ mut counter := 0
+
+ // Send counter updates every 500ms for 20 iterations
+ for _ in 0 .. 20 {
+ if !stream.is_open() {
+ println('[Counter] Stream closed, stopping')
+ break
+ }
+
+ counter++
+ stream.write_sse(hono.SSEEvent{
+ data: '${counter}'
+ event: 'counter'
+ id: '${counter}'
+ })!
+
+ stream.sleep(500)
+ }
+
+ // Send completion event
+ stream.write_sse(hono.SSEEvent{
+ data: 'Counter finished at ${counter}'
+ event: 'complete'
+ id: 'done'
+ })!
+ })
+ })
+
+ // =========================================================================
+ // Pipe Example - Stream file content
+ // =========================================================================
+
+ // Pipe example - demonstrates piping data to stream
+ app.get('/stream-pipe', fn (mut c hono.Context) http.Response {
+ return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
+ // Create some sample data to pipe
+ sample_data := 'This is sample data that will be piped to the stream.\n'.repeat(5)
+
+ // Pipe the data in chunks
+ chunk_size := 50
+ mut offset := 0
+
+ for offset < sample_data.len {
+ end := if offset + chunk_size > sample_data.len { sample_data.len } else { offset + chunk_size }
+ chunk := sample_data[offset..end]
+ stream.pipe(chunk.bytes())!
+ offset = end
+ stream.sleep(100)
+ }
+ })
+ })
+
+ // =========================================================================
+ // REST API endpoints for demonstration
+ // =========================================================================
+
+ app.get('/api/status', fn (mut c hono.Context) http.Response {
+ return c.json('{"status":"running","streaming_endpoints":["/stream","/stream-text","/sse","/sse-multiline","/sse-retry","/sse-error","/sse-counter","/stream-pipe"]}')
+ })
+
+ // =========================================================================
+ // Start the server
+ // =========================================================================
+
+ println('============================================================')
+ println('SSE Streaming Demo Server')
+ println('============================================================')
+ println('')
+ println('Server starting on http://127.0.0.1:3000')
+ println('')
+ println('Streaming Endpoints:')
+ println(' http://127.0.0.1:3000/stream - Basic binary streaming')
+ println(' http://127.0.0.1:3000/stream-text - Text streaming')
+ println(' http://127.0.0.1:3000/sse - SSE event streaming')
+ println(' http://127.0.0.1:3000/sse-multiline - SSE with multi-line data')
+ println(' http://127.0.0.1:3000/sse-retry - SSE with retry field')
+ println(' http://127.0.0.1:3000/sse-error - SSE with error handling')
+ println(' http://127.0.0.1:3000/sse-counter - Real-time counter')
+ println(' http://127.0.0.1:3000/stream-pipe - Pipe data to stream')
+ println('')
+ println('HTTP Endpoints:')
+ println(' http://127.0.0.1:3000/ - Test page')
+ println(' http://127.0.0.1:3000/api/status - Server status')
+ println('')
+ println('Test with curl:')
+ println(' curl http://127.0.0.1:3000/stream')
+ println(' curl http://127.0.0.1:3000/stream-text')
+ println(' curl http://127.0.0.1:3000/sse')
+ println('')
+ println('============================================================')
+
+ app.listen(':3000')
+}
+
+// get_timestamp - Get current timestamp string
+fn get_timestamp() string {
+ return '${os.execute("echo %TIME%").output.trim_space()}'
+}
+
+
+
+// get_sse_test_page - Returns HTML test page for SSE streaming
+fn get_sse_test_page() string {
+ sq := "'"
+ return '
+
+
+v-hono SSE Streaming Demo
+
+
+
+v-hono SSE Streaming Demo
+Test Server-Sent Events and streaming responses
+
+
+
+
SSE Event Stream
+
+ /sse
+
+
+ Disconnected
+
+
+
+
+
+
+
Multi-line SSE
+
+ /sse-multiline
+
+
+ Disconnected
+
+
+
+
+
+
+
Real-time Counter
+
+ /sse-counter
+
+
+ Disconnected
+
+
+
+
+
+
+
Error Handling
+
+ /sse-error
+
+
+ Disconnected
+
+
+
+
+
+
+
Text Stream
+
+ /stream-text
+
+ Ready
+
+
+
+
+
+
+
Binary Stream
+
+ /stream
+
+ Ready
+
+
+
+
+
+
+
+
+'
+}
diff --git a/picoev_server.v b/picoev_server.v
index 2977e93..f655caf 100644
--- a/picoev_server.v
+++ b/picoev_server.v
@@ -521,6 +521,13 @@ fn parse_http_method_fast(method string) http.Method {
// 发送 picoev 响应 - 优化版
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
+ // Check if this is a streaming response
+ if is_streaming_response(ctx) {
+ // Handle streaming response
+ handle_picoev_streaming_response(mut res, ctx, config)
+ return
+ }
+
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
if status_code == 200 {
@@ -570,6 +577,94 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
res.end()
}
+// ============================================================================
+// Streaming Response Handling for Picoev
+// ============================================================================
+
+// handle_picoev_streaming_response - Handle a streaming response using PicoevStreamWriter
+// This function:
+// 1. Sends the HTTP headers for streaming
+// 2. Creates a PicoevStreamWriter
+// 3. Executes the streaming callback
+// 4. Handles errors and cleanup
+fn handle_picoev_streaming_response(mut res picohttpparser.Response, ctx Context, config PicoevConfig) {
+ // Get stream configuration
+ stream_config := get_stream_config(ctx) or {
+ // Fallback to error response if stream config not found
+ res.raw('HTTP/1.1 500 Internal Server Error\r\n')
+ res.header('Content-Type', 'text/plain')
+ res.body('Stream configuration not found')
+ res.end()
+ return
+ }
+
+ // Determine status code
+ status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
+
+ // Send HTTP response line
+ if status_code == 200 {
+ res.http_ok()
+ } else {
+ res.raw('HTTP/1.1 ${status_code} ${get_status_text(status_code)}\r\n')
+ }
+
+ // Send headers from context
+ mut has_transfer_encoding := false
+ mut has_connection := false
+
+ for key, value in ctx.headers {
+ key_len := key.len
+ // Skip Content-Length for streaming (we use chunked encoding)
+ if key_len == 14 && eq_ignore_case(key, 'content-length') {
+ continue
+ }
+ res.header(key, value)
+ if key_len == 17 && eq_ignore_case(key, 'transfer-encoding') {
+ has_transfer_encoding = true
+ } else if key_len == 10 && eq_ignore_case(key, 'connection') {
+ has_connection = true
+ }
+ }
+
+ // Ensure Transfer-Encoding: chunked is set
+ if !has_transfer_encoding {
+ res.header('Transfer-Encoding', 'chunked')
+ }
+
+ // Ensure Connection header is set for streaming
+ if !has_connection {
+ res.header('Connection', 'keep-alive')
+ res.header('Keep-Alive', 'timeout=${config.timeout_secs}')
+ }
+
+ // End headers section (empty body to signal headers are complete)
+ // Note: We don't call res.body() or res.end() here because we're streaming
+ res.raw('\r\n')
+
+ // Create PicoevStreamWriter for streaming
+ mut writer := PicoevStreamWriter.new(res)
+
+ // Create StreamContext with the writer
+ mut stream_ctx := StreamContext.new(writer)
+
+ // Execute the streaming callback
+ stream_config.callback(mut stream_ctx) or {
+ // Handle error
+ if error_handler := stream_config.error_handler {
+ error_handler(err, mut stream_ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[Picoev Stream Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ stream_ctx.close()
+
+ // Cleanup the stored configuration
+ cleanup_stream_config(ctx)
+}
+
// 获取状态码文本
fn get_status_text(code int) string {
return match code {
diff --git a/streaming.v b/streaming.v
new file mode 100644
index 0000000..a1eac14
--- /dev/null
+++ b/streaming.v
@@ -0,0 +1,950 @@
+// streaming.v - SSE Streaming Helper for v-hono
+// Provides Server-Sent Events (SSE) and streaming response support
+// Reference: Hono.js Streaming Helper API
+module hono
+
+import net.http
+import time
+import picohttpparser
+import usockets
+
+// ============================================================================
+// SSE Event Structure
+// ============================================================================
+
+// SSEEvent - Server-Sent Events event structure
+// Used with stream_sse() to send formatted SSE events to clients
+pub struct SSEEvent {
+pub:
+ data string // Event data (required) - supports multi-line
+ event string // Event type name (optional)
+ id string // Event ID for client reconnection (optional)
+ retry int // Reconnection time in milliseconds (optional, 0 = not set)
+}
+
+// ============================================================================
+// StreamWriter Interface
+// ============================================================================
+
+// StreamWriter - Abstract interface for stream writing
+// Allows different server backends (picoev, uSockets) to implement their own writers
+pub interface StreamWriter {
+ // Check if the connection is still active (immutable)
+ is_connected() bool
+mut:
+ // Write raw bytes to the stream
+ write(data []u8) !
+ // Write string to the stream
+ write_string(data string) !
+ // Flush the buffer to ensure data is sent
+ flush() !
+ // Close the stream connection
+ close() !
+}
+
+// ============================================================================
+// StreamContext Structure
+// ============================================================================
+
+// StreamContext - Context provided to streaming callbacks
+// Provides methods for writing data, SSE events, and managing the stream
+@[heap]
+pub struct StreamContext {
+mut:
+ writer &StreamWriter
+ is_closed bool
+ abort_fn ?fn ()
+}
+
+// StreamContext.new - Create a new StreamContext with the given writer
+pub fn StreamContext.new(writer &StreamWriter) StreamContext {
+ return StreamContext{
+ writer: unsafe { writer }
+ is_closed: false
+ abort_fn: none
+ }
+}
+
+// write - Write raw bytes to the stream
+pub fn (mut ctx StreamContext) write(data []u8) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+ ctx.writer.write(data)!
+}
+
+// write_string - Write a string to the stream
+pub fn (mut ctx StreamContext) write_string(data string) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+ ctx.writer.write_string(data)!
+}
+
+// writeln - Write a string followed by a newline character
+pub fn (mut ctx StreamContext) writeln(data string) ! {
+ ctx.write_string(data)!
+ ctx.write_string('\n')!
+}
+
+
+// write_sse - Write an SSE event to the stream
+// Formats the event according to the SSE specification:
+// - event: {type}\n (if event field is set)
+// - data: {line}\n (for each line of data)
+// - id: {id}\n (if id field is set)
+// - retry: {ms}\n (if retry > 0)
+// - \n (event separator)
+pub fn (mut ctx StreamContext) write_sse(event SSEEvent) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+
+ // Write event type if specified
+ if event.event.len > 0 {
+ ctx.writer.write_string('event: ${event.event}\n')!
+ }
+
+ // Write data field - handle multi-line data
+ lines := event.data.split('\n')
+ for line in lines {
+ ctx.writer.write_string('data: ${line}\n')!
+ }
+
+ // Write id if specified
+ if event.id.len > 0 {
+ ctx.writer.write_string('id: ${event.id}\n')!
+ }
+
+ // Write retry if specified (must be > 0)
+ if event.retry > 0 {
+ ctx.writer.write_string('retry: ${event.retry}\n')!
+ }
+
+ // Write event separator (empty line)
+ ctx.writer.write_string('\n')!
+
+ // Flush to ensure event is sent immediately
+ ctx.writer.flush()!
+}
+
+// sleep - Pause execution for the specified milliseconds
+pub fn (ctx StreamContext) sleep(ms int) {
+ time.sleep(ms * time.millisecond)
+}
+
+// pipe - Pipe data from a byte array to the stream
+pub fn (mut ctx StreamContext) pipe(data []u8) ! {
+ ctx.write(data)!
+}
+
+// on_abort - Register a callback to be called when the client aborts the connection
+pub fn (mut ctx StreamContext) on_abort(callback fn ()) {
+ ctx.abort_fn = callback
+}
+
+// close - Close the stream
+pub fn (mut ctx StreamContext) close() {
+ if !ctx.is_closed {
+ ctx.is_closed = true
+ ctx.writer.close() or {}
+ }
+}
+
+// is_open - Check if the stream is still open
+pub fn (ctx StreamContext) is_open() bool {
+ return !ctx.is_closed && ctx.writer.is_connected()
+}
+
+// trigger_abort - Internal method to trigger the abort callback
+// Called by server backends when client disconnects
+pub fn (mut ctx StreamContext) trigger_abort() {
+ if abort_callback := ctx.abort_fn {
+ abort_callback()
+ }
+ ctx.is_closed = true
+}
+
+
+// ============================================================================
+// Picoev StreamWriter Implementation
+// ============================================================================
+
+// PicoevStreamWriter - StreamWriter implementation for picoev server
+// Supports chunked transfer encoding for streaming responses
+@[heap]
+pub struct PicoevStreamWriter {
+mut:
+ res &picohttpparser.Response
+ connected bool
+}
+
+// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response
+pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter {
+ return &PicoevStreamWriter{
+ res: unsafe { res }
+ connected: true
+ }
+}
+
+// write - Write raw bytes to the stream using chunked transfer encoding
+// Format: {hex_size}\r\n{data}\r\n
+pub fn (mut w PicoevStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := format_chunk_size(data.len)
+ w.res.raw(chunk_header)
+ w.res.raw(data.bytestr())
+ w.res.raw('\r\n')
+}
+
+// write_string - Write a string to the stream using chunked transfer encoding
+pub fn (mut w PicoevStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := format_chunk_size(data.len)
+ w.res.raw(chunk_header)
+ w.res.raw(data)
+ w.res.raw('\r\n')
+}
+
+// flush - Flush the buffer (picoev handles this automatically)
+pub fn (mut w PicoevStreamWriter) flush() ! {
+ // picoev automatically flushes data
+ // No explicit flush needed
+}
+
+// close - Close the stream by sending the final chunk
+// Final chunk format: 0\r\n\r\n
+pub fn (mut w PicoevStreamWriter) close() ! {
+ if w.connected {
+ // Write the final chunk (zero-length chunk) to signal end of stream
+ w.res.raw('0\r\n\r\n')
+ w.res.end()
+ w.connected = false
+ }
+}
+
+// is_connected - Check if the connection is still active
+pub fn (w PicoevStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+// ============================================================================
+// uSockets StreamWriter Implementation
+// ============================================================================
+
+// UsocketsStreamWriter - StreamWriter implementation for uSockets server
+// Supports chunked transfer encoding for streaming responses
+@[heap]
+pub struct UsocketsStreamWriter {
+mut:
+ socket usockets.Socket
+ connected bool
+}
+
+// UsocketsStreamWriter.new - Create a new UsocketsStreamWriter with the given socket
+pub fn UsocketsStreamWriter.new(socket usockets.Socket) &UsocketsStreamWriter {
+ return &UsocketsStreamWriter{
+ socket: socket
+ connected: true
+ }
+}
+
+// write - Write raw bytes to the stream using chunked transfer encoding
+// Format: {hex_size}\r\n{data}\r\n
+pub fn (mut w UsocketsStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := format_chunk_size(data.len)
+ w.socket.write_bytes(chunk_header)
+ w.socket.write_bytes(data.bytestr())
+ w.socket.write_bytes('\r\n')
+}
+
+// write_string - Write a string to the stream using chunked transfer encoding
+pub fn (mut w UsocketsStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := format_chunk_size(data.len)
+ w.socket.write_bytes(chunk_header)
+ w.socket.write_bytes(data)
+ w.socket.write_bytes('\r\n')
+}
+
+// flush - Flush the buffer (uSockets handles this automatically)
+pub fn (mut w UsocketsStreamWriter) flush() ! {
+ // uSockets automatically flushes data
+ // No explicit flush needed
+}
+
+// close - Close the stream by sending the final chunk
+// Final chunk format: 0\r\n\r\n
+pub fn (mut w UsocketsStreamWriter) close() ! {
+ if w.connected {
+ // Write the final chunk (zero-length chunk) to signal end of stream
+ w.socket.write_bytes('0\r\n\r\n')
+ w.connected = false
+ }
+}
+
+// is_connected - Check if the connection is still active
+pub fn (w UsocketsStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+// mark_disconnected - Mark the connection as disconnected
+// Called by server backends when client disconnects
+pub fn (mut w UsocketsStreamWriter) mark_disconnected() {
+ w.connected = false
+}
+
+// ============================================================================
+// Helper Functions for Chunked Encoding
+// ============================================================================
+
+// format_chunk_size - Format the chunk size as hex followed by CRLF
+// This is used for HTTP chunked transfer encoding
+@[inline]
+fn format_chunk_size(size int) string {
+ return '${size:x}\r\n'
+}
+
+// format_chunk - Format a complete chunk with size, data, and trailing CRLF
+// Returns the full chunk in chunked transfer encoding format
+pub fn format_chunk(data []u8) string {
+ if data.len == 0 {
+ return ''
+ }
+ return '${data.len:x}\r\n${data.bytestr()}\r\n'
+}
+
+// format_chunk_string - Format a string as a complete chunk
+pub fn format_chunk_string(data string) string {
+ if data.len == 0 {
+ return ''
+ }
+ return '${data.len:x}\r\n${data}\r\n'
+}
+
+// format_final_chunk - Return the final chunk marker for chunked encoding
+pub fn format_final_chunk() string {
+ return '0\r\n\r\n'
+}
+
+// ============================================================================
+// Streaming Helper Functions
+// ============================================================================
+
+// StreamCallback - Callback function type for streaming
+// The callback receives a mutable StreamContext and can write data to the stream
+pub type StreamCallback = fn (mut StreamContext) !
+
+// StreamErrorHandler - Error handler function type for streaming
+// Called when an error occurs during streaming
+pub type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
+
+// StreamResponse - Response type for streaming operations
+// Contains the headers that should be sent before streaming begins
+pub struct StreamResponse {
+pub:
+ status_code int = 200
+ headers map[string]string
+ is_stream bool = true
+}
+
+// stream - Basic binary streaming function
+// Sets Transfer-Encoding: chunked and executes the callback with a StreamContext
+//
+// Parameters:
+// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
+// callback: The callback function that writes data to the stream
+// error_handler: Optional error handler called if an error occurs
+//
+// Returns:
+// StreamResponse with the appropriate headers
+//
+// Example:
+// stream(writer, fn (mut stream) ! {
+// stream.write('Hello'.bytes())!
+// stream.sleep(100)
+// stream.write('World'.bytes())!
+// })
+pub fn stream(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
+ // Create StreamContext with the writer
+ mut ctx := StreamContext.new(writer)
+
+ // Execute the callback and handle errors
+ callback(mut ctx) or {
+ // Handle error
+ if error_handler.len > 0 {
+ // Call custom error handler
+ error_handler[0](err, mut ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[SSE Stream Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ ctx.close()
+
+ // Return response headers for streaming
+ return StreamResponse{
+ status_code: 200
+ headers: {
+ 'Transfer-Encoding': 'chunked'
+ 'Connection': 'keep-alive'
+ }
+ is_stream: true
+ }
+}
+
+// stream_with_headers - Stream with custom initial headers
+// Allows setting additional headers before streaming begins
+pub fn stream_with_headers(mut writer StreamWriter, headers map[string]string, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
+ // Create StreamContext with the writer
+ mut ctx := StreamContext.new(writer)
+
+ // Execute the callback and handle errors
+ callback(mut ctx) or {
+ // Handle error
+ if error_handler.len > 0 {
+ // Call custom error handler
+ error_handler[0](err, mut ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[SSE Stream Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ ctx.close()
+
+ // Merge headers with required streaming headers
+ mut response_headers := headers.clone()
+ response_headers['Transfer-Encoding'] = 'chunked'
+ if 'Connection' !in response_headers {
+ response_headers['Connection'] = 'keep-alive'
+ }
+
+ return StreamResponse{
+ status_code: 200
+ headers: response_headers
+ is_stream: true
+ }
+}
+
+// get_stream_headers - Get the headers required for basic streaming
+// Returns a map of headers that should be set for chunked transfer encoding
+pub fn get_stream_headers() map[string]string {
+ return {
+ 'Transfer-Encoding': 'chunked'
+ 'Connection': 'keep-alive'
+ }
+}
+
+// write_stream_headers - Write HTTP headers for streaming response
+// This is a helper function to write the initial HTTP response line and headers
+// before starting chunked data transfer
+//
+// Parameters:
+// writer: The StreamWriter to write headers to
+// status_code: HTTP status code (default 200)
+// headers: Map of header name to value
+pub fn write_stream_headers(mut writer StreamWriter, status_code int, headers map[string]string) ! {
+ // Build HTTP response line
+ status_text := get_status_text(status_code)
+ mut response := 'HTTP/1.1 ${status_code} ${status_text}\r\n'
+
+ // Add headers
+ for key, value in headers {
+ response += '${key}: ${value}\r\n'
+ }
+
+ // Add required streaming headers if not present
+ if 'Transfer-Encoding' !in headers {
+ response += 'Transfer-Encoding: chunked\r\n'
+ }
+ if 'Connection' !in headers {
+ response += 'Connection: keep-alive\r\n'
+ }
+
+ // End headers section
+ response += '\r\n'
+
+ // Write headers directly (not chunked)
+ // Note: Headers are written raw, not using chunked encoding
+ // The chunked encoding starts after the headers
+ writer.write_string(response)!
+}
+
+// stream_text - Text streaming function
+// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
+// and X-Content-Type-Options: nosniff headers, then executes the callback
+// with a StreamContext for text streaming.
+//
+// Parameters:
+// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
+// callback: The callback function that writes text data to the stream
+// error_handler: Optional error handler called if an error occurs
+//
+// Returns:
+// StreamResponse with the appropriate headers for text streaming
+//
+// Example:
+// stream_text(writer, fn (mut stream) ! {
+// stream.write_string('Hello ')!
+// stream.sleep(100)
+// stream.writeln('World')!
+// })
+pub fn stream_text(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
+ // Create StreamContext with the writer
+ mut ctx := StreamContext.new(writer)
+
+ // Execute the callback and handle errors
+ callback(mut ctx) or {
+ // Handle error
+ if error_handler.len > 0 {
+ // Call custom error handler
+ error_handler[0](err, mut ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[SSE StreamText Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ ctx.close()
+
+ // Return response headers for text streaming
+ return StreamResponse{
+ status_code: 200
+ headers: {
+ 'Content-Type': 'text/plain; charset=utf-8'
+ 'Transfer-Encoding': 'chunked'
+ 'X-Content-Type-Options': 'nosniff'
+ 'Connection': 'keep-alive'
+ }
+ is_stream: true
+ }
+}
+
+// get_stream_text_headers - Get the headers required for text streaming
+// Returns a map of headers that should be set for text streaming with chunked transfer encoding
+pub fn get_stream_text_headers() map[string]string {
+ return {
+ 'Content-Type': 'text/plain; charset=utf-8'
+ 'Transfer-Encoding': 'chunked'
+ 'X-Content-Type-Options': 'nosniff'
+ 'Connection': 'keep-alive'
+ }
+}
+
+// stream_sse - Server-Sent Events streaming function
+// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
+// and Connection: keep-alive headers, then executes the callback
+// with a StreamContext for SSE streaming.
+//
+// Parameters:
+// writer: The StreamWriter implementation (PicoevStreamWriter or UsocketsStreamWriter)
+// callback: The callback function that writes SSE events to the stream
+// error_handler: Optional error handler called if an error occurs
+//
+// Returns:
+// StreamResponse with the appropriate headers for SSE streaming
+//
+// Example:
+// stream_sse(writer, fn (mut stream) ! {
+// stream.write_sse(SSEEvent{
+// data: 'Hello World'
+// event: 'message'
+// id: '1'
+// })!
+// stream.sleep(1000)
+// stream.write_sse(SSEEvent{
+// data: 'Another message'
+// event: 'update'
+// id: '2'
+// })!
+// })
+pub fn stream_sse(mut writer StreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
+ // Create StreamContext with the writer
+ mut ctx := StreamContext.new(writer)
+
+ // Execute the callback and handle errors
+ callback(mut ctx) or {
+ // Handle error
+ if error_handler.len > 0 {
+ // Call custom error handler
+ error_handler[0](err, mut ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[SSE StreamSSE Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ ctx.close()
+
+ // Return response headers for SSE streaming
+ // Requirements 3.1, 3.2, 3.3:
+ // - Content-Type: text/event-stream
+ // - Cache-Control: no-cache
+ // - Connection: keep-alive
+ return StreamResponse{
+ status_code: 200
+ headers: {
+ 'Content-Type': 'text/event-stream'
+ 'Cache-Control': 'no-cache'
+ 'Connection': 'keep-alive'
+ 'Transfer-Encoding': 'chunked'
+ }
+ is_stream: true
+ }
+}
+
+// get_stream_sse_headers - Get the headers required for SSE streaming
+// Returns a map of headers that should be set for Server-Sent Events streaming
+pub fn get_stream_sse_headers() map[string]string {
+ return {
+ 'Content-Type': 'text/event-stream'
+ 'Cache-Control': 'no-cache'
+ 'Connection': 'keep-alive'
+ 'Transfer-Encoding': 'chunked'
+ }
+}
+
+
+// ============================================================================
+// Context-Based Streaming Functions
+// ============================================================================
+// These functions are designed to be used in route handlers and integrate
+// with the picoev and uSockets server backends.
+
+// StreamType - Type of streaming response
+pub enum StreamType {
+ basic // Basic binary streaming
+ text // Text streaming with text/plain content type
+ sse // Server-Sent Events streaming
+}
+
+// StreamConfig - Configuration stored in Context for streaming responses
+// This is used internally by the server backends to detect and handle streaming
+pub struct StreamConfig {
+pub:
+ stream_type StreamType
+ callback StreamCallback = unsafe { nil }
+ error_handler ?StreamErrorHandler
+}
+
+// Global storage for stream callbacks (workaround for V's limitations with storing functions in maps)
+// Key is a unique identifier stored in Context.store['_stream_id']
+// Using shared map for thread-safe access
+struct StreamCallbackStorage {
+mut:
+ callbacks shared map[string]StreamConfig
+}
+
+// Module-level storage instance
+const stream_storage = &StreamCallbackStorage{}
+
+// store_stream_config - Store a stream configuration
+fn store_stream_config(id string, config StreamConfig) {
+ lock stream_storage.callbacks {
+ stream_storage.callbacks[id] = config
+ }
+}
+
+// retrieve_stream_config - Retrieve a stream configuration by ID
+fn retrieve_stream_config(id string) ?StreamConfig {
+ rlock stream_storage.callbacks {
+ if id in stream_storage.callbacks {
+ return stream_storage.callbacks[id]
+ }
+ }
+ return none
+}
+
+// remove_stream_config - Remove a stream configuration by ID
+fn remove_stream_config(id string) {
+ lock stream_storage.callbacks {
+ stream_storage.callbacks.delete(id)
+ }
+}
+
+// generate_stream_id - Generate a unique stream ID
+fn generate_stream_id() string {
+ return '${time.now().unix_milli()}_${rand_int()}'
+}
+
+// rand_int - Simple random integer generator
+fn rand_int() int {
+ return int(time.now().unix_nano() % 1000000)
+}
+
+// c_stream - Basic binary streaming function for use in route handlers
+// Sets Transfer-Encoding: chunked and stores the callback for execution by the server backend
+//
+// Parameters:
+// c: The request Context
+// callback: The callback function that writes data to the stream
+// error_handler: Optional error handler called if an error occurs
+//
+// Returns:
+// http.Response with streaming marker headers
+//
+// Example:
+// app.get('/stream', fn (mut c hono.Context) http.Response {
+// return hono.c_stream(mut c, fn (mut stream hono.StreamContext) ! {
+// stream.write('Hello'.bytes())!
+// stream.sleep(100)
+// stream.write('World'.bytes())!
+// })
+// })
+pub fn c_stream(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
+ // Generate unique stream ID
+ stream_id := generate_stream_id()
+
+ // Store stream configuration
+ config := StreamConfig{
+ stream_type: .basic
+ callback: callback
+ error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
+ }
+ store_stream_config(stream_id, config)
+
+ // Mark context as streaming
+ c.store['_stream'] = 'true'
+ c.store['_stream_id'] = stream_id
+ c.store['_stream_type'] = 'basic'
+
+ // Set streaming headers
+ c.headers['Transfer-Encoding'] = 'chunked'
+ c.headers['Connection'] = 'keep-alive'
+
+ // Return a marker response (body will be ignored, streaming will be handled by server)
+ mut headers := http.new_header()
+ headers.add_custom('Transfer-Encoding', 'chunked') or {}
+ headers.add_custom('Connection', 'keep-alive') or {}
+
+ return http.Response{
+ status_code: 200
+ header: headers
+ body: ''
+ }
+}
+
+// c_stream_text - Text streaming function for use in route handlers
+// Sets Content-Type: text/plain; charset=utf-8, Transfer-Encoding: chunked,
+// and X-Content-Type-Options: nosniff headers
+//
+// Parameters:
+// c: The request Context
+// callback: The callback function that writes text data to the stream
+// error_handler: Optional error handler called if an error occurs
+//
+// Returns:
+// http.Response with streaming marker headers
+//
+// Example:
+// app.get('/stream-text', fn (mut c hono.Context) http.Response {
+// return hono.c_stream_text(mut c, fn (mut stream hono.StreamContext) ! {
+// stream.write_string('Hello ')!
+// stream.sleep(100)
+// stream.writeln('World')!
+// })
+// })
+pub fn c_stream_text(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
+ // Generate unique stream ID
+ stream_id := generate_stream_id()
+
+ // Store stream configuration
+ config := StreamConfig{
+ stream_type: .text
+ callback: callback
+ error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
+ }
+ store_stream_config(stream_id, config)
+
+ // Mark context as streaming
+ c.store['_stream'] = 'true'
+ c.store['_stream_id'] = stream_id
+ c.store['_stream_type'] = 'text'
+
+ // Set streaming headers
+ c.headers['Content-Type'] = 'text/plain; charset=utf-8'
+ c.headers['Transfer-Encoding'] = 'chunked'
+ c.headers['X-Content-Type-Options'] = 'nosniff'
+ c.headers['Connection'] = 'keep-alive'
+
+ // Return a marker response
+ mut headers := http.new_header()
+ headers.add_custom('Content-Type', 'text/plain; charset=utf-8') or {}
+ headers.add_custom('Transfer-Encoding', 'chunked') or {}
+ headers.add_custom('X-Content-Type-Options', 'nosniff') or {}
+ headers.add_custom('Connection', 'keep-alive') or {}
+
+ return http.Response{
+ status_code: 200
+ header: headers
+ body: ''
+ }
+}
+
+// c_stream_sse - Server-Sent Events streaming function for use in route handlers
+// Sets Content-Type: text/event-stream, Cache-Control: no-cache,
+// and Connection: keep-alive headers
+//
+// Parameters:
+// c: The request Context
+// callback: The callback function that writes SSE events to the stream
+// error_handler: Optional error handler called if an error occurs
+//
+// Returns:
+// http.Response with streaming marker headers
+//
+// Example:
+// app.get('/sse', fn (mut c hono.Context) http.Response {
+// return hono.c_stream_sse(mut c, fn (mut stream hono.StreamContext) ! {
+// stream.write_sse(hono.SSEEvent{
+// data: 'Hello World'
+// event: 'message'
+// id: '1'
+// })!
+// stream.sleep(1000)
+// stream.write_sse(hono.SSEEvent{
+// data: 'Another message'
+// event: 'update'
+// id: '2'
+// })!
+// })
+// })
+pub fn c_stream_sse(mut c Context, callback StreamCallback, error_handler ...StreamErrorHandler) http.Response {
+ // Generate unique stream ID
+ stream_id := generate_stream_id()
+
+ // Store stream configuration
+ config := StreamConfig{
+ stream_type: .sse
+ callback: callback
+ error_handler: if error_handler.len > 0 { error_handler[0] } else { none }
+ }
+ store_stream_config(stream_id, config)
+
+ // Mark context as streaming
+ c.store['_stream'] = 'true'
+ c.store['_stream_id'] = stream_id
+ c.store['_stream_type'] = 'sse'
+
+ // Set SSE headers (Requirements 3.1, 3.2, 3.3)
+ c.headers['Content-Type'] = 'text/event-stream'
+ c.headers['Cache-Control'] = 'no-cache'
+ c.headers['Connection'] = 'keep-alive'
+ c.headers['Transfer-Encoding'] = 'chunked'
+
+ // Return a marker response
+ mut headers := http.new_header()
+ headers.add_custom('Content-Type', 'text/event-stream') or {}
+ headers.add_custom('Cache-Control', 'no-cache') or {}
+ headers.add_custom('Connection', 'keep-alive') or {}
+ headers.add_custom('Transfer-Encoding', 'chunked') or {}
+
+ return http.Response{
+ status_code: 200
+ header: headers
+ body: ''
+ }
+}
+
+// is_streaming_response - Check if a Context represents a streaming response
+// Used by server backends to detect streaming responses
+pub fn is_streaming_response(ctx Context) bool {
+ return '_stream' in ctx.store && ctx.store['_stream'] == 'true'
+}
+
+// get_stream_config - Get the stream configuration from Context
+// Returns the StreamConfig if this is a streaming response, none otherwise
+pub fn get_stream_config(ctx Context) ?StreamConfig {
+ if !is_streaming_response(ctx) {
+ return none
+ }
+
+ stream_id := ctx.store['_stream_id'] or { return none }
+ return retrieve_stream_config(stream_id)
+}
+
+// cleanup_stream_config - Remove stream configuration after use
+// Should be called by server backends after streaming is complete
+pub fn cleanup_stream_config(ctx Context) {
+ if stream_id := ctx.store['_stream_id'] {
+ remove_stream_config(stream_id)
+ }
+}
+
+// execute_stream - Execute a streaming response with the given writer
+// This is the main entry point for server backends to handle streaming
+//
+// Parameters:
+// ctx: The request Context with streaming configuration
+// writer: The StreamWriter implementation for the server backend
+//
+// Returns:
+// true if streaming was executed, false if not a streaming response
+pub fn execute_stream(ctx Context, mut writer StreamWriter) bool {
+ config := get_stream_config(ctx) or { return false }
+
+ // Create StreamContext with the writer
+ mut stream_ctx := StreamContext.new(writer)
+
+ // Execute the callback and handle errors
+ config.callback(mut stream_ctx) or {
+ // Handle error
+ if error_handler := config.error_handler {
+ error_handler(err, mut stream_ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[SSE Stream Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ stream_ctx.close()
+
+ // Cleanup the stored configuration
+ cleanup_stream_config(ctx)
+
+ return true
+}
diff --git a/tests/test_picoev_stream_writer_properties.v b/tests/test_picoev_stream_writer_properties.v
new file mode 100644
index 0000000..8229159
--- /dev/null
+++ b/tests/test_picoev_stream_writer_properties.v
@@ -0,0 +1,434 @@
+// test_picoev_stream_writer_properties.v
+// Property-Based Testing for PicoevStreamWriter chunked encoding
+// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
+// Validates: Requirements 6.2
+module main
+
+import rand
+import time
+
+const test_iterations = 100
+
+struct PropertyTestStats {
+mut:
+ total_tests int
+ passed_tests int
+ failed_tests int
+}
+
+fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
+ stats.total_tests++
+ print('🔬 ${test_name}... ')
+
+ if test_func() {
+ stats.passed_tests++
+ println('✅')
+ } else {
+ stats.failed_tests++
+ println('❌')
+ }
+}
+
+fn (stats PropertyTestStats) print_summary() {
+ println('\n=== PicoevStreamWriter 属性测试总结 ===')
+ println('总测试数: ${stats.total_tests}')
+ println('通过: ${stats.passed_tests}')
+ println('失败: ${stats.failed_tests}')
+
+ if stats.failed_tests == 0 {
+ println('🎉 所有属性测试通过!')
+ } else {
+ println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
+ }
+}
+
+// ============================================================================
+// Chunked Encoding Functions (copied from streaming.v for testing)
+// ============================================================================
+
+// format_chunk_size - Format the chunk size as hex followed by CRLF
+@[inline]
+fn format_chunk_size(size int) string {
+ return '${size:x}\r\n'
+}
+
+// format_chunk - Format a complete chunk with size, data, and trailing CRLF
+fn format_chunk(data []u8) string {
+ if data.len == 0 {
+ return ''
+ }
+ return '${data.len:x}\r\n${data.bytestr()}\r\n'
+}
+
+// format_chunk_string - Format a string as a complete chunk
+fn format_chunk_string(data string) string {
+ if data.len == 0 {
+ return ''
+ }
+ return '${data.len:x}\r\n${data}\r\n'
+}
+
+// format_final_chunk - Return the final chunk marker for chunked encoding
+fn format_final_chunk() string {
+ return '0\r\n\r\n'
+}
+
+// ============================================================================
+// Random Data Generators
+// ============================================================================
+
+// Generate random bytes for testing
+fn generate_random_bytes(max_len int) []u8 {
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut data := []u8{len: len}
+ for i in 0 .. len {
+ data[i] = u8(rand.int_in_range(0, 256) or { 0 })
+ }
+ return data
+}
+
+// Generate random string for testing (printable ASCII only to avoid encoding issues)
+fn generate_random_string(max_len int) string {
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut result := ''
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result += chars[idx].ascii_str()
+ }
+ return result
+}
+
+// ============================================================================
+// Chunk Parsing Helper
+// ============================================================================
+
+// Parse a chunked encoded string and extract the data
+// Returns the extracted data and whether parsing was successful
+fn parse_chunked_data(chunked string) !([]u8, bool) {
+ // Find the size part (before first \r\n)
+ mut crlf_pos := -1
+ for i := 0; i < chunked.len - 1; i++ {
+ if chunked[i] == `\r` && chunked[i + 1] == `\n` {
+ crlf_pos = i
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ return error('No CRLF found in chunk header')
+ }
+
+ size_hex := chunked[..crlf_pos]
+ size := parse_hex(size_hex) or {
+ return error('Invalid hex size: ${size_hex}')
+ }
+
+ // Extract data after the first \r\n
+ data_start := crlf_pos + 2
+ data_end := data_start + size
+
+ if data_end > chunked.len {
+ return error('Chunk data incomplete')
+ }
+
+ data := chunked[data_start..data_end].bytes()
+
+ // Verify trailing \r\n
+ if data_end + 2 > chunked.len {
+ return error('Missing trailing CRLF')
+ }
+
+ trailing := chunked[data_end..data_end + 2]
+ if trailing != '\r\n' {
+ return error('Invalid trailing CRLF: got "${trailing}"')
+ }
+
+ return data, true
+}
+
+// Parse hex string to int
+fn parse_hex(hex_str string) !int {
+ mut result := 0
+ for c in hex_str {
+ result *= 16
+ if c >= `0` && c <= `9` {
+ result += int(c - `0`)
+ } else if c >= `a` && c <= `f` {
+ result += int(c - `a` + 10)
+ } else if c >= `A` && c <= `F` {
+ result += int(c - `A` + 10)
+ } else {
+ return error('Invalid hex character: ${c.ascii_str()}')
+ }
+ }
+ return result
+}
+
+// ============================================================================
+// Property 10: Chunked 编码格式正确性
+// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
+// Validates: Requirements 6.2
+//
+// *For any* data written to the stream, the output SHALL follow HTTP chunked
+// transfer encoding format:
+// - Each chunk starts with the hex size followed by \r\n
+// - Chunk data followed by \r\n
+// - Final chunk is 0\r\n\r\n
+// ============================================================================
+
+// Test 10a: format_chunk produces valid chunked encoding for any byte array
+fn test_property_10a_format_chunk_bytes() bool {
+ rand.seed([u32(time.now().unix()), u32(12345)])
+
+ for i in 0 .. test_iterations {
+ // Generate random data
+ data := generate_random_bytes(1000)
+
+ // Format as chunk
+ chunk := format_chunk(data)
+
+ // Verify chunk format
+ // 1. Should start with hex size
+ mut crlf_pos := -1
+ for j := 0; j < chunk.len - 1; j++ {
+ if chunk[j] == `\r` && chunk[j + 1] == `\n` {
+ crlf_pos = j
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ println(' Iteration ${i}: No CRLF found in chunk header')
+ return false
+ }
+
+ size_hex := chunk[..crlf_pos]
+ parsed_size := parse_hex(size_hex) or {
+ println(' Iteration ${i}: Invalid hex size: ${size_hex}')
+ return false
+ }
+
+ // 2. Size should match data length
+ if parsed_size != data.len {
+ println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
+ return false
+ }
+
+ // 3. Data should be extractable and match original
+ extracted_data, _ := parse_chunked_data(chunk) or {
+ println(' Iteration ${i}: Failed to parse chunk: ${err}')
+ return false
+ }
+
+ if extracted_data != data {
+ println(' Iteration ${i}: Data mismatch after extraction')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 10b: format_chunk_string produces valid chunked encoding for any string
+fn test_property_10b_format_chunk_string() bool {
+ rand.seed([u32(time.now().unix()), u32(54321)])
+
+ for i in 0 .. test_iterations {
+ // Generate random string
+ data := generate_random_string(500)
+
+ // Format as chunk
+ chunk := format_chunk_string(data)
+
+ // Verify chunk format
+ mut crlf_pos := -1
+ for j := 0; j < chunk.len - 1; j++ {
+ if chunk[j] == `\r` && chunk[j + 1] == `\n` {
+ crlf_pos = j
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ println(' Iteration ${i}: No CRLF found in chunk header')
+ return false
+ }
+
+ size_hex := chunk[..crlf_pos]
+ parsed_size := parse_hex(size_hex) or {
+ println(' Iteration ${i}: Invalid hex size: ${size_hex}')
+ return false
+ }
+
+ // Size should match string byte length
+ if parsed_size != data.len {
+ println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
+ return false
+ }
+
+ // Extract and verify data
+ extracted_data, _ := parse_chunked_data(chunk) or {
+ println(' Iteration ${i}: Failed to parse chunk: ${err}')
+ return false
+ }
+
+ if extracted_data.bytestr() != data {
+ println(' Iteration ${i}: String mismatch after extraction')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 10c: format_final_chunk produces correct terminator
+fn test_property_10c_final_chunk_format() bool {
+ final_chunk := format_final_chunk()
+
+ // Final chunk must be exactly "0\r\n\r\n"
+ expected := '0\r\n\r\n'
+
+ if final_chunk != expected {
+ println(' Final chunk mismatch - expected "${expected}", got "${final_chunk}"')
+ return false
+ }
+
+ // Verify it starts with zero size
+ if !final_chunk.starts_with('0\r\n') {
+ println(' Final chunk should start with "0\\r\\n"')
+ return false
+ }
+
+ // Verify it ends with empty line (double CRLF after size)
+ if !final_chunk.ends_with('\r\n\r\n') {
+ println(' Final chunk should end with "\\r\\n\\r\\n"')
+ return false
+ }
+
+ return true
+}
+
+// Test 10d: Empty data produces empty string (no chunk)
+fn test_property_10d_empty_data_handling() bool {
+ // Empty byte array
+ empty_bytes := []u8{}
+ chunk_bytes := format_chunk(empty_bytes)
+ if chunk_bytes != '' {
+ println(' Empty bytes should produce empty string, got "${chunk_bytes}"')
+ return false
+ }
+
+ // Empty string
+ chunk_string := format_chunk_string('')
+ if chunk_string != '' {
+ println(' Empty string should produce empty string, got "${chunk_string}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 10e: Chunk size is correctly formatted as lowercase hex
+fn test_property_10e_hex_format() bool {
+ rand.seed([u32(time.now().unix()), u32(98765)])
+
+ // Test various sizes including edge cases
+ test_sizes := [1, 15, 16, 255, 256, 4095, 4096, 65535]
+
+ for size in test_sizes {
+ // Create data of specific size
+ mut data := []u8{len: size}
+ for i in 0 .. size {
+ data[i] = u8(i % 256)
+ }
+
+ chunk := format_chunk(data)
+
+ // Extract hex size
+ mut crlf_pos := -1
+ for j := 0; j < chunk.len - 1; j++ {
+ if chunk[j] == `\r` && chunk[j + 1] == `\n` {
+ crlf_pos = j
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ println(' Size ${size}: No CRLF found')
+ return false
+ }
+
+ size_hex := chunk[..crlf_pos]
+
+ // Verify it's valid lowercase hex
+ for c in size_hex {
+ is_digit := c >= `0` && c <= `9`
+ is_lower_hex := c >= `a` && c <= `f`
+ if !is_digit && !is_lower_hex {
+ println(' Size ${size}: Invalid hex character "${c.ascii_str()}" in "${size_hex}"')
+ return false
+ }
+ }
+
+ // Verify parsed value matches
+ parsed := parse_hex(size_hex) or {
+ println(' Size ${size}: Failed to parse hex "${size_hex}"')
+ return false
+ }
+
+ if parsed != size {
+ println(' Size ${size}: Hex "${size_hex}" parsed as ${parsed}')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 10f: Round-trip property - chunk then parse returns original data
+fn test_property_10f_roundtrip() bool {
+ rand.seed([u32(time.now().unix()), u32(11111)])
+
+ for i in 0 .. test_iterations {
+ // Generate random data
+ original := generate_random_bytes(500)
+
+ // Format as chunk
+ chunk := format_chunk(original)
+
+ // Parse back
+ recovered, _ := parse_chunked_data(chunk) or {
+ println(' Iteration ${i}: Failed to parse: ${err}')
+ return false
+ }
+
+ // Verify round-trip
+ if recovered != original {
+ println(' Iteration ${i}: Round-trip failed - data mismatch')
+ return false
+ }
+ }
+
+ return true
+}
+
+fn main() {
+ println('🚀 开始 PicoevStreamWriter 属性测试...')
+ println('每个属性测试运行 ${test_iterations} 次迭代\n')
+
+ mut stats := PropertyTestStats{}
+
+ // Run Property 10 tests
+ // Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
+ // Validates: Requirements 6.2
+ stats.run_property_test('Property 10a: format_chunk bytes encoding', test_property_10a_format_chunk_bytes)
+ stats.run_property_test('Property 10b: format_chunk_string encoding', test_property_10b_format_chunk_string)
+ stats.run_property_test('Property 10c: Final chunk format', test_property_10c_final_chunk_format)
+ stats.run_property_test('Property 10d: Empty data handling', test_property_10d_empty_data_handling)
+ stats.run_property_test('Property 10e: Hex format correctness', test_property_10e_hex_format)
+ stats.run_property_test('Property 10f: Round-trip property', test_property_10f_roundtrip)
+
+ // Print summary
+ stats.print_summary()
+}
diff --git a/tests/test_stream_error_handling_properties.v b/tests/test_stream_error_handling_properties.v
new file mode 100644
index 0000000..7f2b57b
--- /dev/null
+++ b/tests/test_stream_error_handling_properties.v
@@ -0,0 +1,761 @@
+// test_stream_error_handling_properties.v
+// Property-Based Testing for Error Handling in SSE Streaming
+// Feature: sse-streaming-helper
+// Property 8: 错误处理器调用
+// Property 9: 流自动关闭
+// Validates: Requirements 4.1, 1.5, 5.2
+module main
+
+import rand
+import time
+
+const test_iterations = 100
+
+struct PropertyTestStats {
+mut:
+ total_tests int
+ passed_tests int
+ failed_tests int
+}
+
+fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
+ stats.total_tests++
+ print('🔬 ${test_name}... ')
+
+ if test_func() {
+ stats.passed_tests++
+ println('✅')
+ } else {
+ stats.failed_tests++
+ println('❌')
+ }
+}
+
+fn (stats PropertyTestStats) print_summary() {
+ println('\n=== 错误处理属性测试总结 ===')
+ println('总测试数: ${stats.total_tests}')
+ println('通过: ${stats.passed_tests}')
+ println('失败: ${stats.failed_tests}')
+
+ if stats.failed_tests == 0 {
+ println('🎉 所有属性测试通过!')
+ } else {
+ println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
+ }
+}
+
+// ============================================================================
+// Mock StreamWriter for Testing
+// ============================================================================
+
+// MockStreamWriter - A mock implementation of StreamWriter for testing
+// Captures all written data for verification
+struct MockStreamWriter {
+mut:
+ written_data []u8
+ connected bool
+ closed bool
+ flush_count int
+}
+
+fn MockStreamWriter.new() &MockStreamWriter {
+ return &MockStreamWriter{
+ written_data: []u8{}
+ connected: true
+ closed: false
+ flush_count: 0
+ }
+}
+
+fn (mut w MockStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data.bytes()
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) flush() ! {
+ w.flush_count++
+}
+
+fn (mut w MockStreamWriter) close() ! {
+ if w.connected {
+ // Write the final chunk (zero-length chunk) to signal end of stream
+ w.written_data << '0\r\n\r\n'.bytes()
+ w.connected = false
+ w.closed = true
+ }
+}
+
+fn (w MockStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+fn (w MockStreamWriter) get_written_string() string {
+ return w.written_data.bytestr()
+}
+
+// ============================================================================
+// StreamContext for Testing (simplified version)
+// ============================================================================
+
+// StreamContext - Context provided to streaming callbacks
+@[heap]
+struct StreamContext {
+mut:
+ writer &MockStreamWriter
+ is_closed bool
+ abort_fn ?fn ()
+}
+
+// StreamContext.new - Create a new StreamContext with the given writer
+fn StreamContext.new(writer &MockStreamWriter) StreamContext {
+ return StreamContext{
+ writer: unsafe { writer }
+ is_closed: false
+ abort_fn: none
+ }
+}
+
+// write - Write raw bytes to the stream
+fn (mut ctx StreamContext) write(data []u8) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+ ctx.writer.write(data)!
+}
+
+// write_string - Write a string to the stream
+fn (mut ctx StreamContext) write_string(data string) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+ ctx.writer.write_string(data)!
+}
+
+// close - Close the stream
+fn (mut ctx StreamContext) close() {
+ if !ctx.is_closed {
+ ctx.is_closed = true
+ ctx.writer.close() or {}
+ }
+}
+
+// is_open - Check if the stream is still open
+fn (ctx StreamContext) is_open() bool {
+ return !ctx.is_closed && ctx.writer.is_connected()
+}
+
+// ============================================================================
+// Callback and Error Handler Types
+// ============================================================================
+
+// StreamCallback - Callback function type for streaming
+type StreamCallback = fn (mut StreamContext) !
+
+// StreamErrorHandler - Error handler function type for streaming
+type StreamErrorHandler = fn (err IError, mut ctx StreamContext)
+
+// ============================================================================
+// Stream Response Type
+// ============================================================================
+
+struct StreamResponse {
+pub:
+ status_code int = 200
+ headers map[string]string
+ is_stream bool = true
+}
+
+// ============================================================================
+// Streaming Helper Functions (for testing)
+// ============================================================================
+
+// stream - Basic binary streaming function with error handling
+fn stream(mut writer MockStreamWriter, callback StreamCallback, error_handler ...StreamErrorHandler) StreamResponse {
+ // Create StreamContext with the writer
+ mut ctx := StreamContext.new(writer)
+
+ // Execute the callback and handle errors
+ callback(mut ctx) or {
+ // Handle error
+ if error_handler.len > 0 {
+ // Call custom error handler
+ error_handler[0](err, mut ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[SSE Stream Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ ctx.close()
+
+ // Return response headers for streaming
+ return StreamResponse{
+ status_code: 200
+ headers: {
+ 'Transfer-Encoding': 'chunked'
+ 'Connection': 'keep-alive'
+ }
+ is_stream: true
+ }
+}
+
+// ============================================================================
+// Error Tracking for Tests
+// ============================================================================
+
+// Global variables to track error handler calls
+__global error_handler_called = false
+__global error_handler_error_msg = ''
+__global error_handler_stream_open = false
+__global error_handler_write_success = false
+
+fn reset_error_tracking() {
+ error_handler_called = false
+ error_handler_error_msg = ''
+ error_handler_stream_open = false
+ error_handler_write_success = false
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// Generate random error message
+fn generate_random_error_message(max_len int) string {
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 _-.'
+ len := rand.int_in_range(5, max_len) or { 20 }
+ mut result := ''
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result += chars[idx].ascii_str()
+ }
+ return result
+}
+
+// Generate random string for testing
+fn generate_random_string(max_len int) string {
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut result := ''
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result += chars[idx].ascii_str()
+ }
+ return result
+}
+
+// ============================================================================
+// Property 8: 错误处理器调用
+// Feature: sse-streaming-helper, Property 8
+// Validates: Requirements 4.1
+//
+// *For any* callback that throws an error, if an error handler is provided,
+// the error handler SHALL be called with the error and stream context.
+// ============================================================================
+
+// Test 8a: Custom error handler is called when callback throws error
+fn test_property_8a_error_handler_called() bool {
+ rand.seed([u32(time.now().unix()), u32(88881)])
+
+ for i in 0 .. test_iterations {
+ reset_error_tracking()
+
+ // Generate random error message
+ error_msg := generate_random_error_message(50)
+
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define callback that throws an error
+ callback := fn [error_msg] (mut ctx StreamContext) ! {
+ return error(error_msg)
+ }
+
+ // Define error handler that tracks the call
+ error_handler := fn (err IError, mut ctx StreamContext) {
+ error_handler_called = true
+ error_handler_error_msg = err.msg()
+ }
+
+ // Call stream with error handler
+ _ := stream(mut writer, callback, error_handler)
+
+ // Verify error handler was called
+ if !error_handler_called {
+ println(' Iteration ${i}: Error handler was not called')
+ return false
+ }
+
+ // Verify error message was passed correctly
+ if error_handler_error_msg != error_msg {
+ println(' Iteration ${i}: Error message mismatch')
+ println(' Expected: ${error_msg}')
+ println(' Got: ${error_handler_error_msg}')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 8b: Error handler receives valid stream context
+fn test_property_8b_error_handler_receives_context() bool {
+ rand.seed([u32(time.now().unix()), u32(88882)])
+
+ for i in 0 .. test_iterations {
+ reset_error_tracking()
+
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define callback that throws an error
+ callback := fn (mut ctx StreamContext) ! {
+ return error('Test error')
+ }
+
+ // Define error handler that checks stream context
+ error_handler := fn (err IError, mut ctx StreamContext) {
+ error_handler_called = true
+ error_handler_stream_open = ctx.is_open()
+ }
+
+ // Call stream with error handler
+ _ := stream(mut writer, callback, error_handler)
+
+ // Verify error handler was called
+ if !error_handler_called {
+ println(' Iteration ${i}: Error handler was not called')
+ return false
+ }
+
+ // Verify stream was still open when error handler was called
+ if !error_handler_stream_open {
+ println(' Iteration ${i}: Stream should be open when error handler is called')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 8c: Error handler can write to stream before it closes
+fn test_property_8c_error_handler_can_write() bool {
+ rand.seed([u32(time.now().unix()), u32(88883)])
+
+ for i in 0 .. test_iterations {
+ reset_error_tracking()
+
+ // Generate random error response message
+ error_response := generate_random_string(30)
+
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define callback that throws an error
+ callback := fn (mut ctx StreamContext) ! {
+ return error('Test error')
+ }
+
+ // Define error handler that writes to stream
+ error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
+ error_handler_called = true
+ ctx.write_string(error_response) or {
+ error_handler_write_success = false
+ return
+ }
+ error_handler_write_success = true
+ }
+
+ // Call stream with error handler
+ _ := stream(mut writer, callback, error_handler)
+
+ // Verify error handler was called
+ if !error_handler_called {
+ println(' Iteration ${i}: Error handler was not called')
+ return false
+ }
+
+ // Verify write was successful
+ if !error_handler_write_success {
+ println(' Iteration ${i}: Error handler failed to write to stream')
+ return false
+ }
+
+ // Verify the error response was written
+ written := writer.get_written_string()
+ if !written.contains(error_response) {
+ println(' Iteration ${i}: Error response not found in written data')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 8d: Without error handler, error is logged (no crash)
+fn test_property_8d_default_error_handling() bool {
+ rand.seed([u32(time.now().unix()), u32(88884)])
+
+ for i in 0 .. test_iterations {
+ // Generate random error message
+ error_msg := generate_random_error_message(50)
+
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define callback that throws an error
+ callback := fn [error_msg] (mut ctx StreamContext) ! {
+ return error(error_msg)
+ }
+
+ // Call stream WITHOUT error handler - should not crash
+ // Default behavior logs to console via eprintln
+ response := stream(mut writer, callback)
+
+ // Verify stream completed (no crash)
+ if response.status_code != 200 {
+ println(' Iteration ${i}: Unexpected status code ${response.status_code}')
+ return false
+ }
+
+ // Verify stream was closed
+ if !writer.closed {
+ println(' Iteration ${i}: Stream should be closed after error')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 8e: Error handler called with correct error type
+fn test_property_8e_error_type_preserved() bool {
+ rand.seed([u32(time.now().unix()), u32(88885)])
+
+ for i in 0 .. test_iterations {
+ reset_error_tracking()
+
+ // Generate random error message
+ error_msg := generate_random_error_message(50)
+
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define callback that throws an error
+ callback := fn [error_msg] (mut ctx StreamContext) ! {
+ return error(error_msg)
+ }
+
+ // Define error handler that checks error type
+ error_handler := fn [error_msg] (err IError, mut ctx StreamContext) {
+ error_handler_called = true
+ // Verify error message matches
+ if err.msg() == error_msg {
+ error_handler_write_success = true
+ }
+ }
+
+ // Call stream with error handler
+ _ := stream(mut writer, callback, error_handler)
+
+ // Verify error handler was called with correct error
+ if !error_handler_called {
+ println(' Iteration ${i}: Error handler was not called')
+ return false
+ }
+
+ if !error_handler_write_success {
+ println(' Iteration ${i}: Error message was not preserved correctly')
+ return false
+ }
+ }
+
+ return true
+}
+
+// ============================================================================
+// Property 9: 流自动关闭
+// Feature: sse-streaming-helper, Property 9
+// Validates: Requirements 1.5, 5.2
+//
+// *For any* stream where the callback completes normally, the stream SHALL be
+// automatically closed after callback execution.
+// ============================================================================
+
+// Test 9a: Stream is closed after successful callback completion
+fn test_property_9a_stream_closed_after_success() bool {
+ rand.seed([u32(time.now().unix()), u32(99991)])
+
+ for i in 0 .. test_iterations {
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Generate random data to write
+ data := generate_random_string(50)
+
+ // Define callback that completes successfully
+ callback := fn [data] (mut ctx StreamContext) ! {
+ ctx.write_string(data)!
+ }
+
+ // Call stream
+ _ := stream(mut writer, callback)
+
+ // Verify stream was closed
+ if !writer.closed {
+ println(' Iteration ${i}: Stream should be closed after callback completes')
+ return false
+ }
+
+ // Verify writer is disconnected
+ if writer.connected {
+ println(' Iteration ${i}: Writer should be disconnected after close')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 9b: Stream is closed after callback throws error
+fn test_property_9b_stream_closed_after_error() bool {
+ rand.seed([u32(time.now().unix()), u32(99992)])
+
+ for i in 0 .. test_iterations {
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Generate random error message
+ error_msg := generate_random_error_message(30)
+
+ // Define callback that throws an error
+ callback := fn [error_msg] (mut ctx StreamContext) ! {
+ return error(error_msg)
+ }
+
+ // Call stream (with or without error handler)
+ _ := stream(mut writer, callback)
+
+ // Verify stream was closed even after error
+ if !writer.closed {
+ println(' Iteration ${i}: Stream should be closed after error')
+ return false
+ }
+
+ // Verify writer is disconnected
+ if writer.connected {
+ println(' Iteration ${i}: Writer should be disconnected after close')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 9c: Final chunk marker is written when stream closes
+fn test_property_9c_final_chunk_written() bool {
+ rand.seed([u32(time.now().unix()), u32(99993)])
+
+ for i in 0 .. test_iterations {
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Generate random data to write
+ data := generate_random_string(50)
+
+ // Define callback that completes successfully
+ callback := fn [data] (mut ctx StreamContext) ! {
+ ctx.write_string(data)!
+ }
+
+ // Call stream
+ _ := stream(mut writer, callback)
+
+ // Verify final chunk marker is present
+ written := writer.get_written_string()
+ if !written.ends_with('0\r\n\r\n') {
+ println(' Iteration ${i}: Missing final chunk marker')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 9d: Stream closed only once (idempotent close)
+fn test_property_9d_idempotent_close() bool {
+ rand.seed([u32(time.now().unix()), u32(99994)])
+
+ for i in 0 .. test_iterations {
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define callback that manually closes the stream
+ callback := fn (mut ctx StreamContext) ! {
+ ctx.write_string('test')!
+ ctx.close() // Manual close
+ }
+
+ // Call stream (which will also try to close)
+ _ := stream(mut writer, callback)
+
+ // Verify stream was closed
+ if !writer.closed {
+ println(' Iteration ${i}: Stream should be closed')
+ return false
+ }
+
+ // Count final chunk markers - should be exactly one
+ written := writer.get_written_string()
+ mut final_chunk_count := 0
+ mut pos := 0
+ for {
+ idx := written[pos..].index('0\r\n\r\n') or { break }
+ final_chunk_count++
+ pos += idx + 5
+ }
+
+ if final_chunk_count != 1 {
+ println(' Iteration ${i}: Expected 1 final chunk marker, got ${final_chunk_count}')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 9e: Stream closed after error handler writes
+fn test_property_9e_closed_after_error_handler_writes() bool {
+ rand.seed([u32(time.now().unix()), u32(99995)])
+
+ for i in 0 .. test_iterations {
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Generate random error response
+ error_response := generate_random_string(30)
+
+ // Define callback that throws an error
+ callback := fn (mut ctx StreamContext) ! {
+ return error('Test error')
+ }
+
+ // Define error handler that writes to stream
+ error_handler := fn [error_response] (err IError, mut ctx StreamContext) {
+ ctx.write_string(error_response) or {}
+ }
+
+ // Call stream with error handler
+ _ := stream(mut writer, callback, error_handler)
+
+ // Verify stream was closed after error handler
+ if !writer.closed {
+ println(' Iteration ${i}: Stream should be closed after error handler')
+ return false
+ }
+
+ // Verify error response was written before close
+ written := writer.get_written_string()
+ if !written.contains(error_response) {
+ println(' Iteration ${i}: Error response should be written before close')
+ return false
+ }
+
+ // Verify final chunk marker is present
+ if !written.ends_with('0\r\n\r\n') {
+ println(' Iteration ${i}: Missing final chunk marker')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 9f: Empty callback still closes stream
+fn test_property_9f_empty_callback_closes() bool {
+ for i in 0 .. test_iterations {
+ // Create mock writer
+ mut writer := MockStreamWriter.new()
+
+ // Define empty callback
+ callback := fn (mut ctx StreamContext) ! {
+ // Do nothing
+ }
+
+ // Call stream
+ _ := stream(mut writer, callback)
+
+ // Verify stream was closed
+ if !writer.closed {
+ println(' Iteration ${i}: Stream should be closed even with empty callback')
+ return false
+ }
+ }
+
+ return true
+}
+
+fn main() {
+ println('🚀 开始错误处理属性测试...')
+ println('每个属性测试运行 ${test_iterations} 次迭代\n')
+
+ mut stats := PropertyTestStats{}
+
+ // Run Property 8 tests
+ // Feature: sse-streaming-helper, Property 8: 错误处理器调用
+ // Validates: Requirements 4.1
+ println('--- Property 8: 错误处理器调用 ---')
+ stats.run_property_test('Property 8a: Error handler called on error', test_property_8a_error_handler_called)
+ stats.run_property_test('Property 8b: Error handler receives context', test_property_8b_error_handler_receives_context)
+ stats.run_property_test('Property 8c: Error handler can write', test_property_8c_error_handler_can_write)
+ stats.run_property_test('Property 8d: Default error handling', test_property_8d_default_error_handling)
+ stats.run_property_test('Property 8e: Error type preserved', test_property_8e_error_type_preserved)
+
+ println('')
+
+ // Run Property 9 tests
+ // Feature: sse-streaming-helper, Property 9: 流自动关闭
+ // Validates: Requirements 1.5, 5.2
+ println('--- Property 9: 流自动关闭 ---')
+ stats.run_property_test('Property 9a: Stream closed after success', test_property_9a_stream_closed_after_success)
+ stats.run_property_test('Property 9b: Stream closed after error', test_property_9b_stream_closed_after_error)
+ stats.run_property_test('Property 9c: Final chunk written', test_property_9c_final_chunk_written)
+ stats.run_property_test('Property 9d: Idempotent close', test_property_9d_idempotent_close)
+ stats.run_property_test('Property 9e: Closed after error handler writes', test_property_9e_closed_after_error_handler_writes)
+ stats.run_property_test('Property 9f: Empty callback closes stream', test_property_9f_empty_callback_closes)
+
+ // Print summary
+ stats.print_summary()
+}
diff --git a/tests/test_stream_function_properties.v b/tests/test_stream_function_properties.v
new file mode 100644
index 0000000..26d6d3b
--- /dev/null
+++ b/tests/test_stream_function_properties.v
@@ -0,0 +1,586 @@
+// test_stream_function_properties.v
+// Property-Based Testing for stream() function
+// Feature: sse-streaming-helper
+// Property 1: stream() 设置正确的 Transfer-Encoding 头
+// Property 2: write() 正确输出数据
+// Validates: Requirements 1.1, 1.2
+module main
+
+import rand
+import time
+
+const test_iterations = 100
+
+struct PropertyTestStats {
+mut:
+ total_tests int
+ passed_tests int
+ failed_tests int
+}
+
+fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
+ stats.total_tests++
+ print('🔬 ${test_name}... ')
+
+ if test_func() {
+ stats.passed_tests++
+ println('✅')
+ } else {
+ stats.failed_tests++
+ println('❌')
+ }
+}
+
+fn (stats PropertyTestStats) print_summary() {
+ println('\n=== stream() 函数属性测试总结 ===')
+ println('总测试数: ${stats.total_tests}')
+ println('通过: ${stats.passed_tests}')
+ println('失败: ${stats.failed_tests}')
+
+ if stats.failed_tests == 0 {
+ println('🎉 所有属性测试通过!')
+ } else {
+ println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
+ }
+}
+
+// ============================================================================
+// Mock StreamWriter for Testing
+// ============================================================================
+
+// MockStreamWriter - A mock implementation of StreamWriter for testing
+// Captures all written data for verification
+struct MockStreamWriter {
+mut:
+ written_data []u8
+ connected bool
+ closed bool
+ flush_count int
+}
+
+fn MockStreamWriter.new() &MockStreamWriter {
+ return &MockStreamWriter{
+ written_data: []u8{}
+ connected: true
+ closed: false
+ flush_count: 0
+ }
+}
+
+fn (mut w MockStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data.bytes()
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) flush() ! {
+ w.flush_count++
+}
+
+fn (mut w MockStreamWriter) close() ! {
+ if w.connected {
+ // Write the final chunk (zero-length chunk) to signal end of stream
+ w.written_data << '0\r\n\r\n'.bytes()
+ w.connected = false
+ w.closed = true
+ }
+}
+
+fn (w MockStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+fn (w MockStreamWriter) get_written_string() string {
+ return w.written_data.bytestr()
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// get_stream_headers - Get the headers required for basic streaming
+fn get_stream_headers() map[string]string {
+ return {
+ 'Transfer-Encoding': 'chunked'
+ 'Connection': 'keep-alive'
+ }
+}
+
+// Generate random bytes for testing
+fn generate_random_bytes(max_len int) []u8 {
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut data := []u8{len: len}
+ for i in 0 .. len {
+ data[i] = u8(rand.int_in_range(0, 256) or { 0 })
+ }
+ return data
+}
+
+// Generate random string for testing (printable ASCII only)
+fn generate_random_string(max_len int) string {
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut result := ''
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result += chars[idx].ascii_str()
+ }
+ return result
+}
+
+// Parse hex string to int
+fn parse_hex(hex_str string) !int {
+ mut result := 0
+ for c in hex_str {
+ result *= 16
+ if c >= `0` && c <= `9` {
+ result += int(c - `0`)
+ } else if c >= `a` && c <= `f` {
+ result += int(c - `a` + 10)
+ } else if c >= `A` && c <= `F` {
+ result += int(c - `A` + 10)
+ } else {
+ return error('Invalid hex character: ${c.ascii_str()}')
+ }
+ }
+ return result
+}
+
+// Parse a chunked encoded string and extract all chunks
+fn parse_all_chunks(chunked string) !([][]u8, bool) {
+ mut chunks := [][]u8{}
+ mut pos := 0
+
+ for pos < chunked.len {
+ // Find the size part (before \r\n)
+ mut crlf_pos := -1
+ for i := pos; i < chunked.len - 1; i++ {
+ if chunked[i] == `\r` && chunked[i + 1] == `\n` {
+ crlf_pos = i
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ return error('No CRLF found in chunk header at position ${pos}')
+ }
+
+ size_hex := chunked[pos..crlf_pos]
+ size := parse_hex(size_hex) or {
+ return error('Invalid hex size: ${size_hex}')
+ }
+
+ // Check for final chunk
+ if size == 0 {
+ // Verify final chunk format
+ remaining := chunked[pos..]
+ if remaining != '0\r\n\r\n' {
+ return error('Invalid final chunk format')
+ }
+ break
+ }
+
+ // Extract data after the \r\n
+ data_start := crlf_pos + 2
+ data_end := data_start + size
+
+ if data_end > chunked.len {
+ return error('Chunk data incomplete')
+ }
+
+ data := chunked[data_start..data_end].bytes()
+ chunks << data
+
+ // Verify trailing \r\n
+ if data_end + 2 > chunked.len {
+ return error('Missing trailing CRLF')
+ }
+
+ trailing := chunked[data_end..data_end + 2]
+ if trailing != '\r\n' {
+ return error('Invalid trailing CRLF')
+ }
+
+ pos = data_end + 2
+ }
+
+ return chunks, true
+}
+
+// ============================================================================
+// Property 1: stream() 设置正确的 Transfer-Encoding 头
+// Feature: sse-streaming-helper, Property 1
+// Validates: Requirements 1.1
+//
+// *For any* call to stream(c, callback), the response SHALL contain
+// Transfer-Encoding: chunked header.
+// ============================================================================
+
+// Test 1a: get_stream_headers returns Transfer-Encoding: chunked
+fn test_property_1a_transfer_encoding_header() bool {
+ headers := get_stream_headers()
+
+ // Verify Transfer-Encoding header exists
+ if 'Transfer-Encoding' !in headers {
+ println(' Transfer-Encoding header missing')
+ return false
+ }
+
+ // Verify value is 'chunked'
+ if headers['Transfer-Encoding'] != 'chunked' {
+ println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 1b: get_stream_headers returns Connection: keep-alive
+fn test_property_1b_connection_header() bool {
+ headers := get_stream_headers()
+
+ // Verify Connection header exists
+ if 'Connection' !in headers {
+ println(' Connection header missing')
+ return false
+ }
+
+ // Verify value is 'keep-alive'
+ if headers['Connection'] != 'keep-alive' {
+ println(' Connection should be "keep-alive", got "${headers['Connection']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 1c: Headers are consistent across multiple calls
+fn test_property_1c_headers_consistency() bool {
+ for _ in 0 .. test_iterations {
+ headers1 := get_stream_headers()
+ headers2 := get_stream_headers()
+
+ // Headers should be identical
+ if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
+ println(' Transfer-Encoding inconsistent between calls')
+ return false
+ }
+
+ if headers1['Connection'] != headers2['Connection'] {
+ println(' Connection inconsistent between calls')
+ return false
+ }
+ }
+
+ return true
+}
+
+// ============================================================================
+// Property 2: write() 正确输出数据
+// Feature: sse-streaming-helper, Property 2
+// Validates: Requirements 1.2
+//
+// *For any* byte array data, when stream.write(data) is called, the output
+// stream SHALL contain the exact bytes from data in chunked transfer encoding format.
+// ============================================================================
+
+// Test 2a: write() outputs data in chunked format for any byte array
+fn test_property_2a_write_bytes_chunked() bool {
+ rand.seed([u32(time.now().unix()), u32(12345)])
+
+ for i in 0 .. test_iterations {
+ // Generate random data
+ data := generate_random_bytes(500)
+
+ // Create mock writer and write data
+ mut writer := MockStreamWriter.new()
+ writer.write(data) or {
+ println(' Iteration ${i}: write failed: ${err}')
+ return false
+ }
+
+ // Parse the written chunked data
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
+ println(' Iteration ${i}: Failed to parse chunks: ${err}')
+ return false
+ }
+
+ // Should have exactly one chunk
+ if chunks.len != 1 {
+ println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
+ return false
+ }
+
+ // Chunk data should match original
+ if chunks[0] != data {
+ println(' Iteration ${i}: Data mismatch')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 2b: write_string() outputs data in chunked format for any string
+fn test_property_2b_write_string_chunked() bool {
+ rand.seed([u32(time.now().unix()), u32(54321)])
+
+ for i in 0 .. test_iterations {
+ // Generate random string
+ data := generate_random_string(500)
+
+ // Create mock writer and write data
+ mut writer := MockStreamWriter.new()
+ writer.write_string(data) or {
+ println(' Iteration ${i}: write_string failed: ${err}')
+ return false
+ }
+
+ // Parse the written chunked data
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
+ println(' Iteration ${i}: Failed to parse chunks: ${err}')
+ return false
+ }
+
+ // Should have exactly one chunk
+ if chunks.len != 1 {
+ println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
+ return false
+ }
+
+ // Chunk data should match original string
+ if chunks[0].bytestr() != data {
+ println(' Iteration ${i}: String mismatch')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 2c: Multiple writes produce multiple chunks
+fn test_property_2c_multiple_writes() bool {
+ rand.seed([u32(time.now().unix()), u32(98765)])
+
+ for i in 0 .. test_iterations {
+ // Generate multiple random data pieces
+ num_writes := rand.int_in_range(2, 10) or { 3 }
+ mut original_data := [][]u8{}
+
+ mut writer := MockStreamWriter.new()
+
+ for _ in 0 .. num_writes {
+ data := generate_random_bytes(100)
+ original_data << data
+ writer.write(data) or {
+ println(' Iteration ${i}: write failed: ${err}')
+ return false
+ }
+ }
+
+ // Parse the written chunked data
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written + '0\r\n\r\n') or {
+ println(' Iteration ${i}: Failed to parse chunks: ${err}')
+ return false
+ }
+
+ // Should have same number of chunks as writes
+ if chunks.len != num_writes {
+ println(' Iteration ${i}: Expected ${num_writes} chunks, got ${chunks.len}')
+ return false
+ }
+
+ // Each chunk should match original data
+ for j, chunk in chunks {
+ if chunk != original_data[j] {
+ println(' Iteration ${i}: Chunk ${j} data mismatch')
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
+// Test 2d: close() writes final chunk marker
+fn test_property_2d_close_final_chunk() bool {
+ for _ in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+
+ // Write some data
+ data := generate_random_bytes(50)
+ writer.write(data) or {
+ println(' write failed')
+ return false
+ }
+
+ // Close the writer
+ writer.close() or {
+ println(' close failed')
+ return false
+ }
+
+ // Verify final chunk marker is present
+ written := writer.get_written_string()
+ if !written.ends_with('0\r\n\r\n') {
+ println(' Missing final chunk marker')
+ return false
+ }
+
+ // Verify writer is marked as closed
+ if writer.connected {
+ println(' Writer should be disconnected after close')
+ return false
+ }
+
+ if !writer.closed {
+ println(' Writer should be marked as closed')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 2e: Empty data is handled correctly (no chunk written)
+fn test_property_2e_empty_data() bool {
+ mut writer := MockStreamWriter.new()
+
+ // Write empty byte array
+ writer.write([]u8{}) or {
+ println(' write empty bytes failed')
+ return false
+ }
+
+ // Write empty string
+ writer.write_string('') or {
+ println(' write empty string failed')
+ return false
+ }
+
+ // No data should be written for empty inputs
+ if writer.written_data.len != 0 {
+ println(' Empty data should not produce output, got ${writer.written_data.len} bytes')
+ return false
+ }
+
+ return true
+}
+
+// Test 2f: Round-trip property - write then parse returns original data
+fn test_property_2f_roundtrip() bool {
+ rand.seed([u32(time.now().unix()), u32(11111)])
+
+ for i in 0 .. test_iterations {
+ // Generate random data
+ original := generate_random_bytes(500)
+
+ // Write to mock writer
+ mut writer := MockStreamWriter.new()
+ writer.write(original) or {
+ println(' Iteration ${i}: write failed')
+ return false
+ }
+ writer.close() or {
+ println(' Iteration ${i}: close failed')
+ return false
+ }
+
+ // Parse back
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written) or {
+ println(' Iteration ${i}: Failed to parse: ${err}')
+ return false
+ }
+
+ // Verify round-trip
+ if chunks.len != 1 {
+ println(' Iteration ${i}: Expected 1 chunk, got ${chunks.len}')
+ return false
+ }
+
+ if chunks[0] != original {
+ println(' Iteration ${i}: Round-trip failed - data mismatch')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 2g: Writing to closed connection returns error
+fn test_property_2g_write_after_close() bool {
+ mut writer := MockStreamWriter.new()
+
+ // Close the writer
+ writer.close() or {
+ println(' close failed')
+ return false
+ }
+
+ // Try to write - should fail
+ writer.write('test'.bytes()) or {
+ // Expected error
+ return true
+ }
+
+ println(' Writing to closed connection should fail')
+ return false
+}
+
+fn main() {
+ println('🚀 开始 stream() 函数属性测试...')
+ println('每个属性测试运行 ${test_iterations} 次迭代\n')
+
+ mut stats := PropertyTestStats{}
+
+ // Run Property 1 tests
+ // Feature: sse-streaming-helper, Property 1: stream() 设置正确的 Transfer-Encoding 头
+ // Validates: Requirements 1.1
+ println('--- Property 1: stream() 设置正确的 Transfer-Encoding 头 ---')
+ stats.run_property_test('Property 1a: Transfer-Encoding header', test_property_1a_transfer_encoding_header)
+ stats.run_property_test('Property 1b: Connection header', test_property_1b_connection_header)
+ stats.run_property_test('Property 1c: Headers consistency', test_property_1c_headers_consistency)
+
+ println('')
+
+ // Run Property 2 tests
+ // Feature: sse-streaming-helper, Property 2: write() 正确输出数据
+ // Validates: Requirements 1.2
+ println('--- Property 2: write() 正确输出数据 ---')
+ stats.run_property_test('Property 2a: write() bytes chunked format', test_property_2a_write_bytes_chunked)
+ stats.run_property_test('Property 2b: write_string() chunked format', test_property_2b_write_string_chunked)
+ stats.run_property_test('Property 2c: Multiple writes produce chunks', test_property_2c_multiple_writes)
+ stats.run_property_test('Property 2d: close() writes final chunk', test_property_2d_close_final_chunk)
+ stats.run_property_test('Property 2e: Empty data handling', test_property_2e_empty_data)
+ stats.run_property_test('Property 2f: Round-trip property', test_property_2f_roundtrip)
+ stats.run_property_test('Property 2g: Write after close fails', test_property_2g_write_after_close)
+
+ // Print summary
+ stats.print_summary()
+}
diff --git a/tests/test_stream_sse_properties.v b/tests/test_stream_sse_properties.v
new file mode 100644
index 0000000..0791761
--- /dev/null
+++ b/tests/test_stream_sse_properties.v
@@ -0,0 +1,988 @@
+// test_stream_sse_properties.v
+// Property-Based Testing for stream_sse() function
+// Feature: sse-streaming-helper
+// Property 6: streamSSE() 设置正确的 HTTP 头
+// Property 7: SSE 事件格式化正确性
+// Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
+module main
+
+import rand
+
+const test_iterations = 100
+
+struct PropertyTestStats {
+mut:
+ total_tests int
+ passed_tests int
+ failed_tests int
+}
+
+fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
+ stats.total_tests++
+ print('🔬 ${test_name}... ')
+
+ if test_func() {
+ stats.passed_tests++
+ println('✅')
+ } else {
+ stats.failed_tests++
+ println('❌')
+ }
+}
+
+fn (stats PropertyTestStats) print_summary() {
+ println('\n=== stream_sse() 函数属性测试总结 ===')
+ println('总测试数: ${stats.total_tests}')
+ println('通过: ${stats.passed_tests}')
+ println('失败: ${stats.failed_tests}')
+
+ if stats.failed_tests == 0 {
+ println('🎉 所有属性测试通过!')
+ } else {
+ println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
+ }
+}
+
+
+// ============================================================================
+// Mock StreamWriter for Testing
+// ============================================================================
+
+// MockStreamWriter - A mock implementation of StreamWriter for testing
+// Captures all written data for verification
+struct MockStreamWriter {
+mut:
+ written_data []u8
+ connected bool
+ closed bool
+ flush_count int
+}
+
+fn MockStreamWriter.new() &MockStreamWriter {
+ return &MockStreamWriter{
+ written_data: []u8{}
+ connected: true
+ closed: false
+ flush_count: 0
+ }
+}
+
+fn (mut w MockStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data.bytes()
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) flush() ! {
+ w.flush_count++
+}
+
+fn (mut w MockStreamWriter) close() ! {
+ if w.connected {
+ // Write the final chunk (zero-length chunk) to signal end of stream
+ w.written_data << '0\r\n\r\n'.bytes()
+ w.connected = false
+ w.closed = true
+ }
+}
+
+fn (w MockStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+fn (w MockStreamWriter) get_written_string() string {
+ return w.written_data.bytestr()
+}
+
+// extract_sse_content - Extract the actual SSE content from chunked encoding
+// Removes the chunk size headers and CRLF markers to get the raw SSE data
+fn (w MockStreamWriter) extract_sse_content() string {
+ raw := w.written_data.bytestr()
+ mut result := []u8{}
+ mut i := 0
+
+ for i < raw.len {
+ // Find the chunk size (hex number before \r\n)
+ mut size_end := i
+ for size_end < raw.len && raw[size_end] != `\r` {
+ size_end++
+ }
+
+ if size_end >= raw.len {
+ break
+ }
+
+ // Parse chunk size from hex string
+ size_str := raw[i..size_end]
+ chunk_size := parse_hex_int(size_str)
+
+ if chunk_size == 0 {
+ // Final chunk
+ break
+ }
+
+ // Skip \r\n after size
+ i = size_end + 2
+
+ // Extract chunk data
+ if i + chunk_size <= raw.len {
+ result << raw[i..i + chunk_size].bytes()
+ }
+
+ // Skip chunk data and trailing \r\n
+ i = i + chunk_size + 2
+ }
+
+ return result.bytestr()
+}
+
+// parse_hex_int - Parse a hex string to integer
+fn parse_hex_int(s string) int {
+ mut result := 0
+ for c in s {
+ result *= 16
+ if c >= `0` && c <= `9` {
+ result += int(c - `0`)
+ } else if c >= `a` && c <= `f` {
+ result += int(c - `a` + 10)
+ } else if c >= `A` && c <= `F` {
+ result += int(c - `A` + 10)
+ }
+ }
+ return result
+}
+
+
+// ============================================================================
+// SSEEvent Structure for Testing
+// ============================================================================
+
+// SSEEvent - Server-Sent Events event structure
+// Mirrors the implementation in streaming.v
+struct SSEEvent {
+ data string // Event data (required) - supports multi-line
+ event string // Event type name (optional)
+ id string // Event ID for client reconnection (optional)
+ retry int // Reconnection time in milliseconds (optional, 0 = not set)
+}
+
+
+// ============================================================================
+// StreamContext for Testing
+// ============================================================================
+
+// TestStreamContext - A test implementation of StreamContext
+// Provides write_sse method for testing SSE event formatting
+struct TestStreamContext {
+mut:
+ writer &MockStreamWriter
+ is_closed bool
+}
+
+fn TestStreamContext.new(writer &MockStreamWriter) TestStreamContext {
+ return TestStreamContext{
+ writer: unsafe { writer }
+ is_closed: false
+ }
+}
+
+fn (mut ctx TestStreamContext) write_string(data string) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+ ctx.writer.write_string(data)!
+}
+
+// write_sse - Write an SSE event to the stream
+// Formats the event according to the SSE specification:
+// - event: {type}\n (if event field is set)
+// - data: {line}\n (for each line of data)
+// - id: {id}\n (if id field is set)
+// - retry: {ms}\n (if retry > 0)
+// - \n (event separator)
+fn (mut ctx TestStreamContext) write_sse(event SSEEvent) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+
+ // Write event type if specified
+ if event.event.len > 0 {
+ ctx.writer.write_string('event: ${event.event}\n')!
+ }
+
+ // Write data field - handle multi-line data
+ lines := event.data.split('\n')
+ for line in lines {
+ ctx.writer.write_string('data: ${line}\n')!
+ }
+
+ // Write id if specified
+ if event.id.len > 0 {
+ ctx.writer.write_string('id: ${event.id}\n')!
+ }
+
+ // Write retry if specified (must be > 0)
+ if event.retry > 0 {
+ ctx.writer.write_string('retry: ${event.retry}\n')!
+ }
+
+ // Write event separator (empty line)
+ ctx.writer.write_string('\n')!
+
+ // Flush to ensure event is sent immediately
+ ctx.writer.flush()!
+}
+
+fn (mut ctx TestStreamContext) close() {
+ if !ctx.is_closed {
+ ctx.is_closed = true
+ ctx.writer.close() or {}
+ }
+}
+
+
+// ============================================================================
+// Random Data Generators for Property Testing
+// ============================================================================
+
+// generate_random_string - Generate a random alphanumeric string
+fn generate_random_string(min_len int, max_len int) string {
+ len := rand.int_in_range(min_len, max_len + 1) or { min_len }
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
+ mut result := []u8{cap: len}
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result << chars[idx]
+ }
+ return result.bytestr()
+}
+
+// generate_random_multiline_string - Generate a random string with newlines
+fn generate_random_multiline_string(min_lines int, max_lines int) string {
+ num_lines := rand.int_in_range(min_lines, max_lines + 1) or { min_lines }
+ mut lines := []string{cap: num_lines}
+ for _ in 0 .. num_lines {
+ lines << generate_random_string(1, 20)
+ }
+ return lines.join('\n')
+}
+
+// generate_random_sse_event - Generate a random SSE event
+fn generate_random_sse_event() SSEEvent {
+ // Randomly decide which optional fields to include
+ include_event := rand.int_in_range(0, 2) or { 0 } == 1
+ include_id := rand.int_in_range(0, 2) or { 0 } == 1
+ include_retry := rand.int_in_range(0, 2) or { 0 } == 1
+ is_multiline := rand.int_in_range(0, 3) or { 0 } == 0 // 1/3 chance of multiline
+
+ mut data := ''
+ if is_multiline {
+ data = generate_random_multiline_string(2, 5)
+ } else {
+ data = generate_random_string(1, 50)
+ }
+
+ return SSEEvent{
+ data: data
+ event: if include_event { generate_random_string(3, 15) } else { '' }
+ id: if include_id { generate_random_string(1, 10) } else { '' }
+ retry: if include_retry { rand.int_in_range(100, 10000) or { 1000 } } else { 0 }
+ }
+}
+
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// get_stream_sse_headers - Get the headers required for SSE streaming
+// This mirrors the implementation in streaming.v
+fn get_stream_sse_headers() map[string]string {
+ return {
+ 'Content-Type': 'text/event-stream'
+ 'Cache-Control': 'no-cache'
+ 'Connection': 'keep-alive'
+ 'Transfer-Encoding': 'chunked'
+ }
+}
+
+// ============================================================================
+// Property 6: streamSSE() 设置正确的 HTTP 头
+// Feature: sse-streaming-helper, Property 6
+// Validates: Requirements 3.1, 3.2, 3.3
+//
+// *For any* call to streamSSE(c, callback), the response SHALL contain:
+// - Content-Type: text/event-stream
+// - Cache-Control: no-cache
+// - Connection: keep-alive
+// ============================================================================
+
+// Test 6a: Content-Type header is set correctly for SSE
+fn test_property_6a_content_type_header() bool {
+ headers := get_stream_sse_headers()
+
+ // Verify Content-Type header exists
+ if 'Content-Type' !in headers {
+ println(' Content-Type header missing')
+ return false
+ }
+
+ // Verify value is 'text/event-stream' (Requirement 3.1)
+ if headers['Content-Type'] != 'text/event-stream' {
+ println(' Content-Type should be "text/event-stream", got "${headers['Content-Type']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 6b: Cache-Control header is set correctly for SSE
+fn test_property_6b_cache_control_header() bool {
+ headers := get_stream_sse_headers()
+
+ // Verify Cache-Control header exists
+ if 'Cache-Control' !in headers {
+ println(' Cache-Control header missing')
+ return false
+ }
+
+ // Verify value is 'no-cache' (Requirement 3.2)
+ if headers['Cache-Control'] != 'no-cache' {
+ println(' Cache-Control should be "no-cache", got "${headers['Cache-Control']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 6c: Connection header is set correctly for SSE
+fn test_property_6c_connection_header() bool {
+ headers := get_stream_sse_headers()
+
+ // Verify Connection header exists
+ if 'Connection' !in headers {
+ println(' Connection header missing')
+ return false
+ }
+
+ // Verify value is 'keep-alive' (Requirement 3.3)
+ if headers['Connection'] != 'keep-alive' {
+ println(' Connection should be "keep-alive", got "${headers['Connection']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 6d: Transfer-Encoding header is set for chunked transfer
+fn test_property_6d_transfer_encoding_header() bool {
+ headers := get_stream_sse_headers()
+
+ // Verify Transfer-Encoding header exists
+ if 'Transfer-Encoding' !in headers {
+ println(' Transfer-Encoding header missing')
+ return false
+ }
+
+ // Verify value is 'chunked'
+ if headers['Transfer-Encoding'] != 'chunked' {
+ println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 6e: Headers are consistent across multiple calls
+fn test_property_6e_headers_consistency() bool {
+ for _ in 0 .. test_iterations {
+ headers1 := get_stream_sse_headers()
+ headers2 := get_stream_sse_headers()
+
+ // Headers should be identical
+ if headers1['Content-Type'] != headers2['Content-Type'] {
+ println(' Content-Type inconsistent between calls')
+ return false
+ }
+
+ if headers1['Cache-Control'] != headers2['Cache-Control'] {
+ println(' Cache-Control inconsistent between calls')
+ return false
+ }
+
+ if headers1['Connection'] != headers2['Connection'] {
+ println(' Connection inconsistent between calls')
+ return false
+ }
+
+ if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
+ println(' Transfer-Encoding inconsistent between calls')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 6f: All required SSE headers are present
+fn test_property_6f_all_required_headers_present() bool {
+ headers := get_stream_sse_headers()
+
+ required_headers := ['Content-Type', 'Cache-Control', 'Connection', 'Transfer-Encoding']
+
+ for header in required_headers {
+ if header !in headers {
+ println(' Required header "${header}" is missing')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 6g: SSE headers differ from text streaming headers
+fn test_property_6g_sse_headers_differ_from_text() bool {
+ sse_headers := get_stream_sse_headers()
+
+ // SSE should use text/event-stream, not text/plain
+ if sse_headers['Content-Type'] == 'text/plain; charset=utf-8' {
+ println(' SSE Content-Type should not be text/plain')
+ return false
+ }
+
+ // SSE should have Cache-Control: no-cache
+ if 'Cache-Control' !in sse_headers {
+ println(' SSE should have Cache-Control header')
+ return false
+ }
+
+ return true
+}
+
+
+// ============================================================================
+// Property 7: SSE 事件格式化正确性
+// Feature: sse-streaming-helper, Property 7
+// Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
+//
+// *For any* SSEEvent with fields (data, event, id, retry), when stream.writeSSE(event) is called:
+// - If event is non-empty, output SHALL start with event: {event}\n
+// - Output SHALL contain data: {data}\n for each line of data
+// - If id is non-empty, output SHALL contain id: {id}\n
+// - If retry > 0, output SHALL contain retry: {retry}\n
+// - Output SHALL end with an additional \n to separate events
+// ============================================================================
+
+// Test 7a: Data field is always written correctly (Requirement 3.5)
+// *For any* SSE event with data, the output SHALL contain "data: {value}\n"
+fn test_property_7a_data_field_formatting() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ // Generate random single-line data
+ data := generate_random_string(1, 50)
+ event := SSEEvent{
+ data: data
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+ expected := 'data: ${data}\n'
+
+ if !content.contains(expected) {
+ println(' Iteration ${i}: Expected data line "${expected}" not found in output')
+ println(' Got: "${content}"')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7b: Multi-line data is split correctly (Requirement 3.5)
+// *For any* SSE event with multi-line data, each line SHALL be prefixed with "data: "
+fn test_property_7b_multiline_data_formatting() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ // Generate random multi-line data
+ data := generate_random_multiline_string(2, 5)
+ event := SSEEvent{
+ data: data
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+ lines := data.split('\n')
+
+ // Each line of data should be prefixed with "data: "
+ for line in lines {
+ expected := 'data: ${line}\n'
+ if !content.contains(expected) {
+ println(' Iteration ${i}: Expected data line "${expected}" not found')
+ println(' Original data: "${data}"')
+ println(' Got: "${content}"')
+ return false
+ }
+ }
+ }
+ return true
+}
+
+// Test 7c: Event field is written before data (Requirement 3.6)
+// *For any* SSE event with event field, output SHALL start with "event: {value}\n"
+fn test_property_7c_event_field_before_data() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ event_type := generate_random_string(3, 15)
+ data := generate_random_string(1, 30)
+ event := SSEEvent{
+ data: data
+ event: event_type
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+ event_line := 'event: ${event_type}\n'
+ data_line := 'data: ${data}\n'
+
+ // Event line should exist
+ if !content.contains(event_line) {
+ println(' Iteration ${i}: Event line "${event_line}" not found')
+ return false
+ }
+
+ // Event line should come before data line
+ event_pos := content.index(event_line) or { -1 }
+ data_pos := content.index(data_line) or { -1 }
+
+ if event_pos >= data_pos {
+ println(' Iteration ${i}: Event line should come before data line')
+ println(' Event pos: ${event_pos}, Data pos: ${data_pos}')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7d: Event field is omitted when empty (Requirement 3.6)
+// *For any* SSE event with empty event field, output SHALL NOT contain "event: "
+fn test_property_7d_empty_event_field_omitted() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ data := generate_random_string(1, 30)
+ event := SSEEvent{
+ data: data
+ event: '' // Empty event field
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+
+ // Should not contain "event: " prefix
+ if content.contains('event: ') {
+ println(' Iteration ${i}: Empty event field should not produce "event: " line')
+ println(' Got: "${content}"')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7e: ID field is written after data (Requirement 3.7)
+// *For any* SSE event with id field, output SHALL contain "id: {value}\n" after data
+fn test_property_7e_id_field_after_data() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ id := generate_random_string(1, 10)
+ data := generate_random_string(1, 30)
+ event := SSEEvent{
+ data: data
+ id: id
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+ id_line := 'id: ${id}\n'
+ data_line := 'data: ${data}\n'
+
+ // ID line should exist
+ if !content.contains(id_line) {
+ println(' Iteration ${i}: ID line "${id_line}" not found')
+ return false
+ }
+
+ // ID line should come after data line
+ id_pos := content.index(id_line) or { -1 }
+ data_pos := content.index(data_line) or { -1 }
+
+ if id_pos <= data_pos {
+ println(' Iteration ${i}: ID line should come after data line')
+ println(' ID pos: ${id_pos}, Data pos: ${data_pos}')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7f: ID field is omitted when empty (Requirement 3.7)
+// *For any* SSE event with empty id field, output SHALL NOT contain "id: "
+fn test_property_7f_empty_id_field_omitted() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ data := generate_random_string(1, 30)
+ event := SSEEvent{
+ data: data
+ id: '' // Empty id field
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+
+ // Should not contain "id: " prefix
+ if content.contains('id: ') {
+ println(' Iteration ${i}: Empty id field should not produce "id: " line')
+ println(' Got: "${content}"')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7g: Retry field is written when positive (Requirement 3.8)
+// *For any* SSE event with retry > 0, output SHALL contain "retry: {value}\n"
+fn test_property_7g_retry_field_when_positive() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ retry := rand.int_in_range(100, 10000) or { 1000 }
+ data := generate_random_string(1, 30)
+ event := SSEEvent{
+ data: data
+ retry: retry
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+ retry_line := 'retry: ${retry}\n'
+
+ // Retry line should exist
+ if !content.contains(retry_line) {
+ println(' Iteration ${i}: Retry line "${retry_line}" not found')
+ println(' Got: "${content}"')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7h: Retry field is omitted when zero (Requirement 3.8)
+// *For any* SSE event with retry = 0, output SHALL NOT contain "retry: "
+fn test_property_7h_zero_retry_field_omitted() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ data := generate_random_string(1, 30)
+ event := SSEEvent{
+ data: data
+ retry: 0 // Zero retry
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+
+ // Should not contain "retry: " prefix
+ if content.contains('retry: ') {
+ println(' Iteration ${i}: Zero retry should not produce "retry: " line')
+ println(' Got: "${content}"')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7i: Event separator (empty line) is always present (Requirement 3.9)
+// *For any* SSE event, output SHALL end with an additional "\n" to separate events
+fn test_property_7i_event_separator_present() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ event := generate_random_sse_event()
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+
+ // Content should end with double newline (data line + separator)
+ // The last line before separator should be data:, id:, or retry:
+ // followed by \n\n (the separator)
+ if !content.ends_with('\n\n') {
+ println(' Iteration ${i}: Event should end with double newline (separator)')
+ println(' Got: "${content}"')
+ println(' Last chars: ${content.bytes()[content.len - 4..]}')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7j: Complete SSE event with all fields (Requirements 3.4-3.9)
+// *For any* SSE event with all fields set, output SHALL follow correct format and order
+fn test_property_7j_complete_event_format() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ event_type := generate_random_string(3, 15)
+ data := generate_random_string(1, 30)
+ id := generate_random_string(1, 10)
+ retry := rand.int_in_range(100, 10000) or { 1000 }
+
+ event := SSEEvent{
+ data: data
+ event: event_type
+ id: id
+ retry: retry
+ }
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+
+ // Verify all fields are present
+ if !content.contains('event: ${event_type}\n') {
+ println(' Iteration ${i}: Event field missing')
+ return false
+ }
+ if !content.contains('data: ${data}\n') {
+ println(' Iteration ${i}: Data field missing')
+ return false
+ }
+ if !content.contains('id: ${id}\n') {
+ println(' Iteration ${i}: ID field missing')
+ return false
+ }
+ if !content.contains('retry: ${retry}\n') {
+ println(' Iteration ${i}: Retry field missing')
+ return false
+ }
+
+ // Verify order: event -> data -> id -> retry -> separator
+ event_pos := content.index('event: ') or { -1 }
+ data_pos := content.index('data: ') or { -1 }
+ id_pos := content.index('id: ') or { -1 }
+ retry_pos := content.index('retry: ') or { -1 }
+
+ if event_pos >= data_pos {
+ println(' Iteration ${i}: Event should come before data')
+ return false
+ }
+ if data_pos >= id_pos {
+ println(' Iteration ${i}: Data should come before id')
+ return false
+ }
+ if id_pos >= retry_pos {
+ println(' Iteration ${i}: ID should come before retry')
+ return false
+ }
+
+ // Verify ends with separator
+ if !content.ends_with('\n\n') {
+ println(' Iteration ${i}: Should end with event separator')
+ return false
+ }
+ }
+ return true
+}
+
+// Test 7k: Random SSE events are formatted correctly (Requirements 3.4-3.9)
+// *For any* randomly generated SSE event, the formatting SHALL be correct
+fn test_property_7k_random_events_formatted_correctly() bool {
+ for i in 0 .. test_iterations {
+ mut writer := MockStreamWriter.new()
+ mut ctx := TestStreamContext.new(writer)
+
+ event := generate_random_sse_event()
+
+ ctx.write_sse(event) or {
+ println(' Iteration ${i}: write_sse failed: ${err}')
+ return false
+ }
+
+ content := writer.extract_sse_content()
+
+ // Data field should always be present
+ lines := event.data.split('\n')
+ for line in lines {
+ if !content.contains('data: ${line}\n') {
+ println(' Iteration ${i}: Data line missing: "data: ${line}"')
+ return false
+ }
+ }
+
+ // Event field should be present only if non-empty
+ if event.event.len > 0 {
+ if !content.contains('event: ${event.event}\n') {
+ println(' Iteration ${i}: Event field should be present')
+ return false
+ }
+ } else {
+ if content.contains('event: ') {
+ println(' Iteration ${i}: Event field should not be present when empty')
+ return false
+ }
+ }
+
+ // ID field should be present only if non-empty
+ if event.id.len > 0 {
+ if !content.contains('id: ${event.id}\n') {
+ println(' Iteration ${i}: ID field should be present')
+ return false
+ }
+ } else {
+ if content.contains('id: ') {
+ println(' Iteration ${i}: ID field should not be present when empty')
+ return false
+ }
+ }
+
+ // Retry field should be present only if > 0
+ if event.retry > 0 {
+ if !content.contains('retry: ${event.retry}\n') {
+ println(' Iteration ${i}: Retry field should be present')
+ return false
+ }
+ } else {
+ if content.contains('retry: ') {
+ println(' Iteration ${i}: Retry field should not be present when zero')
+ return false
+ }
+ }
+
+ // Should always end with separator
+ if !content.ends_with('\n\n') {
+ println(' Iteration ${i}: Should end with event separator')
+ return false
+ }
+ }
+ return true
+}
+
+
+fn main() {
+ println('🚀 开始 stream_sse() 函数属性测试...')
+ println('每个属性测试运行 ${test_iterations} 次迭代\n')
+
+ mut stats := PropertyTestStats{}
+
+ // Run Property 6 tests
+ // Feature: sse-streaming-helper, Property 6: streamSSE() 设置正确的 HTTP 头
+ // Validates: Requirements 3.1, 3.2, 3.3
+ println('--- Property 6: streamSSE() 设置正确的 HTTP 头 ---')
+ stats.run_property_test('Property 6a: Content-Type header (text/event-stream)', test_property_6a_content_type_header)
+ stats.run_property_test('Property 6b: Cache-Control header (no-cache)', test_property_6b_cache_control_header)
+ stats.run_property_test('Property 6c: Connection header (keep-alive)', test_property_6c_connection_header)
+ stats.run_property_test('Property 6d: Transfer-Encoding header (chunked)', test_property_6d_transfer_encoding_header)
+ stats.run_property_test('Property 6e: Headers consistency', test_property_6e_headers_consistency)
+ stats.run_property_test('Property 6f: All required headers present', test_property_6f_all_required_headers_present)
+ stats.run_property_test('Property 6g: SSE headers differ from text', test_property_6g_sse_headers_differ_from_text)
+
+ // Run Property 7 tests
+ // Feature: sse-streaming-helper, Property 7: SSE 事件格式化正确性
+ // Validates: Requirements 3.4, 3.5, 3.6, 3.7, 3.8, 3.9
+ println('\n--- Property 7: SSE 事件格式化正确性 ---')
+ stats.run_property_test('Property 7a: Data field formatting', test_property_7a_data_field_formatting)
+ stats.run_property_test('Property 7b: Multi-line data formatting', test_property_7b_multiline_data_formatting)
+ stats.run_property_test('Property 7c: Event field before data', test_property_7c_event_field_before_data)
+ stats.run_property_test('Property 7d: Empty event field omitted', test_property_7d_empty_event_field_omitted)
+ stats.run_property_test('Property 7e: ID field after data', test_property_7e_id_field_after_data)
+ stats.run_property_test('Property 7f: Empty ID field omitted', test_property_7f_empty_id_field_omitted)
+ stats.run_property_test('Property 7g: Retry field when positive', test_property_7g_retry_field_when_positive)
+ stats.run_property_test('Property 7h: Zero retry field omitted', test_property_7h_zero_retry_field_omitted)
+ stats.run_property_test('Property 7i: Event separator present', test_property_7i_event_separator_present)
+ stats.run_property_test('Property 7j: Complete event format', test_property_7j_complete_event_format)
+ stats.run_property_test('Property 7k: Random events formatted correctly', test_property_7k_random_events_formatted_correctly)
+
+ // Print summary
+ stats.print_summary()
+}
diff --git a/tests/test_stream_text_properties.v b/tests/test_stream_text_properties.v
new file mode 100644
index 0000000..2aefa94
--- /dev/null
+++ b/tests/test_stream_text_properties.v
@@ -0,0 +1,648 @@
+// test_stream_text_properties.v
+// Property-Based Testing for stream_text() function
+// Feature: sse-streaming-helper
+// Property 3: streamText() 设置正确的 HTTP 头
+// Property 4: writeln() 添加换行符
+// Property 5: sleep() 暂停正确的时间
+// Validates: Requirements 2.1, 2.2, 2.3, 2.5, 2.6
+module main
+
+import rand
+import time
+
+const test_iterations = 100
+
+struct PropertyTestStats {
+mut:
+ total_tests int
+ passed_tests int
+ failed_tests int
+}
+
+fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
+ stats.total_tests++
+ print('🔬 ${test_name}... ')
+
+ if test_func() {
+ stats.passed_tests++
+ println('✅')
+ } else {
+ stats.failed_tests++
+ println('❌')
+ }
+}
+
+fn (stats PropertyTestStats) print_summary() {
+ println('\n=== stream_text() 函数属性测试总结 ===')
+ println('总测试数: ${stats.total_tests}')
+ println('通过: ${stats.passed_tests}')
+ println('失败: ${stats.failed_tests}')
+
+ if stats.failed_tests == 0 {
+ println('🎉 所有属性测试通过!')
+ } else {
+ println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
+ }
+}
+
+// ============================================================================
+// Mock StreamWriter for Testing
+// ============================================================================
+
+// MockStreamWriter - A mock implementation of StreamWriter for testing
+// Captures all written data for verification
+struct MockStreamWriter {
+mut:
+ written_data []u8
+ connected bool
+ closed bool
+ flush_count int
+}
+
+fn MockStreamWriter.new() &MockStreamWriter {
+ return &MockStreamWriter{
+ written_data: []u8{}
+ connected: true
+ closed: false
+ flush_count: 0
+ }
+}
+
+fn (mut w MockStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ // Write chunked format: size in hex, CRLF, data, CRLF
+ chunk_header := '${data.len:x}\r\n'
+ w.written_data << chunk_header.bytes()
+ w.written_data << data.bytes()
+ w.written_data << '\r\n'.bytes()
+}
+
+fn (mut w MockStreamWriter) flush() ! {
+ w.flush_count++
+}
+
+fn (mut w MockStreamWriter) close() ! {
+ if w.connected {
+ // Write the final chunk (zero-length chunk) to signal end of stream
+ w.written_data << '0\r\n\r\n'.bytes()
+ w.connected = false
+ w.closed = true
+ }
+}
+
+fn (w MockStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+fn (w MockStreamWriter) get_written_string() string {
+ return w.written_data.bytestr()
+}
+
+// ============================================================================
+// StreamContext for Testing (simplified version)
+// ============================================================================
+
+@[heap]
+struct StreamContext {
+mut:
+ writer &MockStreamWriter
+ is_closed bool
+}
+
+fn StreamContext.new(writer &MockStreamWriter) StreamContext {
+ return StreamContext{
+ writer: unsafe { writer }
+ is_closed: false
+ }
+}
+
+fn (mut ctx StreamContext) write_string(data string) ! {
+ if ctx.is_closed {
+ return error('Stream is closed')
+ }
+ if !ctx.writer.is_connected() {
+ ctx.is_closed = true
+ return error('Connection lost')
+ }
+ ctx.writer.write_string(data)!
+}
+
+fn (mut ctx StreamContext) writeln(data string) ! {
+ ctx.write_string(data)!
+ ctx.write_string('\n')!
+}
+
+fn (ctx StreamContext) sleep(ms int) {
+ time.sleep(ms * time.millisecond)
+}
+
+fn (mut ctx StreamContext) close() {
+ if !ctx.is_closed {
+ ctx.is_closed = true
+ ctx.writer.close() or {}
+ }
+}
+
+// ============================================================================
+// Helper Functions
+// ============================================================================
+
+// get_stream_text_headers - Get the headers required for text streaming
+fn get_stream_text_headers() map[string]string {
+ return {
+ 'Content-Type': 'text/plain; charset=utf-8'
+ 'Transfer-Encoding': 'chunked'
+ 'X-Content-Type-Options': 'nosniff'
+ 'Connection': 'keep-alive'
+ }
+}
+
+// Generate random string for testing (printable ASCII only)
+fn generate_random_string(max_len int) string {
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-. '
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut result := ''
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result += chars[idx].ascii_str()
+ }
+ return result
+}
+
+// Parse hex string to int
+fn parse_hex(hex_str string) !int {
+ mut result := 0
+ for c in hex_str {
+ result *= 16
+ if c >= `0` && c <= `9` {
+ result += int(c - `0`)
+ } else if c >= `a` && c <= `f` {
+ result += int(c - `a` + 10)
+ } else if c >= `A` && c <= `F` {
+ result += int(c - `A` + 10)
+ } else {
+ return error('Invalid hex character: ${c.ascii_str()}')
+ }
+ }
+ return result
+}
+
+// Parse a chunked encoded string and extract all chunks
+fn parse_all_chunks(chunked string) !([]string, bool) {
+ mut chunks := []string{}
+ mut pos := 0
+
+ for pos < chunked.len {
+ // Find the size part (before \r\n)
+ mut crlf_pos := -1
+ for i := pos; i < chunked.len - 1; i++ {
+ if chunked[i] == `\r` && chunked[i + 1] == `\n` {
+ crlf_pos = i
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ return error('No CRLF found in chunk header at position ${pos}')
+ }
+
+ size_hex := chunked[pos..crlf_pos]
+ size := parse_hex(size_hex) or {
+ return error('Invalid hex size: ${size_hex}')
+ }
+
+ // Check for final chunk
+ if size == 0 {
+ // Verify final chunk format
+ remaining := chunked[pos..]
+ if remaining != '0\r\n\r\n' {
+ return error('Invalid final chunk format')
+ }
+ break
+ }
+
+ // Extract data after the \r\n
+ data_start := crlf_pos + 2
+ data_end := data_start + size
+
+ if data_end > chunked.len {
+ return error('Chunk data incomplete')
+ }
+
+ data := chunked[data_start..data_end]
+ chunks << data
+
+ // Verify trailing \r\n
+ if data_end + 2 > chunked.len {
+ return error('Missing trailing CRLF')
+ }
+
+ trailing := chunked[data_end..data_end + 2]
+ if trailing != '\r\n' {
+ return error('Invalid trailing CRLF')
+ }
+
+ pos = data_end + 2
+ }
+
+ return chunks, true
+}
+
+// ============================================================================
+// Property 3: streamText() 设置正确的 HTTP 头
+// Feature: sse-streaming-helper, Property 3
+// Validates: Requirements 2.1, 2.2, 2.3
+//
+// *For any* call to streamText(c, callback), the response SHALL contain:
+// - Content-Type: text/plain; charset=utf-8
+// - Transfer-Encoding: chunked
+// - X-Content-Type-Options: nosniff
+// ============================================================================
+
+// Test 3a: Content-Type header is set correctly
+fn test_property_3a_content_type_header() bool {
+ headers := get_stream_text_headers()
+
+ // Verify Content-Type header exists
+ if 'Content-Type' !in headers {
+ println(' Content-Type header missing')
+ return false
+ }
+
+ // Verify value is 'text/plain; charset=utf-8'
+ if headers['Content-Type'] != 'text/plain; charset=utf-8' {
+ println(' Content-Type should be "text/plain; charset=utf-8", got "${headers['Content-Type']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 3b: Transfer-Encoding header is set correctly
+fn test_property_3b_transfer_encoding_header() bool {
+ headers := get_stream_text_headers()
+
+ // Verify Transfer-Encoding header exists
+ if 'Transfer-Encoding' !in headers {
+ println(' Transfer-Encoding header missing')
+ return false
+ }
+
+ // Verify value is 'chunked'
+ if headers['Transfer-Encoding'] != 'chunked' {
+ println(' Transfer-Encoding should be "chunked", got "${headers['Transfer-Encoding']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 3c: X-Content-Type-Options header is set correctly
+fn test_property_3c_x_content_type_options_header() bool {
+ headers := get_stream_text_headers()
+
+ // Verify X-Content-Type-Options header exists
+ if 'X-Content-Type-Options' !in headers {
+ println(' X-Content-Type-Options header missing')
+ return false
+ }
+
+ // Verify value is 'nosniff'
+ if headers['X-Content-Type-Options'] != 'nosniff' {
+ println(' X-Content-Type-Options should be "nosniff", got "${headers['X-Content-Type-Options']}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 3d: Headers are consistent across multiple calls
+fn test_property_3d_headers_consistency() bool {
+ for _ in 0 .. test_iterations {
+ headers1 := get_stream_text_headers()
+ headers2 := get_stream_text_headers()
+
+ // Headers should be identical
+ if headers1['Content-Type'] != headers2['Content-Type'] {
+ println(' Content-Type inconsistent between calls')
+ return false
+ }
+
+ if headers1['Transfer-Encoding'] != headers2['Transfer-Encoding'] {
+ println(' Transfer-Encoding inconsistent between calls')
+ return false
+ }
+
+ if headers1['X-Content-Type-Options'] != headers2['X-Content-Type-Options'] {
+ println(' X-Content-Type-Options inconsistent between calls')
+ return false
+ }
+ }
+
+ return true
+}
+
+// ============================================================================
+// Property 4: writeln() 添加换行符
+// Feature: sse-streaming-helper, Property 4
+// Validates: Requirements 2.5
+//
+// *For any* string text, when stream.writeln(text) is called, the output
+// SHALL be text followed by a newline character \n.
+// ============================================================================
+
+// Test 4a: writeln() appends newline for any string
+fn test_property_4a_writeln_appends_newline() bool {
+ rand.seed([u32(time.now().unix()), u32(12345)])
+
+ for i in 0 .. test_iterations {
+ // Generate random text
+ text := generate_random_string(200)
+
+ // Create mock writer and context
+ mut writer := MockStreamWriter.new()
+ mut ctx := StreamContext.new(writer)
+
+ // Call writeln
+ ctx.writeln(text) or {
+ println(' Iteration ${i}: writeln failed: ${err}')
+ return false
+ }
+
+ // Close to finalize
+ ctx.close()
+
+ // Parse the written chunked data
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written) or {
+ println(' Iteration ${i}: Failed to parse chunks: ${err}')
+ return false
+ }
+
+ // Should have exactly 2 chunks (text and newline)
+ if chunks.len != 2 {
+ println(' Iteration ${i}: Expected 2 chunks, got ${chunks.len}')
+ return false
+ }
+
+ // First chunk should be the text
+ if chunks[0] != text {
+ println(' Iteration ${i}: First chunk should be text')
+ return false
+ }
+
+ // Second chunk should be newline
+ if chunks[1] != '\n' {
+ println(' Iteration ${i}: Second chunk should be newline, got "${chunks[1]}"')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 4b: writeln() with empty string still adds newline
+fn test_property_4b_writeln_empty_string() bool {
+ // Create mock writer and context
+ mut writer := MockStreamWriter.new()
+ mut ctx := StreamContext.new(writer)
+
+ // Call writeln with empty string - this should fail because write_string skips empty
+ // Actually, writeln calls write_string twice: once for data, once for \n
+ // Empty string write is skipped, but \n should still be written
+ ctx.writeln('') or {
+ println(' writeln empty string failed: ${err}')
+ return false
+ }
+
+ // Close to finalize
+ ctx.close()
+
+ // Parse the written chunked data
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written) or {
+ println(' Failed to parse chunks: ${err}')
+ return false
+ }
+
+ // Should have exactly 1 chunk (just the newline, empty string is skipped)
+ if chunks.len != 1 {
+ println(' Expected 1 chunk (newline only), got ${chunks.len}')
+ return false
+ }
+
+ // The chunk should be newline
+ if chunks[0] != '\n' {
+ println(' Chunk should be newline, got "${chunks[0]}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 4c: Multiple writeln calls produce correct output
+fn test_property_4c_multiple_writeln() bool {
+ rand.seed([u32(time.now().unix()), u32(54321)])
+
+ for i in 0 .. test_iterations {
+ // Generate multiple random lines
+ num_lines := rand.int_in_range(2, 5) or { 3 }
+ mut lines := []string{}
+
+ mut writer := MockStreamWriter.new()
+ mut ctx := StreamContext.new(writer)
+
+ for _ in 0 .. num_lines {
+ line := generate_random_string(50)
+ lines << line
+ ctx.writeln(line) or {
+ println(' Iteration ${i}: writeln failed: ${err}')
+ return false
+ }
+ }
+
+ // Close to finalize
+ ctx.close()
+
+ // Parse the written chunked data
+ written := writer.get_written_string()
+ chunks, _ := parse_all_chunks(written) or {
+ println(' Iteration ${i}: Failed to parse chunks: ${err}')
+ return false
+ }
+
+ // Should have 2 chunks per line (text + newline)
+ expected_chunks := num_lines * 2
+ if chunks.len != expected_chunks {
+ println(' Iteration ${i}: Expected ${expected_chunks} chunks, got ${chunks.len}')
+ return false
+ }
+
+ // Verify each line and newline pair
+ for j, line in lines {
+ text_idx := j * 2
+ newline_idx := j * 2 + 1
+
+ if chunks[text_idx] != line {
+ println(' Iteration ${i}: Line ${j} text mismatch')
+ return false
+ }
+
+ if chunks[newline_idx] != '\n' {
+ println(' Iteration ${i}: Line ${j} newline missing')
+ return false
+ }
+ }
+ }
+
+ return true
+}
+
+// ============================================================================
+// Property 5: sleep() 暂停正确的时间
+// Feature: sse-streaming-helper, Property 5
+// Validates: Requirements 2.6
+//
+// *For any* positive integer ms, when stream.sleep(ms) is called, the execution
+// SHALL pause for approximately ms milliseconds (within ±10% tolerance).
+// ============================================================================
+
+// Test 5a: sleep() pauses for approximately the specified time
+fn test_property_5a_sleep_duration() bool {
+ // Test with a few specific durations to avoid long test times
+ test_durations := [10, 20, 50]
+
+ for duration in test_durations {
+ mut writer := MockStreamWriter.new()
+ ctx := StreamContext.new(writer)
+
+ // Measure time before and after sleep
+ start := time.now()
+ ctx.sleep(duration)
+ elapsed := time.now() - start
+
+ elapsed_ms := elapsed.milliseconds()
+
+ // Allow generous tolerance for system overhead (±50% plus overhead)
+ // Sleep timing can vary significantly depending on system load
+ min_expected := int(f64(duration) * 0.5)
+ max_expected := int(f64(duration) * 2.0) + 20
+
+ if elapsed_ms < min_expected || elapsed_ms > max_expected {
+ println(' sleep(${duration}) took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 5b: sleep(0) returns immediately
+fn test_property_5b_sleep_zero() bool {
+ mut writer := MockStreamWriter.new()
+ ctx := StreamContext.new(writer)
+
+ // Measure time for sleep(0)
+ start := time.now()
+ ctx.sleep(0)
+ elapsed := time.now() - start
+
+ elapsed_ms := elapsed.milliseconds()
+
+ // sleep(0) should return very quickly (within 10ms)
+ if elapsed_ms > 10 {
+ println(' sleep(0) took ${elapsed_ms}ms, expected < 10ms')
+ return false
+ }
+
+ return true
+}
+
+// Test 5c: Multiple sleeps accumulate correctly
+fn test_property_5c_multiple_sleeps() bool {
+ mut writer := MockStreamWriter.new()
+ ctx := StreamContext.new(writer)
+
+ // Sleep multiple times
+ sleep_duration := 10
+ num_sleeps := 3
+
+ start := time.now()
+ for _ in 0 .. num_sleeps {
+ ctx.sleep(sleep_duration)
+ }
+ elapsed := time.now() - start
+
+ elapsed_ms := elapsed.milliseconds()
+ expected_total := sleep_duration * num_sleeps
+
+ // Allow generous tolerance for system overhead (±50% plus overhead)
+ // Sleep timing can vary significantly depending on system load
+ min_expected := int(f64(expected_total) * 0.5)
+ max_expected := int(f64(expected_total) * 2.0) + 30
+
+ if elapsed_ms < min_expected || elapsed_ms > max_expected {
+ println(' ${num_sleeps} sleeps of ${sleep_duration}ms took ${elapsed_ms}ms, expected ${min_expected}-${max_expected}ms')
+ return false
+ }
+
+ return true
+}
+
+fn main() {
+ println('🚀 开始 stream_text() 函数属性测试...')
+ println('每个属性测试运行 ${test_iterations} 次迭代\n')
+
+ mut stats := PropertyTestStats{}
+
+ // Run Property 3 tests
+ // Feature: sse-streaming-helper, Property 3: streamText() 设置正确的 HTTP 头
+ // Validates: Requirements 2.1, 2.2, 2.3
+ println('--- Property 3: streamText() 设置正确的 HTTP 头 ---')
+ stats.run_property_test('Property 3a: Content-Type header', test_property_3a_content_type_header)
+ stats.run_property_test('Property 3b: Transfer-Encoding header', test_property_3b_transfer_encoding_header)
+ stats.run_property_test('Property 3c: X-Content-Type-Options header', test_property_3c_x_content_type_options_header)
+ stats.run_property_test('Property 3d: Headers consistency', test_property_3d_headers_consistency)
+
+ println('')
+
+ // Run Property 4 tests
+ // Feature: sse-streaming-helper, Property 4: writeln() 添加换行符
+ // Validates: Requirements 2.5
+ println('--- Property 4: writeln() 添加换行符 ---')
+ stats.run_property_test('Property 4a: writeln() appends newline', test_property_4a_writeln_appends_newline)
+ stats.run_property_test('Property 4b: writeln() empty string', test_property_4b_writeln_empty_string)
+ stats.run_property_test('Property 4c: Multiple writeln calls', test_property_4c_multiple_writeln)
+
+ println('')
+
+ // Run Property 5 tests
+ // Feature: sse-streaming-helper, Property 5: sleep() 暂停正确的时间
+ // Validates: Requirements 2.6
+ println('--- Property 5: sleep() 暂停正确的时间 ---')
+ stats.run_property_test('Property 5a: sleep() duration', test_property_5a_sleep_duration)
+ stats.run_property_test('Property 5b: sleep(0) returns immediately', test_property_5b_sleep_zero)
+ stats.run_property_test('Property 5c: Multiple sleeps accumulate', test_property_5c_multiple_sleeps)
+
+ // Print summary
+ stats.print_summary()
+}
diff --git a/tests/test_usockets_stream_writer_properties.v b/tests/test_usockets_stream_writer_properties.v
new file mode 100644
index 0000000..b0d09c2
--- /dev/null
+++ b/tests/test_usockets_stream_writer_properties.v
@@ -0,0 +1,624 @@
+// test_usockets_stream_writer_properties.v
+// Property-Based Testing for UsocketsStreamWriter chunked encoding
+// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
+// Validates: Requirements 7.2
+module main
+
+import rand
+import time
+
+const test_iterations = 100
+
+struct PropertyTestStats {
+mut:
+ total_tests int
+ passed_tests int
+ failed_tests int
+}
+
+fn (mut stats PropertyTestStats) run_property_test(test_name string, test_func fn () bool) {
+ stats.total_tests++
+ print('🔬 ${test_name}... ')
+
+ if test_func() {
+ stats.passed_tests++
+ println('✅')
+ } else {
+ stats.failed_tests++
+ println('❌')
+ }
+}
+
+fn (stats PropertyTestStats) print_summary() {
+ println('\n=== UsocketsStreamWriter 属性测试总结 ===')
+ println('总测试数: ${stats.total_tests}')
+ println('通过: ${stats.passed_tests}')
+ println('失败: ${stats.failed_tests}')
+
+ if stats.failed_tests == 0 {
+ println('🎉 所有属性测试通过!')
+ } else {
+ println('⚠️ 有 ${stats.failed_tests} 个属性测试失败')
+ }
+}
+
+
+// ============================================================================
+// Chunked Encoding Functions (copied from streaming.v for testing)
+// ============================================================================
+
+// format_chunk_size - Format the chunk size as hex followed by CRLF
+@[inline]
+fn format_chunk_size(size int) string {
+ return '${size:x}\r\n'
+}
+
+// format_chunk - Format a complete chunk with size, data, and trailing CRLF
+fn format_chunk(data []u8) string {
+ if data.len == 0 {
+ return ''
+ }
+ return '${data.len:x}\r\n${data.bytestr()}\r\n'
+}
+
+// format_chunk_string - Format a string as a complete chunk
+fn format_chunk_string(data string) string {
+ if data.len == 0 {
+ return ''
+ }
+ return '${data.len:x}\r\n${data}\r\n'
+}
+
+// format_final_chunk - Return the final chunk marker for chunked encoding
+fn format_final_chunk() string {
+ return '0\r\n\r\n'
+}
+
+// ============================================================================
+// Random Data Generators
+// ============================================================================
+
+// Generate random bytes for testing
+fn generate_random_bytes(max_len int) []u8 {
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut data := []u8{len: len}
+ for i in 0 .. len {
+ data[i] = u8(rand.int_in_range(0, 256) or { 0 })
+ }
+ return data
+}
+
+// Generate random string for testing (printable ASCII only)
+fn generate_random_string(max_len int) string {
+ chars := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'
+ len := rand.int_in_range(1, max_len) or { 10 }
+ mut result := ''
+ for _ in 0 .. len {
+ idx := rand.int_in_range(0, chars.len) or { 0 }
+ result += chars[idx].ascii_str()
+ }
+ return result
+}
+
+// ============================================================================
+// Chunk Parsing Helper
+// ============================================================================
+
+// Parse a chunked encoded string and extract the data
+fn parse_chunked_data(chunked string) !([]u8, bool) {
+ mut crlf_pos := -1
+ for i := 0; i < chunked.len - 1; i++ {
+ if chunked[i] == `\r` && chunked[i + 1] == `\n` {
+ crlf_pos = i
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ return error('No CRLF found in chunk header')
+ }
+
+ size_hex := chunked[..crlf_pos]
+ size := parse_hex(size_hex) or {
+ return error('Invalid hex size: ${size_hex}')
+ }
+
+ data_start := crlf_pos + 2
+ data_end := data_start + size
+
+ if data_end > chunked.len {
+ return error('Chunk data incomplete')
+ }
+
+ data := chunked[data_start..data_end].bytes()
+
+ if data_end + 2 > chunked.len {
+ return error('Missing trailing CRLF')
+ }
+
+ trailing := chunked[data_end..data_end + 2]
+ if trailing != '\r\n' {
+ return error('Invalid trailing CRLF: got "${trailing}"')
+ }
+
+ return data, true
+}
+
+// Parse hex string to int
+fn parse_hex(hex_str string) !int {
+ mut result := 0
+ for c in hex_str {
+ result *= 16
+ if c >= `0` && c <= `9` {
+ result += int(c - `0`)
+ } else if c >= `a` && c <= `f` {
+ result += int(c - `a` + 10)
+ } else if c >= `A` && c <= `F` {
+ result += int(c - `A` + 10)
+ } else {
+ return error('Invalid hex character: ${c.ascii_str()}')
+ }
+ }
+ return result
+}
+
+
+// ============================================================================
+// Mock Socket for Testing UsocketsStreamWriter
+// ============================================================================
+
+// MockSocket - A mock socket that captures written data for testing
+struct MockSocket {
+mut:
+ buffer string
+ is_closed bool
+}
+
+fn MockSocket.new() MockSocket {
+ return MockSocket{
+ buffer: ''
+ is_closed: false
+ }
+}
+
+fn (mut s MockSocket) write_bytes(data string) int {
+ if s.is_closed {
+ return 0
+ }
+ s.buffer += data
+ return data.len
+}
+
+fn (s MockSocket) get_buffer() string {
+ return s.buffer
+}
+
+fn (mut s MockSocket) clear() {
+ s.buffer = ''
+}
+
+// ============================================================================
+// UsocketsStreamWriter Simulation for Testing
+// Uses the same chunked encoding logic as the real implementation
+// ============================================================================
+
+struct TestUsocketsStreamWriter {
+mut:
+ socket &MockSocket
+ connected bool
+}
+
+fn TestUsocketsStreamWriter.new(socket &MockSocket) TestUsocketsStreamWriter {
+ return TestUsocketsStreamWriter{
+ socket: unsafe { socket }
+ connected: true
+ }
+}
+
+fn (mut w TestUsocketsStreamWriter) write(data []u8) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ chunk_header := format_chunk_size(data.len)
+ w.socket.write_bytes(chunk_header)
+ w.socket.write_bytes(data.bytestr())
+ w.socket.write_bytes('\r\n')
+}
+
+fn (mut w TestUsocketsStreamWriter) write_string(data string) ! {
+ if !w.connected {
+ return error('Connection closed')
+ }
+ if data.len == 0 {
+ return
+ }
+ chunk_header := format_chunk_size(data.len)
+ w.socket.write_bytes(chunk_header)
+ w.socket.write_bytes(data)
+ w.socket.write_bytes('\r\n')
+}
+
+fn (mut w TestUsocketsStreamWriter) close() ! {
+ if w.connected {
+ w.socket.write_bytes('0\r\n\r\n')
+ w.connected = false
+ }
+}
+
+fn (w TestUsocketsStreamWriter) is_connected() bool {
+ return w.connected
+}
+
+
+// ============================================================================
+// Property 10: Chunked 编码格式正确性
+// Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
+// Validates: Requirements 7.2
+//
+// *For any* data written to the stream, the output SHALL follow HTTP chunked
+// transfer encoding format:
+// - Each chunk starts with the hex size followed by \r\n
+// - Chunk data followed by \r\n
+// - Final chunk is 0\r\n\r\n
+// ============================================================================
+
+// Test 10a: UsocketsStreamWriter.write produces valid chunked encoding for bytes
+fn test_property_10a_usockets_write_bytes() bool {
+ rand.seed([u32(time.now().unix()), u32(12345)])
+
+ for i in 0 .. test_iterations {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Generate random data
+ data := generate_random_bytes(1000)
+
+ // Write using the writer
+ writer.write(data) or {
+ println(' Iteration ${i}: Write failed: ${err}')
+ return false
+ }
+
+ // Get the output
+ output := mock_socket.get_buffer()
+
+ // Verify chunk format
+ mut crlf_pos := -1
+ for j := 0; j < output.len - 1; j++ {
+ if output[j] == `\r` && output[j + 1] == `\n` {
+ crlf_pos = j
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ println(' Iteration ${i}: No CRLF found in chunk header')
+ return false
+ }
+
+ size_hex := output[..crlf_pos]
+ parsed_size := parse_hex(size_hex) or {
+ println(' Iteration ${i}: Invalid hex size: ${size_hex}')
+ return false
+ }
+
+ if parsed_size != data.len {
+ println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
+ return false
+ }
+
+ // Extract and verify data
+ extracted_data, _ := parse_chunked_data(output) or {
+ println(' Iteration ${i}: Failed to parse chunk: ${err}')
+ return false
+ }
+
+ if extracted_data != data {
+ println(' Iteration ${i}: Data mismatch after extraction')
+ return false
+ }
+ }
+
+ return true
+}
+
+// Test 10b: UsocketsStreamWriter.write_string produces valid chunked encoding
+fn test_property_10b_usockets_write_string() bool {
+ rand.seed([u32(time.now().unix()), u32(54321)])
+
+ for i in 0 .. test_iterations {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Generate random string
+ data := generate_random_string(500)
+
+ // Write using the writer
+ writer.write_string(data) or {
+ println(' Iteration ${i}: Write failed: ${err}')
+ return false
+ }
+
+ // Get the output
+ output := mock_socket.get_buffer()
+
+ // Verify chunk format
+ mut crlf_pos := -1
+ for j := 0; j < output.len - 1; j++ {
+ if output[j] == `\r` && output[j + 1] == `\n` {
+ crlf_pos = j
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ println(' Iteration ${i}: No CRLF found in chunk header')
+ return false
+ }
+
+ size_hex := output[..crlf_pos]
+ parsed_size := parse_hex(size_hex) or {
+ println(' Iteration ${i}: Invalid hex size: ${size_hex}')
+ return false
+ }
+
+ if parsed_size != data.len {
+ println(' Iteration ${i}: Size mismatch - expected ${data.len}, got ${parsed_size}')
+ return false
+ }
+
+ // Extract and verify data
+ extracted_data, _ := parse_chunked_data(output) or {
+ println(' Iteration ${i}: Failed to parse chunk: ${err}')
+ return false
+ }
+
+ if extracted_data.bytestr() != data {
+ println(' Iteration ${i}: String mismatch after extraction')
+ return false
+ }
+ }
+
+ return true
+}
+
+
+// Test 10c: UsocketsStreamWriter.close produces correct final chunk
+fn test_property_10c_usockets_close_final_chunk() bool {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Close the writer
+ writer.close() or {
+ println(' Close failed: ${err}')
+ return false
+ }
+
+ // Get the output
+ output := mock_socket.get_buffer()
+
+ // Final chunk must be exactly "0\r\n\r\n"
+ expected := '0\r\n\r\n'
+
+ if output != expected {
+ println(' Final chunk mismatch - expected "${expected}", got "${output}"')
+ return false
+ }
+
+ // Verify writer is now disconnected
+ if writer.is_connected() {
+ println(' Writer should be disconnected after close')
+ return false
+ }
+
+ return true
+}
+
+// Test 10d: UsocketsStreamWriter handles empty data correctly
+fn test_property_10d_usockets_empty_data() bool {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Write empty byte array
+ writer.write([]u8{}) or {
+ println(' Empty write failed: ${err}')
+ return false
+ }
+
+ // Buffer should be empty (no chunk for empty data)
+ if mock_socket.get_buffer() != '' {
+ println(' Empty bytes should produce no output, got "${mock_socket.get_buffer()}"')
+ return false
+ }
+
+ // Write empty string
+ writer.write_string('') or {
+ println(' Empty string write failed: ${err}')
+ return false
+ }
+
+ // Buffer should still be empty
+ if mock_socket.get_buffer() != '' {
+ println(' Empty string should produce no output, got "${mock_socket.get_buffer()}"')
+ return false
+ }
+
+ return true
+}
+
+// Test 10e: UsocketsStreamWriter rejects writes after close
+fn test_property_10e_usockets_write_after_close() bool {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Close the writer
+ writer.close() or {
+ println(' Close failed: ${err}')
+ return false
+ }
+
+ mock_socket.clear()
+
+ // Try to write after close - should fail
+ writer.write([u8(1), 2, 3]) or {
+ // Expected error
+ return true
+ }
+
+ println(' Write after close should have failed')
+ return false
+}
+
+// Test 10f: UsocketsStreamWriter multiple writes produce valid chunks
+fn test_property_10f_usockets_multiple_writes() bool {
+ rand.seed([u32(time.now().unix()), u32(99999)])
+
+ for i in 0 .. test_iterations {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Generate multiple random data chunks
+ num_chunks := rand.int_in_range(2, 10) or { 3 }
+ mut all_data := [][]u8{}
+
+ for _ in 0 .. num_chunks {
+ data := generate_random_bytes(100)
+ all_data << data
+ writer.write(data) or {
+ println(' Iteration ${i}: Write failed: ${err}')
+ return false
+ }
+ }
+
+ // Close to add final chunk
+ writer.close() or {
+ println(' Iteration ${i}: Close failed: ${err}')
+ return false
+ }
+
+ // Parse all chunks from output
+ output := mock_socket.get_buffer()
+ mut pos := 0
+ mut chunk_idx := 0
+
+ for pos < output.len && chunk_idx < all_data.len {
+ // Find CRLF for size
+ mut crlf_pos := -1
+ for j := pos; j < output.len - 1; j++ {
+ if output[j] == `\r` && output[j + 1] == `\n` {
+ crlf_pos = j
+ break
+ }
+ }
+
+ if crlf_pos == -1 {
+ println(' Iteration ${i}: No CRLF found at chunk ${chunk_idx}')
+ return false
+ }
+
+ size_hex := output[pos..crlf_pos]
+ size := parse_hex(size_hex) or {
+ println(' Iteration ${i}: Invalid hex at chunk ${chunk_idx}')
+ return false
+ }
+
+ // Check for final chunk
+ if size == 0 {
+ break
+ }
+
+ // Verify size matches expected
+ if size != all_data[chunk_idx].len {
+ println(' Iteration ${i}: Size mismatch at chunk ${chunk_idx}')
+ return false
+ }
+
+ // Extract data
+ data_start := crlf_pos + 2
+ data_end := data_start + size
+ extracted := output[data_start..data_end].bytes()
+
+ if extracted != all_data[chunk_idx] {
+ println(' Iteration ${i}: Data mismatch at chunk ${chunk_idx}')
+ return false
+ }
+
+ // Move to next chunk (skip trailing CRLF)
+ pos = data_end + 2
+ chunk_idx++
+ }
+
+ // Verify all chunks were found
+ if chunk_idx != all_data.len {
+ println(' Iteration ${i}: Expected ${all_data.len} chunks, found ${chunk_idx}')
+ return false
+ }
+
+ // Verify final chunk exists
+ if !output.ends_with('0\r\n\r\n') {
+ println(' Iteration ${i}: Missing final chunk')
+ return false
+ }
+ }
+
+ return true
+}
+
+
+// Test 10g: Round-trip property for UsocketsStreamWriter
+fn test_property_10g_usockets_roundtrip() bool {
+ rand.seed([u32(time.now().unix()), u32(77777)])
+
+ for i in 0 .. test_iterations {
+ mut mock_socket := MockSocket.new()
+ mut writer := TestUsocketsStreamWriter.new(&mock_socket)
+
+ // Generate random data
+ original := generate_random_bytes(500)
+
+ // Write using the writer
+ writer.write(original) or {
+ println(' Iteration ${i}: Write failed: ${err}')
+ return false
+ }
+
+ // Parse back
+ output := mock_socket.get_buffer()
+ recovered, _ := parse_chunked_data(output) or {
+ println(' Iteration ${i}: Failed to parse: ${err}')
+ return false
+ }
+
+ // Verify round-trip
+ if recovered != original {
+ println(' Iteration ${i}: Round-trip failed - data mismatch')
+ return false
+ }
+ }
+
+ return true
+}
+
+fn main() {
+ println('🚀 开始 UsocketsStreamWriter 属性测试...')
+ println('每个属性测试运行 ${test_iterations} 次迭代\n')
+
+ mut stats := PropertyTestStats{}
+
+ // Run Property 10 tests for UsocketsStreamWriter
+ // Feature: sse-streaming-helper, Property 10: Chunked 编码格式正确性
+ // Validates: Requirements 7.2
+ stats.run_property_test('Property 10a: UsocketsStreamWriter.write bytes encoding', test_property_10a_usockets_write_bytes)
+ stats.run_property_test('Property 10b: UsocketsStreamWriter.write_string encoding', test_property_10b_usockets_write_string)
+ stats.run_property_test('Property 10c: UsocketsStreamWriter.close final chunk', test_property_10c_usockets_close_final_chunk)
+ stats.run_property_test('Property 10d: UsocketsStreamWriter empty data handling', test_property_10d_usockets_empty_data)
+ stats.run_property_test('Property 10e: UsocketsStreamWriter write after close', test_property_10e_usockets_write_after_close)
+ stats.run_property_test('Property 10f: UsocketsStreamWriter multiple writes', test_property_10f_usockets_multiple_writes)
+ stats.run_property_test('Property 10g: UsocketsStreamWriter round-trip', test_property_10g_usockets_roundtrip)
+
+ // Print summary
+ stats.print_summary()
+}
diff --git a/usockets_server.v b/usockets_server.v
index cdf9094..9996f7f 100644
--- a/usockets_server.v
+++ b/usockets_server.v
@@ -577,6 +577,13 @@ fn exec_middlewares_usockets(idx int, middlewares []ContextMiddleware, mut ctx C
// 发送 uSockets 响应 - 优化版
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
+ // Check if this is a streaming response
+ if is_streaming_response(ctx) {
+ // Handle streaming response
+ handle_usockets_streaming_response(s, ctx)
+ return
+ }
+
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
mut resp := strings.new_builder(512)
@@ -633,6 +640,100 @@ fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response
s.write_bytes(resp.str())
}
+// ============================================================================
+// Streaming Response Handling for uSockets
+// ============================================================================
+
+// handle_usockets_streaming_response - Handle a streaming response using UsocketsStreamWriter
+// This function:
+// 1. Sends the HTTP headers for streaming
+// 2. Creates a UsocketsStreamWriter
+// 3. Executes the streaming callback
+// 4. Handles errors and cleanup
+//
+// Requirements: 7.1, 7.2, 7.3, 7.4
+fn handle_usockets_streaming_response(s usockets.Socket, ctx Context) {
+ // Get stream configuration
+ stream_config := get_stream_config(ctx) or {
+ // Fallback to error response if stream config not found
+ s.write_bytes('HTTP/1.1 500 Internal Server Error\r\nContent-Type: text/plain\r\nContent-Length: 32\r\n\r\nStream configuration not found')
+ return
+ }
+
+ // Determine status code
+ status_code := if ctx.status_code != 0 { ctx.status_code } else { 200 }
+
+ // Build HTTP response headers
+ mut resp := strings.new_builder(512)
+
+ // Status line
+ resp.write_string('HTTP/1.1 ')
+ resp.write_string(status_code.str())
+ resp.write_string(' ')
+ resp.write_string(get_status_text_usockets(status_code))
+ resp.write_string('\r\n')
+
+ // Send headers from context
+ mut has_transfer_encoding := false
+ mut has_connection := false
+
+ for key, value in ctx.headers {
+ key_len := key.len
+ // Skip Content-Length for streaming (we use chunked encoding)
+ if key_len == 14 && eq_ignore_case_usockets(key, 'content-length') {
+ continue
+ }
+ resp.write_string(key)
+ resp.write_string(': ')
+ resp.write_string(value)
+ resp.write_string('\r\n')
+ if key_len == 17 && eq_ignore_case_usockets(key, 'transfer-encoding') {
+ has_transfer_encoding = true
+ } else if key_len == 10 && eq_ignore_case_usockets(key, 'connection') {
+ has_connection = true
+ }
+ }
+
+ // Ensure Transfer-Encoding: chunked is set (Requirement 7.2)
+ if !has_transfer_encoding {
+ resp.write_string('Transfer-Encoding: chunked\r\n')
+ }
+
+ // Ensure Connection header is set for streaming
+ if !has_connection {
+ resp.write_string('Connection: keep-alive\r\n')
+ }
+
+ // End headers section
+ resp.write_string('\r\n')
+
+ // Send headers to client
+ s.write_bytes(resp.str())
+
+ // Create UsocketsStreamWriter for streaming (Requirement 7.1)
+ mut writer := UsocketsStreamWriter.new(s)
+
+ // Create StreamContext with the writer
+ mut stream_ctx := StreamContext.new(writer)
+
+ // Execute the streaming callback (Requirement 7.4 - compatibility with callback architecture)
+ stream_config.callback(mut stream_ctx) or {
+ // Handle error (Requirement 7.3 - handle connection close events)
+ if error_handler := stream_config.error_handler {
+ error_handler(err, mut stream_ctx)
+ } else {
+ // Default: log error to console
+ eprintln('[uSockets Stream Error] ${err.msg()}')
+ }
+ }
+
+ // Auto-close the stream when callback completes
+ stream_ctx.close()
+
+ // Cleanup the stored configuration
+ cleanup_stream_config(ctx)
+}
+
// 大小写不敏感字符串比较(避免分配)
@[inline]
fn eq_ignore_case_usockets(a string, b string) bool {
diff --git a/v.mod b/v.mod
index b3350a2..b6f4cfe 100644
--- a/v.mod
+++ b/v.mod
@@ -1,7 +1,7 @@
Module {
name: 'hono'
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
- version: '0.6.0'
+ version: '0.7.0'
license: 'MIT'
dependencies: []
}