Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0969901481 | ||
|
|
6bddfc3731 | ||
|
|
26ac804b1d | ||
|
|
56299b5485 | ||
|
|
d31492fd59 | ||
|
|
e19f799e46 | ||
|
|
f7e753969d | ||
|
|
e9d14a35f6 | ||
|
|
9ae9170bcb |
+2
-2
@@ -2,7 +2,6 @@
|
|||||||
*.exe
|
*.exe
|
||||||
*.out
|
*.out
|
||||||
*.o
|
*.o
|
||||||
*.a
|
|
||||||
*.so
|
*.so
|
||||||
*.dll
|
*.dll
|
||||||
|
|
||||||
@@ -46,4 +45,5 @@ temp/
|
|||||||
# Chunk upload temp files
|
# Chunk upload temp files
|
||||||
chunks/
|
chunks/
|
||||||
|
|
||||||
.kiro
|
.kiro
|
||||||
|
*.def
|
||||||
@@ -19,6 +19,7 @@ A high-performance V language web framework inspired by [Hono.js](https://hono.d
|
|||||||
- 📤 **File Upload** - Chunked file upload support
|
- 📤 **File Upload** - Chunked file upload support
|
||||||
- 🗄️ **Database** - SQLite integration for data persistence
|
- 🗄️ **Database** - SQLite integration for data persistence
|
||||||
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
|
- 🔌 **WebSocket** - RFC 6455 compliant WebSocket support with event-based API
|
||||||
|
- 📡 **SSE Streaming** - Server-Sent Events and streaming response support
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -784,6 +785,251 @@ fn main() {
|
|||||||
| 1009 | `ws_close_message_too_big` | Message too big |
|
| 1009 | `ws_close_message_too_big` | Message too big |
|
||||||
| 1011 | `ws_close_internal_error` | Internal server error |
|
| 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
|
||||||
|
<script>
|
||||||
|
// Connect to SSE endpoint
|
||||||
|
const eventSource = new EventSource('/sse');
|
||||||
|
|
||||||
|
// Listen for specific event types
|
||||||
|
eventSource.addEventListener('connect', (e) => {
|
||||||
|
console.log('Connected:', e.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('update', (e) => {
|
||||||
|
console.log('Update:', e.data, 'ID:', e.lastEventId);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventSource.addEventListener('complete', (e) => {
|
||||||
|
console.log('Complete:', e.data);
|
||||||
|
eventSource.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
eventSource.onerror = (e) => {
|
||||||
|
console.error('SSE Error:', e);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
## Route Grouping
|
## Route Grouping
|
||||||
|
|
||||||
```v
|
```v
|
||||||
@@ -889,8 +1135,9 @@ v-hono/
|
|||||||
├── rate_limit.v # Rate limiting middleware
|
├── rate_limit.v # Rate limiting middleware
|
||||||
├── validator.v # Request validation
|
├── validator.v # Request validation
|
||||||
├── websocket.v # WebSocket helper (RFC 6455)
|
├── websocket.v # WebSocket helper (RFC 6455)
|
||||||
├── picoev_server.v # Picoev backend with WebSocket support
|
├── streaming.v # SSE streaming helper
|
||||||
├── usockets_server.v # uSockets backend with WebSocket support
|
├── picoev_server.v # Picoev backend with WebSocket/SSE support
|
||||||
|
├── usockets_server.v # uSockets backend with WebSocket/SSE support
|
||||||
├── v.mod # Module definition
|
├── v.mod # Module definition
|
||||||
├── README.md # Documentation
|
├── README.md # Documentation
|
||||||
├── examples/ # Example applications
|
├── examples/ # Example applications
|
||||||
|
|||||||
@@ -448,21 +448,49 @@ func checkKeepAlive(client *http.Client) bool {
|
|||||||
|
|
||||||
func checkThroughput(client *http.Client) float64 {
|
func checkThroughput(client *http.Client) float64 {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
success := 0
|
concurrency := 1000
|
||||||
requests := 200
|
totalRequests := 1000000
|
||||||
|
requestsPerWorker := totalRequests / concurrency
|
||||||
|
|
||||||
for i := 0; i < requests; i++ {
|
// 创建共享的 Transport,启用连接池复用
|
||||||
resp, err := client.Get(baseURL + "/health")
|
transport := &http.Transport{
|
||||||
if err != nil {
|
MaxIdleConns: concurrency,
|
||||||
continue
|
MaxIdleConnsPerHost: concurrency,
|
||||||
}
|
IdleConnTimeout: 30 * time.Second,
|
||||||
io.Copy(io.Discard, resp.Body)
|
DisableKeepAlives: false,
|
||||||
resp.Body.Close()
|
}
|
||||||
if resp.StatusCode == 200 {
|
|
||||||
success++
|
results := make(chan int, concurrency)
|
||||||
}
|
|
||||||
|
for i := 0; i < concurrency; i++ {
|
||||||
|
go func() {
|
||||||
|
localSuccess := 0
|
||||||
|
// 每个 goroutine 使用独立 Client,但共享 Transport(连接池)
|
||||||
|
localClient := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
for j := 0; j < requestsPerWorker; j++ {
|
||||||
|
resp, err := localClient.Get(baseURL + "/health")
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == 200 {
|
||||||
|
localSuccess++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results <- localSuccess
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
success := 0
|
||||||
|
for i := 0; i < concurrency; i++ {
|
||||||
|
success += <-results
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed := time.Since(start).Seconds()
|
elapsed := time.Since(start).Seconds()
|
||||||
|
fmt.Printf("\n [并发: %d, 总请求: %d, 成功: %d, 耗时: %.2fs]\n ", concurrency, totalRequests, success, elapsed)
|
||||||
return float64(success) / elapsed
|
return float64(success) / elapsed
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 '<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>v-hono SSE Streaming Demo</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1000px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
|
||||||
|
h1 { color: #667eea; margin-bottom: 10px; }
|
||||||
|
h2 { color: #333; font-size: 1.2em; margin: 20px 0 10px; }
|
||||||
|
.subtitle { color: #666; margin-bottom: 30px; }
|
||||||
|
.card { background: white; padding: 20px; margin: 15px 0; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.endpoint { display: flex; align-items: center; gap: 10px; margin: 10px 0; flex-wrap: wrap; }
|
||||||
|
.endpoint-url { font-family: monospace; background: #f0f0f0; padding: 8px 12px; border-radius: 4px; flex: 1; min-width: 200px; }
|
||||||
|
button { padding: 8px 16px; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; transition: background 0.2s; }
|
||||||
|
.btn-connect { background: #667eea; color: white; }
|
||||||
|
.btn-connect:hover { background: #5a6fd6; }
|
||||||
|
.btn-disconnect { background: #e74c3c; color: white; }
|
||||||
|
.btn-disconnect:hover { background: #c0392b; }
|
||||||
|
.btn-clear { background: #95a5a6; color: white; }
|
||||||
|
.btn-clear:hover { background: #7f8c8d; }
|
||||||
|
.status { padding: 4px 8px; border-radius: 4px; font-size: 12px; font-weight: bold; }
|
||||||
|
.status-connected { background: #27ae60; color: white; }
|
||||||
|
.status-disconnected { background: #e74c3c; color: white; }
|
||||||
|
.status-connecting { background: #f39c12; color: white; }
|
||||||
|
.output { height: 200px; overflow-y: auto; background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; font-family: monospace; font-size: 13px; line-height: 1.5; }
|
||||||
|
.output:empty::before { content: "No messages yet..."; color: #666; }
|
||||||
|
.msg { margin: 2px 0; padding: 2px 0; }
|
||||||
|
.msg-event { color: #569cd6; }
|
||||||
|
.msg-data { color: #ce9178; }
|
||||||
|
.msg-id { color: #b5cea8; }
|
||||||
|
.msg-retry { color: #dcdcaa; }
|
||||||
|
.msg-system { color: #6a9955; font-style: italic; }
|
||||||
|
.msg-error { color: #f44747; }
|
||||||
|
.msg-text { color: #d4d4d4; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; }
|
||||||
|
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; } .endpoint { flex-direction: column; align-items: stretch; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>v-hono SSE Streaming Demo</h1>
|
||||||
|
<p class="subtitle">Test Server-Sent Events and streaming responses</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="card">
|
||||||
|
<h2>SSE Event Stream</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse${sq}, ${sq}sse-output${sq}, ${sq}sse-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}sse${sq})">Disconnect</button>
|
||||||
|
<span id="sse-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="sse-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}sse-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Multi-line SSE</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse-multiline</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse-multiline${sq}, ${sq}multiline-output${sq}, ${sq}multiline-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}multiline${sq})">Disconnect</button>
|
||||||
|
<span id="multiline-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="multiline-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}multiline-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Real-time Counter</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse-counter</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse-counter${sq}, ${sq}counter-output${sq}, ${sq}counter-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}counter${sq})">Disconnect</button>
|
||||||
|
<span id="counter-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="counter-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}counter-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Error Handling</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/sse-error</span>
|
||||||
|
<button class="btn-connect" onclick="connectSSE(${sq}/sse-error${sq}, ${sq}error-output${sq}, ${sq}error-status${sq})">Connect</button>
|
||||||
|
<button class="btn-disconnect" onclick="disconnectSSE(${sq}error${sq})">Disconnect</button>
|
||||||
|
<span id="error-status" class="status status-disconnected">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div id="error-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}error-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Text Stream</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/stream-text</span>
|
||||||
|
<button class="btn-connect" onclick="fetchStream(${sq}/stream-text${sq}, ${sq}text-output${sq}, ${sq}text-status${sq})">Fetch</button>
|
||||||
|
<span id="text-status" class="status status-disconnected">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div id="text-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}text-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Binary Stream</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-url">/stream</span>
|
||||||
|
<button class="btn-connect" onclick="fetchStream(${sq}/stream${sq}, ${sq}binary-output${sq}, ${sq}binary-status${sq})">Fetch</button>
|
||||||
|
<span id="binary-status" class="status status-disconnected">Ready</span>
|
||||||
|
</div>
|
||||||
|
<div id="binary-output" class="output"></div>
|
||||||
|
<button class="btn-clear" onclick="clearOutput(${sq}binary-output${sq})">Clear</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var connections = {};
|
||||||
|
|
||||||
|
function log(outputId, msg, type) {
|
||||||
|
var output = document.getElementById(outputId);
|
||||||
|
var div = document.createElement(${sq}div${sq});
|
||||||
|
div.className = ${sq}msg msg-${sq} + (type || ${sq}text${sq});
|
||||||
|
div.textContent = msg;
|
||||||
|
output.appendChild(div);
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(statusId, status) {
|
||||||
|
var el = document.getElementById(statusId);
|
||||||
|
el.textContent = status;
|
||||||
|
el.className = ${sq}status status-${sq} + status.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOutput(outputId) {
|
||||||
|
document.getElementById(outputId).innerHTML = ${sq}${sq};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectSSE(url, outputId, statusId) {
|
||||||
|
var key = outputId.replace(${sq}-output${sq}, ${sq}${sq});
|
||||||
|
if (connections[key]) {
|
||||||
|
connections[key].close();
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(statusId, ${sq}Connecting${sq});
|
||||||
|
log(outputId, ${sq}Connecting to ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
|
||||||
|
|
||||||
|
var es = new EventSource(url);
|
||||||
|
connections[key] = es;
|
||||||
|
|
||||||
|
es.onopen = function() {
|
||||||
|
setStatus(statusId, ${sq}Connected${sq});
|
||||||
|
log(outputId, ${sq}Connected!${sq}, ${sq}system${sq});
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onmessage = function(e) {
|
||||||
|
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
|
||||||
|
};
|
||||||
|
|
||||||
|
es.onerror = function() {
|
||||||
|
setStatus(statusId, ${sq}Disconnected${sq});
|
||||||
|
log(outputId, ${sq}Connection closed${sq}, ${sq}system${sq});
|
||||||
|
connections[key] = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for custom event types
|
||||||
|
[${sq}connect${sq}, ${sq}update${sq}, ${sq}complete${sq}, ${sq}multiline${sq}, ${sq}json${sq}, ${sq}message${sq}, ${sq}counter${sq}, ${sq}start${sq}, ${sq}error${sq}].forEach(function(type) {
|
||||||
|
es.addEventListener(type, function(e) {
|
||||||
|
log(outputId, ${sq}event: ${sq} + type, ${sq}event${sq});
|
||||||
|
log(outputId, ${sq}data: ${sq} + e.data, ${sq}data${sq});
|
||||||
|
if (e.lastEventId) log(outputId, ${sq}id: ${sq} + e.lastEventId, ${sq}id${sq});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnectSSE(key) {
|
||||||
|
if (connections[key]) {
|
||||||
|
connections[key].close();
|
||||||
|
connections[key] = null;
|
||||||
|
setStatus(key + ${sq}-status${sq}, ${sq}Disconnected${sq});
|
||||||
|
log(key + ${sq}-output${sq}, ${sq}Disconnected by user${sq}, ${sq}system${sq});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStream(url, outputId, statusId) {
|
||||||
|
setStatus(statusId, ${sq}Connecting${sq});
|
||||||
|
log(outputId, ${sq}Fetching ${sq} + url + ${sq}...${sq}, ${sq}system${sq});
|
||||||
|
|
||||||
|
try {
|
||||||
|
var response = await fetch(url);
|
||||||
|
var reader = response.body.getReader();
|
||||||
|
var decoder = new TextDecoder();
|
||||||
|
|
||||||
|
setStatus(statusId, ${sq}Connected${sq});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
var result = await reader.read();
|
||||||
|
if (result.done) break;
|
||||||
|
var text = decoder.decode(result.value, {stream: true});
|
||||||
|
log(outputId, text, ${sq}text${sq});
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(statusId, ${sq}Disconnected${sq});
|
||||||
|
log(outputId, ${sq}Stream complete${sq}, ${sq}system${sq});
|
||||||
|
} catch (err) {
|
||||||
|
setStatus(statusId, ${sq}Disconnected${sq});
|
||||||
|
log(outputId, ${sq}Error: ${sq} + err.message, ${sq}error${sq});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'
|
||||||
|
}
|
||||||
+119
@@ -521,6 +521,13 @@ fn parse_http_method_fast(method string) http.Method {
|
|||||||
|
|
||||||
// 发送 picoev 响应 - 优化版
|
// 发送 picoev 响应 - 优化版
|
||||||
fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response http.Response, keepalive bool, config PicoevConfig) {
|
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 }
|
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
|
||||||
|
|
||||||
if status_code == 200 {
|
if status_code == 200 {
|
||||||
@@ -570,6 +577,118 @@ fn send_picoev_response(mut res picohttpparser.Response, ctx Context, response h
|
|||||||
res.end()
|
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 directly to socket fd
|
||||||
|
// 2. Creates a PicoevStreamWriter with the socket fd
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
// Build HTTP headers string for direct socket write
|
||||||
|
mut headers_str := 'HTTP/1.1 ${status_code} ${get_status_text(status_code)}\r\n'
|
||||||
|
|
||||||
|
// Add 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
|
||||||
|
}
|
||||||
|
headers_str += '${key}: ${value}\r\n'
|
||||||
|
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 {
|
||||||
|
headers_str += 'Transfer-Encoding: chunked\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Connection header is set for streaming
|
||||||
|
if !has_connection {
|
||||||
|
headers_str += 'Connection: keep-alive\r\n'
|
||||||
|
headers_str += 'Keep-Alive: timeout=${config.timeout_secs}\r\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
// End headers section
|
||||||
|
headers_str += '\r\n'
|
||||||
|
|
||||||
|
// Write headers directly to socket fd for immediate delivery
|
||||||
|
fd := res.fd
|
||||||
|
write_to_socket_fd(fd, headers_str.bytes()) or {
|
||||||
|
eprintln('[Picoev Stream Error] Failed to write headers: ${err.msg()}')
|
||||||
|
cleanup_stream_config(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create PicoevStreamWriter for streaming (uses fd directly)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_to_socket_fd - Helper function to write bytes directly to socket fd
|
||||||
|
// Used for sending HTTP headers before streaming begins
|
||||||
|
fn write_to_socket_fd(fd int, data []u8) ! {
|
||||||
|
if data.len == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mut total_written := 0
|
||||||
|
for total_written < data.len {
|
||||||
|
remaining := data.len - total_written
|
||||||
|
ptr := unsafe { &u8(data.data) + total_written }
|
||||||
|
written := C.send(fd, ptr, remaining, 0)
|
||||||
|
if written < 0 {
|
||||||
|
return error('Failed to write to socket')
|
||||||
|
}
|
||||||
|
if written == 0 {
|
||||||
|
return error('Connection closed by peer')
|
||||||
|
}
|
||||||
|
total_written += written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取状态码文本
|
// 获取状态码文本
|
||||||
fn get_status_text(code int) string {
|
fn get_status_text(code int) string {
|
||||||
return match code {
|
return match code {
|
||||||
|
|||||||
+982
@@ -0,0 +1,982 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
// C function declaration for direct socket writes
|
||||||
|
fn C.send(sockfd int, buf voidptr, len int, flags int) int
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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
|
||||||
|
// Uses direct socket fd writes for immediate data delivery (true streaming)
|
||||||
|
@[heap]
|
||||||
|
pub struct PicoevStreamWriter {
|
||||||
|
mut:
|
||||||
|
fd int // Socket file descriptor for direct writes
|
||||||
|
connected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PicoevStreamWriter.new - Create a new PicoevStreamWriter with the given response
|
||||||
|
// Extracts the fd from picohttpparser.Response for direct socket writes
|
||||||
|
pub fn PicoevStreamWriter.new(res &picohttpparser.Response) &PicoevStreamWriter {
|
||||||
|
return &PicoevStreamWriter{
|
||||||
|
fd: res.fd
|
||||||
|
connected: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// write - Write raw bytes to the stream using chunked transfer encoding
|
||||||
|
// Format: {hex_size}\r\n{data}\r\n
|
||||||
|
// Writes directly to socket fd for immediate delivery
|
||||||
|
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)
|
||||||
|
// Write directly to socket fd
|
||||||
|
w.write_to_fd(chunk_header.bytes())!
|
||||||
|
w.write_to_fd(data)!
|
||||||
|
w.write_to_fd('\r\n'.bytes())!
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
// Write directly to socket fd
|
||||||
|
w.write_to_fd(chunk_header.bytes())!
|
||||||
|
w.write_to_fd(data.bytes())!
|
||||||
|
w.write_to_fd('\r\n'.bytes())!
|
||||||
|
}
|
||||||
|
|
||||||
|
// write_to_fd - Write bytes directly to the socket file descriptor
|
||||||
|
// This ensures immediate delivery without buffering
|
||||||
|
@[inline]
|
||||||
|
fn (mut w PicoevStreamWriter) write_to_fd(data []u8) ! {
|
||||||
|
if data.len == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mut total_written := 0
|
||||||
|
for total_written < data.len {
|
||||||
|
remaining := data.len - total_written
|
||||||
|
ptr := unsafe { &u8(data.data) + total_written }
|
||||||
|
written := C.send(w.fd, ptr, remaining, 0)
|
||||||
|
if written < 0 {
|
||||||
|
w.connected = false
|
||||||
|
return error('Failed to write to socket')
|
||||||
|
}
|
||||||
|
if written == 0 {
|
||||||
|
w.connected = false
|
||||||
|
return error('Connection closed by peer')
|
||||||
|
}
|
||||||
|
total_written += written
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// flush - Flush the buffer (direct fd writes are already unbuffered)
|
||||||
|
pub fn (mut w PicoevStreamWriter) flush() ! {
|
||||||
|
// Direct fd writes are unbuffered, no explicit flush needed
|
||||||
|
// But we can use TCP_NODELAY to disable Nagle's algorithm if 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.write_to_fd('0\r\n\r\n'.bytes()) or {}
|
||||||
|
w.connected = false
|
||||||
|
// Note: Don't close the fd here, picoev manages the connection lifecycle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+94
-27
@@ -1,13 +1,36 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# uSockets 编译脚本
|
# uSockets 编译脚本
|
||||||
# 用于重新编译 libusockets_full.a
|
# 用于重新编译 libusockets_full.a (包含 uSockets + libuv)
|
||||||
|
# 支持: Windows (Git Bash/MSYS2), macOS (Intel/Apple Silicon), Linux
|
||||||
|
# 输出到: usockets/lib/{platform}/libusockets_full.a
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
USOCKETS_DIR="/tmp/uSockets"
|
USOCKETS_DIR="/tmp/uSockets"
|
||||||
|
LIBUV_DIR="/tmp/libuv"
|
||||||
|
MERGE_DIR="/tmp/merge_libs"
|
||||||
|
|
||||||
echo "=== uSockets 编译脚本 ==="
|
echo "=== uSockets 编译脚本 ==="
|
||||||
|
echo "检测到系统: $OSTYPE"
|
||||||
|
|
||||||
|
# 确定输出目录
|
||||||
|
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||||
|
if [[ $(uname -m) == "arm64" ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-arm64"
|
||||||
|
else
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/macos-x64"
|
||||||
|
fi
|
||||||
|
elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/windows"
|
||||||
|
elif [[ "$OSTYPE" == "linux"* ]]; then
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
|
||||||
|
else
|
||||||
|
OUTPUT_DIR="$SCRIPT_DIR/lib/linux"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "输出目录: $OUTPUT_DIR"
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
|
||||||
# 检查是否有 uSockets 源码
|
# 检查是否有 uSockets 源码
|
||||||
if [ ! -d "$USOCKETS_DIR" ]; then
|
if [ ! -d "$USOCKETS_DIR" ]; then
|
||||||
@@ -15,41 +38,85 @@ if [ ! -d "$USOCKETS_DIR" ]; then
|
|||||||
git clone --depth 1 https://github.com/uNetworking/uSockets.git "$USOCKETS_DIR"
|
git clone --depth 1 https://github.com/uNetworking/uSockets.git "$USOCKETS_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 检查是否有 libuv 源码
|
||||||
|
if [ ! -d "$LIBUV_DIR" ]; then
|
||||||
|
echo "克隆 libuv..."
|
||||||
|
git clone --depth 1 --branch v1.48.0 https://github.com/libuv/libuv.git "$LIBUV_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
# 应用 backlog 修改
|
# 应用 backlog 修改
|
||||||
echo "应用 backlog=16384 修改..."
|
echo "应用 backlog=16384 修改..."
|
||||||
if [ -f "$SCRIPT_DIR/src/bsd.c" ]; then
|
if [ -f "$SCRIPT_DIR/src/bsd.c" ]; then
|
||||||
cp "$SCRIPT_DIR/src/bsd.c" "$USOCKETS_DIR/src/bsd.c"
|
cp "$SCRIPT_DIR/src/bsd.c" "$USOCKETS_DIR/src/bsd.c"
|
||||||
else
|
else
|
||||||
# 如果没有本地修改的源码,直接修改
|
|
||||||
sed -i.bak 's/listen(listenFd, 512)/listen(listenFd, 16384)/g' "$USOCKETS_DIR/src/bsd.c"
|
sed -i.bak 's/listen(listenFd, 512)/listen(listenFd, 16384)/g' "$USOCKETS_DIR/src/bsd.c"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 编译
|
# 编译 libuv
|
||||||
echo "编译 uSockets..."
|
compile_libuv() {
|
||||||
cd "$USOCKETS_DIR"
|
echo "编译 libuv..."
|
||||||
make clean
|
cd "$LIBUV_DIR"
|
||||||
|
rm -rf build 2>/dev/null || true
|
||||||
# 根据系统选择编译参数
|
mkdir -p build
|
||||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
cd build
|
||||||
# macOS
|
|
||||||
if [[ $(uname -m) == "arm64" ]]; then
|
if ! command -v cmake &> /dev/null; then
|
||||||
# Apple Silicon
|
echo "错误: 需要安装 cmake"
|
||||||
WITH_LIBUV=1 CFLAGS="-I/opt/homebrew/include" make
|
echo " macOS: brew install cmake"
|
||||||
else
|
echo " Linux: sudo apt install cmake 或 sudo yum install cmake"
|
||||||
# Intel Mac
|
echo " Windows: scoop install cmake 或 choco install cmake"
|
||||||
WITH_LIBUV=1 CFLAGS="-I/usr/local/include" make
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
# Linux
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
|
||||||
WITH_LIBUV=1 make
|
cmake .. -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
|
||||||
fi
|
else
|
||||||
|
cmake .. -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF -DLIBUV_BUILD_SHARED=OFF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cmake --build . --config Release
|
||||||
|
}
|
||||||
|
|
||||||
# 复制到 v-hono
|
# 编译 uSockets
|
||||||
echo "复制库文件..."
|
compile_usockets() {
|
||||||
cp "$USOCKETS_DIR/uSockets.a" "$SCRIPT_DIR/../lib/libusockets_full.a"
|
echo "编译 uSockets..."
|
||||||
|
cd "$USOCKETS_DIR"
|
||||||
|
rm -f *.o *.a 2>/dev/null || true
|
||||||
|
|
||||||
|
gcc -DLIBUS_NO_SSL -DLIBUS_USE_LIBUV -std=c11 -Isrc -I"$LIBUV_DIR/include" -O3 -c src/*.c src/eventing/*.c src/crypto/*.c
|
||||||
|
ar rcs uSockets.a *.o
|
||||||
|
rm -f *.o
|
||||||
|
}
|
||||||
|
|
||||||
echo "=== 编译完成 ==="
|
# 合并库文件
|
||||||
echo "库文件: $SCRIPT_DIR/../lib/libusockets_full.a"
|
merge_libraries() {
|
||||||
|
echo "合并库文件..."
|
||||||
|
rm -rf "$MERGE_DIR"
|
||||||
|
mkdir -p "$MERGE_DIR"
|
||||||
|
|
||||||
|
cd "$MERGE_DIR"
|
||||||
|
ar x "$USOCKETS_DIR/uSockets.a"
|
||||||
|
ar x "$LIBUV_DIR/build/libuv.a"
|
||||||
|
|
||||||
|
ar rcs "$OUTPUT_DIR/libusockets_full.a" *.o
|
||||||
|
|
||||||
|
rm -rf "$MERGE_DIR"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 执行编译
|
||||||
|
compile_libuv
|
||||||
|
compile_usockets
|
||||||
|
merge_libraries
|
||||||
|
|
||||||
|
# 显示结果
|
||||||
|
LIB_SIZE=$(ls -lh "$OUTPUT_DIR/libusockets_full.a" | awk '{print $5}')
|
||||||
echo ""
|
echo ""
|
||||||
echo "重新编译 v-hono 应用:"
|
echo "=== 编译完成 ==="
|
||||||
echo " v -enable-globals -prod -o your_app your_app.v"
|
echo "库文件: $OUTPUT_DIR/libusockets_full.a ($LIB_SIZE)"
|
||||||
|
echo ""
|
||||||
|
echo "编译 v-hono 应用:"
|
||||||
|
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "mingw"* ]]; then
|
||||||
|
echo " v -enable-globals -cc gcc -ldflags \"-ldbghelp\" -o app.exe app.v"
|
||||||
|
else
|
||||||
|
echo " v -enable-globals -prod -o app app.v"
|
||||||
|
fi
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
+16
-23
@@ -2,41 +2,36 @@ module usockets
|
|||||||
|
|
||||||
// 条件编译:根据操作系统选择正确的路径和库
|
// 条件编译:根据操作系统选择正确的路径和库
|
||||||
// @VMODROOT 指向包含 v.mod 的模块根目录 (v-hono/)
|
// @VMODROOT 指向包含 v.mod 的模块根目录 (v-hono/)
|
||||||
|
// 预编译库按平台存放在 usockets/lib/{platform}/ 目录
|
||||||
|
|
||||||
$if windows {
|
$if windows {
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/lib
|
#flag -L@VMODROOT/usockets/lib/windows
|
||||||
#flag @VMODROOT/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/windows/libusockets_full.a
|
||||||
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32
|
#flag -lws2_32 -liphlpapi -lpsapi -luserenv -lole32 -ldbghelp
|
||||||
} $else $if macos {
|
} $else $if macos {
|
||||||
// macOS: 支持 Apple Silicon (M1/M2/M3) 和 Intel Mac
|
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
|
|
||||||
// libuv 路径 - 根据架构选择 Homebrew 路径
|
|
||||||
$if arm64 {
|
|
||||||
#flag -I/opt/homebrew/include
|
|
||||||
#flag -L/opt/homebrew/lib
|
|
||||||
} $else {
|
|
||||||
#flag -I/usr/local/include
|
|
||||||
#flag -L/usr/local/lib
|
|
||||||
}
|
|
||||||
|
|
||||||
// uSockets 库路径
|
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/lib
|
|
||||||
#flag @VMODROOT/lib/libusockets_full.a
|
$if arm64 {
|
||||||
#flag -luv
|
// Apple Silicon (M1/M2/M3)
|
||||||
|
#flag -L@VMODROOT/usockets/lib/macos-arm64
|
||||||
|
#flag @VMODROOT/usockets/lib/macos-arm64/libusockets_full.a
|
||||||
|
} $else {
|
||||||
|
// Intel Mac
|
||||||
|
#flag -L@VMODROOT/usockets/lib/macos-x64
|
||||||
|
#flag @VMODROOT/usockets/lib/macos-x64/libusockets_full.a
|
||||||
|
}
|
||||||
} $else {
|
} $else {
|
||||||
// Linux 和其他平台
|
// Linux 和其他平台
|
||||||
#flag -DLIBUS_USE_LIBUV
|
#flag -DLIBUS_USE_LIBUV
|
||||||
#flag -DLIBUS_NO_SSL
|
#flag -DLIBUS_NO_SSL
|
||||||
#flag -I@VMODROOT/usockets/include
|
#flag -I@VMODROOT/usockets/include
|
||||||
#flag -L@VMODROOT/lib
|
#flag -L@VMODROOT/usockets/lib/linux
|
||||||
#flag @VMODROOT/lib/libusockets_full.a
|
#flag @VMODROOT/usockets/lib/linux/libusockets_full.a
|
||||||
#flag -luv
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#include "libusockets.h"
|
#include "libusockets.h"
|
||||||
@@ -105,8 +100,6 @@ pub fn (ctx SocketContext) on_writable(h voidptr) { C.us_socket_context_on_writa
|
|||||||
pub fn (ctx SocketContext) on_timeout(h voidptr) { C.us_socket_context_on_timeout(0, ctx, h) }
|
pub fn (ctx SocketContext) on_timeout(h voidptr) { C.us_socket_context_on_timeout(0, ctx, h) }
|
||||||
pub fn (ctx SocketContext) on_end(h voidptr) { C.us_socket_context_on_end(0, ctx, h) }
|
pub fn (ctx SocketContext) on_end(h voidptr) { C.us_socket_context_on_end(0, ctx, h) }
|
||||||
pub fn (ctx SocketContext) listen(port int) ListenSocket {
|
pub fn (ctx SocketContext) listen(port int) ListenSocket {
|
||||||
// 参数: ssl=0, context, host=nil, port, options=LIBUS_LISTEN_DEFAULT(0), socket_ext_size=0
|
|
||||||
// 注意: backlog 是在 uSockets 库内部硬编码的,不通过此函数传递
|
|
||||||
return C.us_socket_context_listen(0, ctx, unsafe { nil }, port, 0, 0)
|
return C.us_socket_context_listen(0, ctx, unsafe { nil }, port, 0, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -577,6 +577,13 @@ fn exec_middlewares_usockets(idx int, middlewares []ContextMiddleware, mut ctx C
|
|||||||
|
|
||||||
// 发送 uSockets 响应 - 优化版
|
// 发送 uSockets 响应 - 优化版
|
||||||
fn send_usockets_response(s usockets.Socket, ctx Context, response http.Response) {
|
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 }
|
status_code := if ctx.status_code != 0 { ctx.status_code } else { response.status_code }
|
||||||
|
|
||||||
mut resp := strings.new_builder(512)
|
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())
|
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]
|
@[inline]
|
||||||
fn eq_ignore_case_usockets(a string, b string) bool {
|
fn eq_ignore_case_usockets(a string, b string) bool {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
Module {
|
Module {
|
||||||
name: 'hono'
|
name: 'hono'
|
||||||
description: 'A high-performance V language web framework inspired by Hono.js, featuring hybrid routing, LRU cache, middleware support, and more.'
|
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.4'
|
||||||
license: 'MIT'
|
license: 'MIT'
|
||||||
dependencies: []
|
dependencies: []
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user