diff --git a/context/CHANGELOG.md b/context/CHANGELOG.md index d4c56c3..d1f23c4 100644 --- a/context/CHANGELOG.md +++ b/context/CHANGELOG.md @@ -1,5 +1,183 @@ # Proxy-WASM Runner - Changelog +## April 2, 2026 - Config Schema Split: Discriminated Union on appType + +### Overview +The config schema now uses a discriminated union on `appType` to differentiate CDN (proxy-wasm) and HTTP (http-wasm) configurations. HTTP configs use `request.path` (path only, e.g. `/api/hello?q=1`) instead of `request.url`. CDN configs continue to use `request.url` (full URL) unchanged. + +### What Changed +- `TestConfigSchema` now discriminates on `appType`: `"proxy-wasm"` or `"http-wasm"` +- Schema split: `CdnRequestConfigSchema` (has `url`) and `HttpRequestConfigSchema` (has `path`) +- `RequestConfigSchema` kept as a backward-compat alias for `CdnRequestConfigSchema` +- `/api/execute` endpoint now accepts both `path` (preferred for HTTP) and `url` (legacy/CDN) +- Frontend API client (`executeHttpWasm`) now sends `{ path }` instead of `{ url }` for HTTP WASM calls + +### Notes +- Proxy-wasm **property names** (e.g. `"request.url"`, `"request.host"` in the `properties` object) are unchanged -- those are CDN server properties, not config fields +- Context documentation updated across 7 files to reflect the new schema + +--- + +## April 1, 2026 - Calculated Properties, Built-in URL, Access Control & Bug Fixes + +### Overview +Major session fixing multiple runner bugs discovered while running FastEdge-sdk-rust examples through the VSCode debugger. Added separated calculated properties for agent/developer side-by-side workflow, fixed built-in URL with query params, added missing property access control entry, and fixed stale property feedback loop. + +### 🎯 What Was Completed + +#### 1. Separated Calculated Properties (Agent/Developer Side-by-Side) +URL-derived properties (`request.url`, `request.host`, `request.path`, `request.query`, `request.scheme`, `request.extension`) were being merged into the editable `properties` store after each Send, then sent BACK to the server on the next request β€” creating a stale feedback loop requiring two Sends to see URL changes. + +**Fix:** Added a separate `calculatedProperties` store field. Server-calculated values are stored there (via both API response and WebSocket `request_completed` event) for read-only display in the Properties panel. They are never sent back to the server, so the server always derives fresh values from the URL. + +- WebSocket handler in App.tsx updates `calculatedProperties` on `request_completed` +- ProxyWasmView `handleSend` also updates `calculatedProperties` from API response +- PropertiesEditor displays them in read-only rows via `defaultValues` overlay +- `loadFromConfig` resets `calculatedProperties` to `{}` so read-only rows show `` +- `exportConfig` does NOT include calculated properties (they're ephemeral) + +**Files Modified:** +- `frontend/src/stores/types.ts` β€” Added `calculatedProperties` to ConfigState and `setCalculatedProperties` action +- `frontend/src/stores/slices/configSlice.ts` β€” Added state, action, reset on config load +- `frontend/src/App.tsx` β€” WebSocket handler stores calculated properties separately +- `frontend/src/views/ProxyWasmView/ProxyWasmView.tsx` β€” API response stores calculated properties +- `frontend/src/components/proxy-wasm/ServerPropertiesPanel/ServerPropertiesPanel.tsx` β€” Pass through +- `frontend/src/components/proxy-wasm/PropertiesEditor/PropertiesEditor.tsx` β€” Display via `getDefaultsWithCalculated`, key-based remount on change + +#### 2. Built-in URL with Query Params +`http://fastedge-builtin.debug?key=value` was not recognized as a built-in URL because `isBuiltIn` used strict equality. The runner tried a real HTTP fetch to `fastedge-builtin.debug`, which failed with `ENOTFOUND`. + +**Fix:** `isBuiltIn` now checks `startsWith(BUILTIN_URL + '?')` and `startsWith(BUILTIN_URL + '/')`. + +**Files Modified:** +- `server/runner/ProxyWasmRunner.ts` β€” `isBuiltIn` detection in `callFullFlowLegacy` + +#### 3. `request.x_real_ip` Missing from Property Access Control +The `BUILT_IN_PROPERTIES` whitelist was missing `request.x_real_ip`. When the WASM called `get_property("request.x_real_ip")`, access control treated it as an unknown custom property and denied access, returning `NotFound` (causing 557 error in the cdn/properties example). + +**Fix:** Added `request.x_real_ip` to `BUILT_IN_PROPERTIES` as read-only in all hooks. + +**Files Modified:** +- `server/runner/PropertyAccessControl.ts` β€” Added `request.x_real_ip` entry + +#### 4. GET/HEAD Body Stripping in HTTP Callouts +Node.js `fetch()` throws `TypeError: Request with GET/HEAD method cannot have body`. The FastEdge-sdk-rust `cdn/http_call` example passes `Some("body".as_bytes())` with a GET dispatch. + +**Fix:** PAUSE loop strips body for GET/HEAD methods. + +**Files Modified:** +- `server/runner/ProxyWasmRunner.ts` β€” Conditional body in fetch call + +#### 5. Always Surface HTTP Call Failures in Logs +Fetch errors in the PAUSE loop were only logged via `logDebug()` (requires `PROXY_RUNNER_DEBUG=1`). Failures were invisible in the UI. + +**Fix:** Failed fetches now push a WARN-level `[host]` prefixed log entry, always visible in the Logs panel. + +**Files Modified:** +- `server/runner/ProxyWasmRunner.ts` β€” catch block pushes to `this.logs` + +#### 6. Log Level Filter Bug (undefined logLevel) +Loading a config without a `logLevel` field set `state.logLevel = undefined`. The filter `log.level >= undefined` evaluated to `false` for all logs, hiding everything. The dropdown showed "Trace (0)" visually but the actual value was `undefined`. + +**Fix:** `loadFromConfig` defaults `config.logLevel ?? 0`. + +**Files Modified:** +- `frontend/src/stores/slices/configSlice.ts` β€” Nullish coalescing default + +### πŸ§ͺ Testing +- Load FastEdge-sdk-rust `cdn/http_call` example β†’ should succeed with Return Code 0 +- Load FastEdge-sdk-rust `cdn/properties` example β†’ all properties should resolve (no 55x errors) +- Use `http://fastedge-builtin.debug?hello=world` β†’ should work as built-in responder +- Change URL query params, press Send ONCE β†’ `request.query` should update immediately +- Load a new config β†’ read-only properties should reset to `` +- Multi-tab: send from tab 1 β†’ tab 2 should see calculated properties update via WebSocket + +### πŸ“ Notes +- Examples in FastEdge-sdk-rust are source of truth β€” never modify them +- `calculatedProperties` is display-only state; never sent to server, never exported +- The `logLevel` default in `DEFAULT_CONFIG_STATE` remains 2 (INFO) for fresh sessions; `?? 0` only applies to loaded configs missing the field +- Fixture/config loading CAN override URL-derived properties via `properties` field β€” these are sent to the server and take precedence in `resolve()` + +--- + +## April 1, 2026 - Multi-Value Header Support (Proxy-WASM ABI Compliance) + +### Overview +Fixed the proxy-wasm host function layer to support multi-valued headers. The internal header storage changed from `Record` to `[string, string][]` (tuple array), enabling `add_header` with the same key to create separate entries rather than comma-concatenating. Also fixed `proxy_remove_header_map_value` to match nginx behavior (set to empty string, not delete). Added the FastEdge-sdk-rust `cdn/headers` example as both a Rust and AS integration test. + +### 🎯 What Was Completed + +#### 1. Internal Tuple Storage +- `HostFunctions` internal storage changed from `Record` to `[string, string][]` +- All `proxy_*` header host functions updated to work with tuples +- Boundary conversion: `recordToTuples()` on input, `tuplesToRecord()` on output (comma-join) +- External interfaces (`HeaderMap`, API schemas, WebSocket events, frontend) unchanged + +#### 2. nginx Behavior Parity +- `proxy_remove_header_map_value`: sets to empty string (not delete) when header exists; no-op when header doesn't exist +- `proxy_get_header_map_value`: returns `Ok("")` for missing headers (matches nginx) +- `proxy_add_header_map_value`: pushes separate tuple entry (not comma-concat) + +#### 3. Integration Tests (cdn-headers) +- Added Rust test app: `test-applications/cdn-apps/rust/cdn-headers/` (from FastEdge-sdk-rust) +- Updated AS test app: `proxy-wasm-sdk-as/examples/headers/` (aligned with nginx behavior) +- 20 integration tests: 10 per variant (AS + Rust), covering add/replace/remove/multi-value/cross-map + +**Files Modified:** +- `server/runner/types.ts` β€” Added `HeaderTuples` type +- `server/runner/HeaderManager.ts` β€” 6 new tuple methods +- `server/runner/HostFunctions.ts` β€” Internal storage + all proxy_* functions +- `server/__tests__/unit/runner/HeaderManager.test.ts` β€” Tuple method tests +- `test-applications/cdn-apps/rust/Cargo.toml` β€” Added cdn-headers to workspace + +**Files Created:** +- `test-applications/cdn-apps/rust/cdn-headers/` β€” Rust test crate +- `server/__tests__/integration/cdn-apps/headers/multi-value-headers.test.ts` + +**Cross-repo:** +- `proxy-wasm-sdk-as/examples/headers/assembly/index.ts` β€” Updated to match nginx behavior + +### πŸ“ Notes +- See `context/features/MULTI_VALUE_HEADERS.md` for full implementation details and error code reference +- The `_bytes` header variants are Rust SDK only; AS tests skip those assertions via `hasBytesVariants` flag + +--- + +## April 1, 2026 - Relative dotenv.path Resolution + +### Overview +Config files can now use relative paths for `dotenv.path` (e.g., `"./fixtures"`). Previously, relative paths resolved against the server's CWD, which broke in VSCode (where CWD is `dist/debugger/`). Now relative paths consistently resolve against the config file's directory across all loading flows. + +### What Changed + +#### 1. Server (`server/server.ts`) +- Added `resolveDotenvPath()` helper β€” resolves relative paths against `WORKSPACE_PATH` (or server root fallback); used as safety net in `/api/load` and `/api/dotenv` endpoints +- `GET /api/config` now resolves relative `dotenv.path` against the config directory before returning + +#### 2. Test Framework (`server/test-framework/suite-runner.ts`) +- `loadConfigFile()` resolves relative `dotenv.path` against the config file's parent directory before returning + +#### 3. Frontend (`frontend/src/components/common/ConfigButtons/ConfigButtons.tsx`, `frontend/src/App.tsx`) +- VSCode flow: resolves relative `dotenv.path` using `configDir` sent by the extension in `filePickerResult` messages +- Browser file drop: logs `console.warn` when relative dotenv.path detected (browser security prevents full path resolution) + +#### 4. VSCode Extension (FastEdge-vscode) +- `DebuggerWebviewProvider.ts` β€” `filePickerResult` message now includes `configDir` (dirname of the picked file); `sendConfig()` accepts and forwards optional `configDir` +- `runDebugger.ts` β€” passes `configDir` to `sendConfig()` for auto-load flow + +**Files Modified:** +- `server/server.ts` β€” `resolveDotenvPath()`, `GET /api/config` resolution +- `server/test-framework/suite-runner.ts` β€” relative path resolution in `loadConfigFile()` +- `server/__tests__/unit/test-framework/suite-runner.test.ts` β€” 3 new tests for path resolution +- `frontend/src/components/common/ConfigButtons/ConfigButtons.tsx` β€” VSCode configDir resolution + browser warning +- `frontend/src/App.tsx` β€” browser drag-drop warning + +**Cross-repo (FastEdge-vscode):** +- `src/debugger/DebuggerWebviewProvider.ts` β€” `configDir` in messages + `sendConfig()` signature +- `src/commands/runDebugger.ts` β€” passes `configDir` to `sendConfig()` + +--- + ## April 1, 2026 - Built-In Responder URL Normalisation ### Overview diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index 74d5a32..0a789f8 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -44,6 +44,7 @@ This index helps you discover relevant documentation without reading thousands o - `FASTEDGE_IMPLEMENTATION.md` (645 lines) - FastEdge CDN integration, secrets, env vars - `PROPERTY_IMPLEMENTATION_COMPLETE.md` (495 lines) - Property system, runtime calculation - `PRODUCTION_PARITY_HEADERS.md` (421 lines) - Header serialization, G-Core SDK format +- `MULTI_VALUE_HEADERS.md` (~200 lines) - Multi-value header support, internal tuple storage, nginx remove behavior, cdn-headers integration test (AS + Rust) - `CONFIG_SHARING.md` (281 lines) - fastedge-config.test.json sharing system - `DOTENV.md` (~210 lines) - Environment variable system, dotenvPath support (CDN + HTTP) - `CDN_VARIABLES_AND_SECRETS.md` (~120 lines) - βœ… CDN env var/secret integration test (7 tests); requires proxy-wasm-sdk-as@^1.2.2 @@ -152,8 +153,9 @@ Generated from source code via `fastedge-plugin-source/generate-docs.sh`. Increm 1. Read relevant `wasm/*.md` file for your specific task 2. Read `FASTEDGE_IMPLEMENTATION.md` (FastEdge context) -3. Read `PRODUCTION_PARITY_HEADERS.md` if dealing with headers -4. Grep for examples in codebase +3. Read `PRODUCTION_PARITY_HEADERS.md` if dealing with header display/injection +4. Read `MULTI_VALUE_HEADERS.md` if dealing with host functions (add/replace/remove/get) +5. Grep for examples in codebase ### Working with proxy_http_call / HTTP Callouts diff --git a/context/FUTURE_ENHANCEMENTS.md b/context/FUTURE_ENHANCEMENTS.md index 010d90a..644b91e 100644 --- a/context/FUTURE_ENHANCEMENTS.md +++ b/context/FUTURE_ENHANCEMENTS.md @@ -42,3 +42,32 @@ Discovers all `*.test.json` files, runs each, prints a summary table. - Reuse `createRunner()` + `runFlow()`/`runHttpRequest()` from the test framework - Auto-detect CDN vs HTTP-WASM from the loaded binary to choose the right execution path - Consider a `--json` flag for machine-readable output + +--- + +## Hot Dotenv Reload + Secret Rollover / Slots + +**Problem**: The debugger currently loads `.env` files once at WASM startup. There is no way to update secrets at runtime without restarting the runner. This means the `secret_rollover` example (which uses `secret::get_effective_at()` with slot-based lookup) cannot be meaningfully tested in the debugger β€” the slot values are static and never change. + +**Research needed**: +1. **Hot dotenv reload**: Can the debugger detect `.env` file changes (via file watcher or manual trigger) and push updated env vars / secrets into the running WASM instance without restarting? +2. **Slot-based secrets in the debugger**: How should the debugger model the `get_effective_at(slot)` API? The real FastEdge server maintains a history of secret values keyed by slot. The debugger currently only has "current" values from `.env.secrets`. +3. **Fixture support**: Should `fastedge-config.test.json` support defining multiple secret versions with slot values? e.g.: + ```json + { + "secrets": { + "TOKEN_SECRET": [ + { "slot": 0, "value": "original-token" }, + { "slot": 1719849600, "value": "rotated-token" } + ] + } + } + ``` +4. **UI considerations**: How should the debugger UI expose secret history / slot editing? Could be a timeline or version list per secret. + +**Context**: The `secret_rollover` example in `FastEdge-sdk-rust/examples/http/wasi/secret_rollover/` uses `x-slot` and `x-secret-name` request headers to query secrets at specific slots. The fixtures (`current.test.json`, `slot.test.json`) exercise this but currently only test against static dotenv values. + +**Why this matters**: +- Secret rotation is a real production pattern (API key rollover, certificate rotation) +- Without slot support in the debugger, developers can't verify their rollover logic locally +- Hot reload would also benefit general development workflow (change an env var without restarting) diff --git a/context/features/CONFIG_EDITOR.md b/context/features/CONFIG_EDITOR.md index cbc3007..e72a92a 100644 --- a/context/features/CONFIG_EDITOR.md +++ b/context/features/CONFIG_EDITOR.md @@ -218,7 +218,7 @@ This is intentional β€” `fastedge-config.test.json` is the marker used by `resol Real-time validation checks: - βœ… Valid JSON syntax - βœ… Required fields: `request`, `properties`, `logLevel` -- βœ… Required nested fields: `request.method`, `request.url`, `request.headers`, `request.body` +- βœ… Required nested fields: `request.method`, `request.url` (CDN) or `request.path` (HTTP), `request.headers`, `request.body` - βœ… Type checking: `logLevel` must be number - βœ… Optional fields: `description`, `wasm` (with required `wasm.path`) diff --git a/context/features/CONFIG_SHARING.md b/context/features/CONFIG_SHARING.md index 58a1148..e16cc34 100644 --- a/context/features/CONFIG_SHARING.md +++ b/context/features/CONFIG_SHARING.md @@ -38,8 +38,10 @@ const config = await fetch("http://localhost:5179/api/config").then((r) => ); // Use settings with optional overrides +// For CDN (proxy-wasm) configs, use request.url; for HTTP (http-wasm), use request.path const testRequest = { - url: config.config.request.url, + url: config.config.request.url, // CDN configs + path: config.config.request.path, // HTTP configs request: { method: config.config.request.method, headers: { @@ -62,8 +64,13 @@ const testRequest = { ### Structure +The config schema uses a discriminated union on `appType`. CDN (proxy-wasm) configs use `request.url` (full URL), while HTTP (http-wasm) configs use `request.path` (path only). + +**CDN / Proxy-WASM config** (`appType: "proxy-wasm"`): + ```json { + "appType": "proxy-wasm", "description": "Test configuration for proxy-wasm debugging", "wasm": { "path": "wasm/cdn_header_change.wasm", @@ -88,18 +95,40 @@ const testRequest = { } ``` +**HTTP / HTTP-WASM config** (`appType: "http-wasm"`): + +```json +{ + "appType": "http-wasm", + "description": "Test configuration for http-wasm app", + "wasm": { + "path": "wasm/http-apps/sdk-examples/sdk-basic.wasm", + "description": "Basic HTTP WASM app" + }, + "request": { + "method": "GET", + "path": "/api/hello?q=1", + "headers": {}, + "body": "" + }, + "logLevel": 0 +} +``` + ### Fields | Field | Type | Description | | ------------------ | ------ | ---------------------------------------------------------------- | +| `appType` | string | `"proxy-wasm"` or `"http-wasm"` β€” determines config shape | | `description` | string | Human-readable description of this config | | `wasm.path` | string | Path to WASM file (relative to project root) | | `wasm.description` | string | Description of what this WASM does | | `request.method` | string | HTTP method (GET, POST, etc.) | -| `request.url` | string | Target URL for the request | +| `request.url` | string | **(CDN only)** Full target URL for the request | +| `request.path` | string | **(HTTP only)** Request path (e.g. `/api/hello?q=1`) | | `request.headers` | object | Request headers (key-value pairs) | | `request.body` | string | Request body content | -| `properties` | object | Server properties (geo-location, country, etc.) | +| `properties` | object | **(CDN only)** Server properties (geo-location, country, etc.) | | `logLevel` | number | Log level: 0=Trace, 1=Debug, 2=Info, 3=Warn, 4=Error, 5=Critical | ## API Endpoints diff --git a/context/features/DOTENV.md b/context/features/DOTENV.md index 3eb14a0..dfe8345 100644 --- a/context/features/DOTENV.md +++ b/context/features/DOTENV.md @@ -236,6 +236,32 @@ let api_url = self.get_property(vec!["dictionary", "API_URL"]); --- +## Relative Path Resolution (April 2026) + +Config files can specify `dotenv.path` as a relative path (e.g., `"./fixtures"`). Relative paths are resolved **against the config file's directory**, not the server's CWD. This ensures the same config file works identically across all loading flows. + +### Resolution by flow: + +| Flow | Resolution base | Implementation | +|------|----------------|----------------| +| **Test framework CLI** (`loadConfigFile`) | Config file's directory | `suite-runner.ts` resolves before returning | +| **GET /api/config** (default .fastedge-debug) | Config file's directory | `server.ts` resolves before responding | +| **VSCode file picker / auto-load** | Config file's directory | Extension sends `configDir`, frontend resolves before `loadFromConfig` | +| **Browser file drop** | WORKSPACE_PATH (fallback) | Browser security hides full path; `resolveDotenvPath()` in server.ts catches remaining relative paths | + +### Example + +Given `geo_redirect/fastedge-config.test.json`: +```json +{ + "dotenv": { "enabled": true, "path": "./fixtures" } +} +``` + +The dotenv path resolves to `geo_redirect/fixtures/` regardless of which flow loads it. + +--- + ## Security Notes ⚠️ **Important**: Always add `.env*` files to your `.gitignore`: @@ -268,13 +294,14 @@ For both runner types, CLI args/direct config takes priority over dotenv files: - `server/utils/dotenv-loader.ts` β€” Node.js dotenv parser (ProxyWasmRunner only) - `server/schemas/api.ts` β€” `dotenvPath` in `ApiLoadBodySchema` - `server/schemas/config.ts` β€” `dotenvPath` in `TestConfigSchema` -- `server/server.ts` β€” precedence logic: client β†’ `WORKSPACE_PATH` β†’ CWD +- `server/server.ts` β€” precedence logic: client β†’ `WORKSPACE_PATH` β†’ CWD; `resolveDotenvPath()` resolves relative paths to absolute; `GET /api/config` resolves dotenv.path against config directory before returning - `frontend/src/stores/types.ts` β€” `dotenvPath` in `ConfigState`, `ConfigActions`, `TestConfig` - `frontend/src/stores/slices/configSlice.ts` β€” `setDotenvPath`, restore/export - `frontend/src/stores/slices/wasmSlice.ts` β€” reads `dotenvPath` from store via `get()` - `frontend/src/api/index.ts` β€” `dotenvPath` forwarded in all relevant API calls - `frontend/src/components/common/DotenvPanel/` β€” shared toggle + path selector (VSCode browse / browser text input); used in both CDN and HTTP views -- `FastEdge-vscode/src/debugger/DebuggerWebviewProvider.ts` β€” `openFolderPicker` / `folderPickerResult` handler; `getAppRoot` / `appRootResult` handler for default path display +- `server/test-framework/suite-runner.ts` β€” `loadConfigFile()` resolves relative dotenv.path against config file directory +- `FastEdge-vscode/src/debugger/DebuggerWebviewProvider.ts` β€” `openFolderPicker` / `folderPickerResult` handler; `getAppRoot` / `appRootResult` handler for default path display; sends `configDir` in `filePickerResult` message for relative path resolution - `schemas/fastedge-config.test.schema.json` β€” IDE intellisense for config files - `schemas/api-load.schema.json` β€” `POST /api/load` request body schema - `schemas/api-config.schema.json` β€” `POST /api/config` config object schema diff --git a/context/features/HTTP_CALL_IMPLEMENTATION.md b/context/features/HTTP_CALL_IMPLEMENTATION.md index e0827e5..c91e52e 100644 --- a/context/features/HTTP_CALL_IMPLEMENTATION.md +++ b/context/features/HTTP_CALL_IMPLEMENTATION.md @@ -1,7 +1,7 @@ # HTTP Callout (proxy_http_call) Implementation **Status**: βœ… Complete -**Last Updated**: February 27, 2026 +**Last Updated**: April 1, 2026 --- @@ -71,7 +71,8 @@ while (returnCode === 1 /* PAUSE */ && hostFunctions.hasPendingHttpCall()) { url = `${scheme}://${authority}${path}`; // HTTP fetch (real network call) - resp = await fetch(url, { method, headers, body, signal: AbortSignal.timeout(timeoutMs) }); + // Note: body is stripped for GET/HEAD (Node.js fetch enforces HTTP spec) + resp = await fetch(url, { method, headers, body: (method !== 'GET' && method !== 'HEAD') ? body : undefined, signal: AbortSignal.timeout(timeoutMs) }); // Store response for WASM to read back hostFunctions.setHttpCallResponse(tokenId, responseHeaders, responseBodyBytes); @@ -149,12 +150,24 @@ Per the proxy-wasm spec: a failed HTTP call delivers `numHeaders = 0` to `proxy_ ```typescript } catch (err) { + // Always surface the error in WASM-visible logs (WARN level, [host] prefix) + this.logs.push({ level: 3, message: `[host] http_call failed for ${url}: ${String(err)}` }); responseHeaders = {}; responseBody = new Uint8Array(0); } // numHeaders = 0, bodySize = 0 β†’ WASM sees failed call ``` +### 5b. GET/HEAD Body Stripping + +Node.js `fetch()` strictly enforces HTTP spec β€” GET/HEAD requests cannot have a body. Some WASM binaries (e.g. FastEdge-sdk-rust `cdn/http_call`) pass a body with GET requests. The PAUSE loop strips the body for these methods: + +```typescript +body: pending.body && method !== 'GET' && method !== 'HEAD' ? Buffer.from(pending.body) : undefined, +``` + +This matches production CDN behavior where the body would be ignored for bodiless methods. + ### 6. `proxy_continue_stream` / `proxy_close_stream` - `proxy_continue_stream`: no-op (the PAUSE loop defaults to Continue after `on_http_call_response`) diff --git a/context/features/HTTP_WASM_IMPLEMENTATION.md b/context/features/HTTP_WASM_IMPLEMENTATION.md index 43a12b3..6f12c8b 100644 --- a/context/features/HTTP_WASM_IMPLEMENTATION.md +++ b/context/features/HTTP_WASM_IMPLEMENTATION.md @@ -140,14 +140,14 @@ curl -X POST http://localhost:5179/api/load \ ### /api/execute (NEW) -Unified endpoint that works with both WASM types: +Unified endpoint that works with both WASM types. Accepts both `path` (preferred for HTTP WASM) and `url` (legacy/CDN): **For HTTP WASM**: ```bash curl -X POST http://localhost:5179/api/execute \ -H "Content-Type: application/json" \ -d '{ - "url": "http://example.com/path?query=value", + "path": "/path?query=value", "method": "GET", "headers": {"user-agent": "test"}, "body": "" @@ -278,10 +278,10 @@ curl -X POST http://localhost:5179/api/load \ -H "Content-Type: application/json" \ -d "{\"wasmBase64\": \"$WASM_BASE64\", \"wasmType\": \"http-wasm\"}" -# Execute +# Execute (use "path" for HTTP WASM) curl -X POST http://localhost:5179/api/execute \ -H "Content-Type: application/json" \ - -d '{"url": "http://example.com/", "method": "GET"}' + -d '{"path": "/", "method": "GET"}' ``` **Test Proxy-WASM**: diff --git a/context/features/HTTP_WASM_PREVIEW.md b/context/features/HTTP_WASM_PREVIEW.md index 62464d6..fecbe79 100644 --- a/context/features/HTTP_WASM_PREVIEW.md +++ b/context/features/HTTP_WASM_PREVIEW.md @@ -138,7 +138,7 @@ Files fixed: `ResponsePanel.module.css`, `HookStagesPanel.module.css`, Before this change, HTTP WASM logs worked correctly for explicit "Send" requests but were completely missing in live mode. -**Execute flow (Send button)**: `POST /api/execute` β†’ `HttpWasmRunner.execute()` β†’ logs +**Execute flow (Send button)**: `POST /api/execute` (with `{ path }` for HTTP WASM) β†’ `HttpWasmRunner.execute()` β†’ logs accumulated in `this.logs[]` β†’ returned in HTTP response β†’ emitted in `http_wasm_request_completed` WebSocket event β†’ frontend replaced `httpLogs` with the new batch. diff --git a/context/features/HTTP_WASM_UI.md b/context/features/HTTP_WASM_UI.md index b402f0c..d3ca6f8 100644 --- a/context/features/HTTP_WASM_UI.md +++ b/context/features/HTTP_WASM_UI.md @@ -545,7 +545,7 @@ function handleServerEvent(event: ServerEvent) { **Signature**: ```typescript async function executeHttpWasm( - url: string, + path: string, method: string = 'GET', headers: Record = {}, body: string = '' @@ -563,7 +563,7 @@ async function executeHttpWasm( **Implementation**: ```typescript export async function executeHttpWasm( - url: string, + path: string, method: string = 'GET', headers: Record = {}, body: string = '' @@ -571,7 +571,7 @@ export async function executeHttpWasm( const response = await fetch('/api/execute', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, method, headers, body }), + body: JSON.stringify({ path, method, headers, body }), }); if (!response.ok) { diff --git a/context/features/MULTI_VALUE_HEADERS.md b/context/features/MULTI_VALUE_HEADERS.md new file mode 100644 index 0000000..57288c1 --- /dev/null +++ b/context/features/MULTI_VALUE_HEADERS.md @@ -0,0 +1,155 @@ +# Multi-Value Header Support (Proxy-WASM ABI Compliance) + +**Date:** April 1, 2026 +**Status:** βœ… Implemented + +## Overview + +Changed the internal header storage in the proxy-wasm host functions from `Record` (one value per key) to `[string, string][]` (ordered tuple array). This enables correct multi-valued header support as required by the proxy-wasm ABI β€” where `add_header("x-foo", "a")` + `add_header("x-foo", "b")` must produce two separate entries returned by `get_headers()`. + +## Motivation + +The FastEdge-sdk-rust `cdn/headers` example exercises all header host functions (add, replace, remove, get_headers, get_header) and validates their behavior against the real FastEdge CDN (nginx-based proxy-wasm runtime). Running this example against the test runner exposed three bugs: + +1. **Multi-value headers concatenated** β€” `proxy_add_header_map_value` comma-joined values (`"val1,val2"`) instead of creating separate entries. The Rust SDK's `get_http_request_headers()` then saw one entry instead of two, causing diff validation to fail (error 552). + +2. **`proxy_remove_header_map_value` deleted entries** β€” On nginx, removing a header sets it to empty string (the header entry persists). The runner was deleting entries entirely, causing the Rust example's diff validation to miss expected `("header-name", "")` entries. + +3. **`proxy_remove` created entries for non-existent headers** β€” When removing a header that was never added, the runner should no-op. Instead, after the initial fix, it was creating a new empty-string entry, causing response header count validation to fail (error 555). + +## Approach: Internal Tuple Storage + +Only the **internal** header storage in `HostFunctions` changed. All external interfaces (`HeaderMap = Record`) remain unchanged β€” conversion happens at the boundary. This minimizes the blast radius: frontend, API schemas, WebSocket events, and test framework assertions are untouched. + +## Changes Implemented + +### 1. New Type (`server/runner/types.ts`) +- Added `HeaderTuples = [string, string][]` β€” internal representation supporting duplicate keys + +### 2. HeaderManager Tuple Methods (`server/runner/HeaderManager.ts`) +Added 6 new methods alongside existing Record-based ones: + +| Method | Purpose | +|--------|---------| +| `recordToTuples()` | Convert `Record` β†’ tuples (boundary input) | +| `tuplesToRecord()` | Convert tuples β†’ Record with comma-joining (boundary output) | +| `normalizeTuples()` | Lowercase all keys | +| `serializeTuples()` | Encode tuples to proxy-wasm binary format (supports dup keys) | +| `deserializeBinaryToTuples()` | Decode binary format preserving dup keys | +| `deserializeToTuples()` | Decode null-separated string preserving dup keys | + +Existing `serialize()`, `deserializeBinary()`, `deserialize()`, `normalize()` kept unchanged for backward compatibility. + +### 3. HostFunctions Internal Storage (`server/runner/HostFunctions.ts`) + +**Storage change:** +```typescript +// Before: +private requestHeaders: HeaderMap = {}; // Record +private responseHeaders: HeaderMap = {}; + +// After: +private requestHeaders: HeaderTuples = []; // [string, string][] +private responseHeaders: HeaderTuples = []; +``` + +**Host function changes:** + +| Function | Before | After | +|----------|--------|-------| +| `proxy_add_header_map_value` | `map[key] = existing ? existing+","+value : value` | `tuples.push([key, value])` | +| `proxy_replace_header_map_value` | `map[key] = value` | Filter out key, push `[key, value]` | +| `proxy_remove_header_map_value` | `delete map[key]` | If exists: filter out key, push `[key, ""]` (nginx behavior). If not exists: no-op | +| `proxy_get_header_map_value` | `map[key]` lookup | `tuples.find()` β€” first match. Missing β†’ `Ok("")` (nginx behavior) | +| `proxy_get_header_map_pairs` | `HeaderManager.serialize(map)` | `HeaderManager.serializeTuples(tuples)` | +| `proxy_get_header_map_size` | `Object.keys(map).length` | `tuples.length` (includes dup keys) | +| `proxy_set_header_map_pairs` | `HeaderManager.deserialize()` | `HeaderManager.deserializeToTuples()` | + +**Boundary conversions:** +- `setHeadersAndBodies()` β€” converts incoming `Record` β†’ tuples via `recordToTuples()` +- `getRequestHeaders()` / `getResponseHeaders()` β€” converts tuples β†’ `Record` via `tuplesToRecord()` (comma-joins multi-values) + +**Private methods renamed:** +- `getHeaderMap()` β†’ `getInternalHeaders()` (returns `HeaderTuples`) +- `setHeaderMap()` β†’ `setInternalHeaders()` (accepts `HeaderTuples`) + +### 4. Rust Test Application (`test-applications/cdn-apps/rust/cdn-headers/`) +- Copied from `FastEdge-sdk-rust/examples/cdn/headers/` β€” the reference implementation +- Added to Rust workspace (`test-applications/cdn-apps/rust/Cargo.toml`) +- Built to `wasm/cdn-apps/rust/headers/headers.wasm` + +### 5. AssemblyScript Test Application (`proxy-wasm-sdk-as/examples/headers/`) +- Updated to match Rust behavior: expected headers now include removed headers as empty-string entries +- Added `validateHeadersExact()` for strict onResponseHeaders validation +- Added response header cross-map validation (553-556 error codes) +- Host check uses `get_headers()` iteration instead of `get()` string comparison +- Built to `wasm/cdn-apps/as/headers/headers.wasm` + +### 6. Integration Test (`server/__tests__/integration/cdn-apps/headers/multi-value-headers.test.ts`) +- 20 tests total: 10 per variant (AS + Rust), using the variant pattern from `shared/variants.ts` +- Tests both `onRequestHeaders` and `onResponseHeaders` hooks +- Validates: multi-value add, replace, removeβ†’empty, response cross-map access, error paths (550) +- `_bytes` header assertions gated by `hasBytesVariants` flag (Rust only) + +### 7. Unit Tests (`server/__tests__/unit/runner/HeaderManager.test.ts`) +- Added tests for all 6 new tuple methods +- Round-trip tests: `recordToTuples` ↔ `tuplesToRecord` +- Multi-value serialization: `serializeTuples` with dup keys β†’ `deserializeBinaryToTuples` preserves them +- `tuplesToRecord` comma-joining validation + +## Key Implementation Details + +### nginx Remove Behavior +`proxy_remove_header_map_value` on nginx sets the header value to empty string rather than deleting the entry. This is because nginx's internal header structure doesn't support true deletion. The runner now matches this: +- Header exists β†’ filter out all entries, add one with empty value +- Header doesn't exist β†’ no-op (don't create a phantom entry) + +### Missing Header Behavior +`proxy_get_header_map_value` for a non-existent header returns `Ok` with empty string (not `NotFound`). This matches nginx behavior where missing headers return a zero-length string. The Rust SDK interprets a non-null return pointer as `Some("")`, which is distinct from `None` (null pointer). Our `writeStringResult("")` always writes a non-null pointer, so the behavior aligns. + +### Response Headers During Request Phase +On the real FastEdge CDN, response headers are accessible during `onRequestHeaders` (the map exists but is empty). The runner matches this: WASM can add/modify/read response headers during request hooks. The Rust example validates this by adding `new-response-header`, removing non-existent `cache-control`, and checking the response header count. + +### Fixture: Response Host Header +The onResponseHeaders test fixture uses `host: ""` (empty string), not `host: "example.com"`. On the real server, the response header map has a pre-allocated "host" field initialized to empty. The WASM validates `get_response_header("host")` returns `Some("")` β€” non-empty values trigger error 554. + +## Files Modified +- `server/runner/types.ts` β€” Added `HeaderTuples` type +- `server/runner/HeaderManager.ts` β€” 6 new tuple methods +- `server/runner/HostFunctions.ts` β€” Internal storage + all proxy_* functions +- `server/__tests__/unit/runner/HeaderManager.test.ts` β€” New tuple method tests + +## Files Created +- `test-applications/cdn-apps/rust/cdn-headers/` β€” Rust test crate (Cargo.toml + src/lib.rs) +- `wasm/cdn-apps/rust/headers/headers.wasm` β€” Compiled Rust WASM +- `wasm/cdn-apps/as/headers/headers.wasm` β€” Compiled AS WASM +- `server/__tests__/integration/cdn-apps/headers/multi-value-headers.test.ts` β€” Integration test + +## Cross-Repo Changes +- `proxy-wasm-sdk-as/examples/headers/assembly/index.ts` β€” Updated to match nginx/Rust behavior +- `test-applications/cdn-apps/rust/Cargo.toml` β€” Added `cdn-headers` to workspace members + +## Testing + +```bash +# Unit tests (HeaderManager tuple methods) +pnpm run test:unit + +# Integration tests (both AS + Rust variants) +NODE_OPTIONS='--no-warnings' npx vitest run --config vitest.integration.cdn.config.ts server/__tests__/integration/cdn-apps/headers/ + +# All tests +pnpm test +``` + +## Error Code Reference (cdn-headers WASM) + +| Code | Trigger | Meaning | +|------|---------|---------| +| 550 | `get_headers()` empty | No headers present | +| 551 | `get_header("host")` is None | Host header missing (request phase only) | +| 552 | Header diff mismatch | add/replace/remove didn't produce expected results | +| 553 | Response header inaccessible | `get_response_header()` returned None | +| 554 | Response header non-empty | Expected empty value for pre-allocated header | +| 555 | Response header count wrong | Unexpected number of response headers | +| 556 | Response header value wrong | Specific header name/value validation failed | diff --git a/context/features/NPM_PACKAGE_PLAN.md b/context/features/NPM_PACKAGE_PLAN.md index c6eb788..94d702a 100644 --- a/context/features/NPM_PACKAGE_PLAN.md +++ b/context/features/NPM_PACKAGE_PLAN.md @@ -39,7 +39,7 @@ Made `fastedge-config.test.json` and all API request/response bodies a versioned - **Runner-internal types** (execution results): TypeScript types β†’ JSON Schema via `ts-json-schema-generator` **New Files:** -- `server/schemas/config.ts` β€” Zod schemas: `TestConfigSchema`, `RequestConfigSchema`, `ResponseConfigSchema`, `WasmConfigSchema` +- `server/schemas/config.ts` β€” Zod schemas: `TestConfigSchema`, `CdnRequestConfigSchema`, `HttpRequestConfigSchema`, `RequestConfigSchema` (backward-compat alias for `CdnRequestConfigSchema`), `ResponseConfigSchema`, `WasmConfigSchema` - `server/schemas/api.ts` β€” Zod schemas: `ApiLoadBodySchema`, `ApiSendBodySchema`, `ApiCallBodySchema`, `ApiConfigBodySchema` - `server/schemas/index.ts` β€” re-exports - `scripts/generate-schemas.ts` β€” schema generation build step diff --git a/docs/API.md b/docs/API.md index 9ef41cd..aa8eb13 100644 --- a/docs/API.md +++ b/docs/API.md @@ -16,12 +16,12 @@ The port can be overridden via the `PORT` environment variable. When `WORKSPACE_ The `POST /api/execute`, `POST /api/send`, and `POST /api/config` endpoints accept an optional `X-Source` request header that tags the origin of the operation in WebSocket broadcast events. -| Value | Description | -| ------------ | ------------------------------------------------------- | -| `ui` | Request originated from the web UI (default if omitted) | -| `ai_agent` | Request originated from an AI agent | -| `api` | Request originated from direct API usage | -| `system` | Request originated from an automated system | +| Value | Description | +| ---------- | ------------------------------------------------------- | +| `ui` | Request originated from the web UI (default if omitted) | +| `ai_agent` | Request originated from an AI agent | +| `api` | Request originated from direct API usage | +| `system` | Request originated from an automated system | ```http X-Source: ai_agent @@ -222,18 +222,19 @@ Requires a WASM module to be loaded via `POST /api/load`. Accepts an optional [` **Request Body** -For **HTTP-WASM**, the top-level `url`, `method`, `headers`, and `body` fields are used directly as the request: +For **HTTP-WASM**, provide either `path` (preferred) or `url` (legacy). When `path` is given, it is used directly as the request path (e.g. `/api/hello?q=1`). When only `url` is given, the path and query string are extracted from it. ```typescript { - url: string; // Full URL (path and query extracted from this) + path?: string; // Request path and query string (preferred) + url?: string; // Full URL β€” path and query extracted (legacy fallback) method?: string; // HTTP method (default: "GET") - headers?: Record; // Request headers - body?: string; // Request body + headers?: Record; // Request headers (default: {}) + body?: string; // Request body (default: "") } ``` -For **Proxy-WASM**, the same top-level `url` field is required for URL parsing. The full CDN flow is controlled via nested `request`, `response`, and `properties` fields: +For **Proxy-WASM**, the top-level `url` field is required. The full CDN flow is controlled via nested `request`, `response`, and `properties` fields: ```typescript { @@ -317,7 +318,7 @@ curl -X POST http://localhost:5179/api/execute \ -H "Content-Type: application/json" \ -H "X-Source: api" \ -d '{ - "url": "http://example.com/api/data", + "path": "/api/data?format=json", "method": "GET", "headers": { "accept": "application/json" } }' @@ -403,10 +404,10 @@ curl -X POST http://localhost:5179/api/execute \ **Error Responses** -| Status | Condition | -| ------ | ------------------------------------------ | -| `400` | No WASM module loaded, or `url` is missing | -| `500` | Execution failed | +| Status | Condition | +| ------ | -------------------------------------------------------------------------------------- | +| `400` | No WASM module loaded, or missing `path`/`url` for HTTP-WASM, or missing `url` for Proxy-WASM | +| `500` | Execution failed | --- @@ -671,16 +672,15 @@ Reads the `fastedge-config.test.json` file from the project root and returns it } ``` -Where `TestConfig` is: +`TestConfig` is a discriminated union on `appType`: ```typescript -type TestConfig = { +// Proxy-WASM config (appType defaults to "proxy-wasm") +type ProxyWasmConfig = { $schema?: string; description?: string; - wasm?: { - path: string; - description?: string; - }; + appType: "proxy-wasm"; + wasm?: { path: string; description?: string }; request: { method: string; url: string; @@ -692,11 +692,26 @@ type TestConfig = { body: string; }; properties: Record; - dotenv?: { - enabled?: boolean; - path?: string; + dotenv?: { enabled?: boolean; path?: string }; +}; + +// HTTP-WASM config +type HttpWasmConfig = { + $schema?: string; + description?: string; + appType: "http-wasm"; + wasm?: { path: string; description?: string }; + request: { + method: string; + path: string; + headers: Record; + body: string; }; + properties: Record; + dotenv?: { enabled?: boolean; path?: string }; }; + +type TestConfig = ProxyWasmConfig | HttpWasmConfig; ``` **Example** @@ -710,6 +725,7 @@ curl http://localhost:5179/api/config "ok": true, "config": { "$schema": "http://localhost:5179/api/schema/fastedge-config.test", + "appType": "proxy-wasm", "request": { "method": "GET", "url": "https://example.com/", @@ -744,32 +760,12 @@ Accepts an optional [`X-Source`](#x-source-header) request header. ```typescript { - config: { - $schema?: string; - description?: string; - wasm?: { - path: string; - description?: string; - }; - request: { // Required - method: string; - url: string; - headers: Record; - body: string; - }; - response?: { - headers: Record; - body: string; - }; - properties: Record; // Required - dotenv?: { - enabled?: boolean; - path?: string; - }; - }; + config: TestConfig; // See GET /api/config for the TestConfig type } ``` +The `config` object must match one of the two `TestConfig` variants. `properties` and `appType` are required in both variants; `request` is required and its shape depends on `appType` (`path` for `"http-wasm"`, `url` for `"proxy-wasm"`). + **Response** ```typescript @@ -787,6 +783,7 @@ curl -X POST http://localhost:5179/api/config \ -d '{ "config": { "$schema": "http://localhost:5179/api/schema/fastedge-config.test", + "appType": "proxy-wasm", "request": { "method": "GET", "url": "https://example.com/", @@ -812,10 +809,10 @@ curl -X POST http://localhost:5179/api/config \ **Error Responses** -| Status | Condition | -| ------ | ------------------------------------------------------------------- | -| `400` | Validation failed (missing `config.request` or `config.properties`) | -| `500` | File write failed | +| Status | Condition | +| ------ | ---------------------------------------------------------------------------------- | +| `400` | Validation failed (missing `config.appType`, `config.request`, or `config.properties`) | +| `500` | File write failed | --- @@ -848,6 +845,7 @@ curl -X POST http://localhost:5179/api/config/save-as \ -H "Content-Type: application/json" \ -d '{ "config": { + "appType": "proxy-wasm", "request": { "method": "GET", "url": "https://example.com/", @@ -892,23 +890,23 @@ Returns the JSON Schema document with `Content-Type: application/json`. #### Request Schemas -| Name | Description | -| -------------- | ------------------------------------------ | -| `api-load` | Request body schema for `POST /api/load` | -| `api-send` | Request body schema for `POST /api/send` | -| `api-call` | Request body schema for `POST /api/call` | -| `api-config` | Request body schema for `POST /api/config` | +| Name | Description | +| ------------ | ------------------------------------------ | +| `api-load` | Request body schema for `POST /api/load` | +| `api-send` | Request body schema for `POST /api/send` | +| `api-call` | Request body schema for `POST /api/call` | +| `api-config` | Request body schema for `POST /api/config` | #### Response / Type Schemas -| Name | Description | -| ------------------------ | ------------------------------------------------------------- | -| `fastedge-config.test` | Schema for `fastedge-config.test.json` config files | -| `hook-result` | Shape of a single `HookResult` object | -| `hook-call` | Shape of a `HookCall` input object | -| `full-flow-result` | Shape of the `FullFlowResult` returned by full-flow endpoints | -| `http-request` | Shape of an `HttpRequest` for HTTP-WASM execution | -| `http-response` | Shape of an `HttpResponse` returned by HTTP-WASM execution | +| Name | Description | +| ---------------------- | ------------------------------------------------------------- | +| `fastedge-config.test` | Schema for `fastedge-config.test.json` config files | +| `hook-result` | Shape of a single `HookResult` object | +| `hook-call` | Shape of a `HookCall` input object | +| `full-flow-result` | Shape of the `FullFlowResult` returned by full-flow endpoints | +| `http-request` | Shape of an `HttpRequest` for HTTP-WASM execution | +| `http-response` | Shape of an `HttpResponse` returned by HTTP-WASM execution | **Example** @@ -925,6 +923,7 @@ curl http://localhost:5179/api/schema/fastedge-config.test ```json { "$schema": "http://localhost:5179/api/schema/fastedge-config.test", + "appType": "proxy-wasm", "request": { "method": "GET", "url": "https://example.com/", diff --git a/docs/DEBUGGER.md b/docs/DEBUGGER.md index 6793c5f..d516718 100644 --- a/docs/DEBUGGER.md +++ b/docs/DEBUGGER.md @@ -90,13 +90,13 @@ curl http://localhost:5179/health ## Environment Variables -| Variable | Type | Default | Description | -| -------------------- | -------- | ------- | ------------------------------------------------------------------------------------------------------------------------- | -| `PORT` | `number` | unset | Port the HTTP server listens on. Defaults to `5179` when not set. | -| `PROXY_RUNNER_DEBUG` | `"1"` | unset | Enable verbose debug logging for WebSocket and runner activity. | +| Variable | Type | Default | Description | +| -------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------- | +| `PORT` | `number` | unset | Port the HTTP server listens on. Defaults to `5179` when not set. | +| `PROXY_RUNNER_DEBUG` | `"1"` | unset | Enable verbose debug logging for WebSocket and runner activity. | | `VSCODE_INTEGRATION` | `"true"` | unset | Set to `"true"` when running in VSCode extension context; enables the `` path placeholder in WASM path loading. | -| `WORKSPACE_PATH` | `string` | unset | Absolute path to the workspace root; used as the `.env` file base and for port file placement. | -| `FASTEDGE_RUN_PATH` | `string` | unset | Override the path to the `fastedge-run` CLI binary used to execute WASM modules. | +| `WORKSPACE_PATH` | `string` | unset | Absolute path to the workspace root; used as the `.env` file base and for port file placement. | +| `FASTEDGE_RUN_PATH` | `string` | unset | Override the path to the `fastedge-run` CLI binary used to execute WASM modules. | ### Usage examples diff --git a/docs/INDEX.md b/docs/INDEX.md index 282bd34..5cc7921 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -71,43 +71,43 @@ Exports the high-level test framework. See [TEST_FRAMEWORK.md](TEST_FRAMEWORK.md import { defineTestSuite, runAndExit } from "@gcoredev/fastedge-test/test"; ``` -| Export | Kind | Description | -| ---------------------------- | -------- | ------------------------------------------------------------------ | -| `defineTestSuite` | function | Validates and returns a typed `TestSuite` definition | -| `runTestSuite` | function | Executes a `TestSuite` and returns a `SuiteResult` | -| `runAndExit` | function | Runs a suite and exits the process with a pass/fail code | -| `runFlow` | function | Executes a single request flow directly | -| `runHttpRequest` | function | Executes a single HTTP request directly | -| `loadConfigFile` | function | Loads and validates a `fastedge-config.test.json` file | -| `assertRequestHeader` | function | Asserts a header is present on the outgoing request | -| `assertNoRequestHeader` | function | Asserts a header is absent from the outgoing request | -| `assertResponseHeader` | function | Asserts a header is present on the final response | -| `assertNoResponseHeader` | function | Asserts a header is absent from the final response | -| `assertFinalStatus` | function | Asserts the final HTTP status code | -| `assertFinalHeader` | function | Asserts a header on the final response (alias for response header) | -| `assertReturnCode` | function | Asserts the proxy-wasm return code | -| `assertLog` | function | Asserts a log entry was emitted | -| `assertNoLog` | function | Asserts a log entry was not emitted | -| `logsContain` | function | Returns whether logs contain a matching entry | -| `hasPropertyAccessViolation` | function | Returns whether any property access violation was recorded | -| `assertPropertyAllowed` | function | Asserts that a WASM property read was allowed | -| `assertPropertyDenied` | function | Asserts that a WASM property read was denied | -| `assertHttpStatus` | function | Asserts the HTTP response status code | -| `assertHttpHeader` | function | Asserts a header is present on the HTTP response | -| `assertHttpNoHeader` | function | Asserts a header is absent from the HTTP response | -| `assertHttpBody` | function | Asserts the HTTP response body equals a value | -| `assertHttpBodyContains` | function | Asserts the HTTP response body contains a substring | -| `assertHttpJson` | function | Asserts the HTTP response body matches a JSON value | -| `assertHttpContentType` | function | Asserts the HTTP response Content-Type header | -| `assertHttpLog` | function | Asserts a log entry was emitted during HTTP request handling | -| `assertHttpNoLog` | function | Asserts a log entry was not emitted during HTTP request handling | +| Export | Kind | Description | +| ---------------------------- | -------- | -------------------------------------------------------------------- | +| `defineTestSuite` | function | Validates and returns a typed `TestSuite` definition | +| `runTestSuite` | function | Executes a `TestSuite` and returns a `SuiteResult` | +| `runAndExit` | function | Runs a suite and exits the process with a pass/fail code | +| `runFlow` | function | Executes a single request flow directly | +| `runHttpRequest` | function | Executes a single HTTP request directly | +| `loadConfigFile` | function | Loads and validates a `fastedge-config.test.json` file | +| `assertRequestHeader` | function | Asserts a header is present on the outgoing request | +| `assertNoRequestHeader` | function | Asserts a header is absent from the outgoing request | +| `assertResponseHeader` | function | Asserts a header is present on the final response | +| `assertNoResponseHeader` | function | Asserts a header is absent from the final response | +| `assertFinalStatus` | function | Asserts the final HTTP status code | +| `assertFinalHeader` | function | Asserts a header on the final response (alias for response header) | +| `assertReturnCode` | function | Asserts the proxy-wasm return code | +| `assertLog` | function | Asserts a log entry was emitted | +| `assertNoLog` | function | Asserts a log entry was not emitted | +| `logsContain` | function | Returns whether logs contain a matching entry | +| `hasPropertyAccessViolation` | function | Returns whether any property access violation was recorded | +| `assertPropertyAllowed` | function | Asserts that a WASM property read was allowed | +| `assertPropertyDenied` | function | Asserts that a WASM property read was denied | +| `assertHttpStatus` | function | Asserts the HTTP response status code | +| `assertHttpHeader` | function | Asserts a header is present on the HTTP response | +| `assertHttpNoHeader` | function | Asserts a header is absent from the HTTP response | +| `assertHttpBody` | function | Asserts the HTTP response body equals a value | +| `assertHttpBodyContains` | function | Asserts the HTTP response body contains a substring | +| `assertHttpJson` | function | Asserts the HTTP response body matches a JSON value | +| `assertHttpContentType` | function | Asserts the HTTP response Content-Type header | +| `assertHttpLog` | function | Asserts a log entry was emitted during HTTP request handling | +| `assertHttpNoLog` | function | Asserts a log entry was not emitted during HTTP request handling | | `TestSuite` | type | Suite definition β€” one of `wasmPath` or `wasmBuffer` plus test cases | -| `TestCase` | type | A single test scenario with config and assertions | -| `TestResult` | type | Result of a single test case execution | -| `SuiteResult` | type | Aggregated result returned by `runTestSuite` | -| `FlowOptions` | type | Options accepted by `runFlow` | -| `HttpRequestOptions` | type | Options accepted by `runHttpRequest` | -| `RunnerConfig` | type | Configuration options for the underlying runner | +| `TestCase` | type | A single test scenario with config and assertions | +| `TestResult` | type | Result of a single test case execution | +| `SuiteResult` | type | Aggregated result returned by `runTestSuite` | +| `FlowOptions` | type | Options accepted by `runFlow` | +| `HttpRequestOptions` | type | Options accepted by `runHttpRequest` | +| `RunnerConfig` | type | Configuration options for the underlying runner | ### `@gcoredev/fastedge-test/server` diff --git a/docs/RUNNER.md b/docs/RUNNER.md index f872871..4601c26 100644 --- a/docs/RUNNER.md +++ b/docs/RUNNER.md @@ -169,18 +169,18 @@ callFullFlow( **Parameters** -| Parameter | Type | Description | -| -------------------------------- | ------------------------- | ----------------------------------------------------------------------- | -| `url` | `string` | Full request URL, or `BUILTIN_SHORTHAND` (`"built-in"`) to use the built-in responder instead of a real origin fetch | -| `method` | `string` | HTTP method | -| `headers` | `Record` | Request headers | -| `body` | `string` | Request body | -| `responseHeaders` | `Record` | Upstream response headers (used as initial state for response hooks) | -| `responseBody` | `string` | Upstream response body | -| `responseStatus` | `number` | Upstream response status code | -| `responseStatusText` | `string` | Upstream response status text | -| `properties` | `Record` | Shared properties passed to all hooks | -| `enforceProductionPropertyRules` | `boolean` | When `true`, restricts property access to match CDN production behavior | +| Parameter | Type | Description | +| ---------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `url` | `string` | Full request URL, or `BUILTIN_SHORTHAND` (`"built-in"`) to use the built-in responder instead of a real origin fetch | +| `method` | `string` | HTTP method | +| `headers` | `Record` | Request headers | +| `body` | `string` | Request body | +| `responseHeaders` | `Record` | Upstream response headers (used as initial state for response hooks) | +| `responseBody` | `string` | Upstream response body | +| `responseStatus` | `number` | Upstream response status code | +| `responseStatusText` | `string` | Upstream response status text | +| `properties` | `Record` | Shared properties passed to all hooks | +| `enforceProductionPropertyRules` | `boolean` | When `true`, restricts property access to match CDN production behavior | Hook execution order: `onRequestHeaders` β†’ `onRequestBody` β†’ *(real HTTP fetch or built-in responder)* β†’ `onResponseHeaders` β†’ `onResponseBody`. @@ -285,10 +285,10 @@ const result = await runner.callFullFlow( **Built-in responder behavior** β€” controlled by request headers set before the origin phase: -| Header | Effect | -| ----------------------- | ---------------------------------------------------------------------- | -| `x-debugger-status` | HTTP status code for the generated response (default: `200`) | -| `x-debugger-content` | Response body mode: `"body-only"`, `"status-only"`, or full JSON echo (default) | +| Header | Effect | +| ------------------------ | ------------------------------------------------------------------------------- | +| `x-debugger-status` | HTTP status code for the generated response (default: `200`) | +| `x-debugger-content` | Response body mode: `"body-only"`, `"status-only"`, or full JSON echo (default) | When `x-debugger-content` is omitted, the built-in responder returns a JSON echo of the request method, headers, body, and URL. Both control headers are stripped before response hooks execute so they do not appear in hook input state. @@ -309,12 +309,12 @@ interface RunnerConfig { } ``` -| Field | Type | Default | Description | -| -------------------------------- | ---------- | ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `dotenv.enabled` | `boolean` | `false` | Whether to load `.env` files | -| `dotenv.path` | `string` | `undefined` | Directory to load dotenv files from. When omitted, `fastedge-run` uses the process CWD β€” correct for most npm package users whose `.env` files live at the project root. Only set this when your dotenv files are in a non-standard location (e.g. a test fixture directory). | -| `enforceProductionPropertyRules` | `boolean` | `true` | Restrict property access to match CDN production behavior | -| `runnerType` | `WasmType` | auto-detected | Override WASM type detection | +| Field | Type | Default | Description | +| ---------------------------------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `dotenv.enabled` | `boolean` | `false` | Whether to load `.env` files | +| `dotenv.path` | `string` | `undefined` | Directory to load dotenv files from. When omitted, `fastedge-run` uses the process CWD β€” correct for most npm package users whose `.env` files live at the project root. Only set this when your dotenv files are in a non-standard location (e.g. a test fixture directory). | +| `enforceProductionPropertyRules` | `boolean` | `true` | Restrict property access to match CDN production behavior | +| `runnerType` | `WasmType` | auto-detected | Override WASM type detection | ### HttpRequest & HttpResponse @@ -367,14 +367,14 @@ type HookCall = { }; ``` -| Field | Description | -| -------------------------------- | --------------------------------------------------------------------------------------------------- | -| `hook` | Hook name: `"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"` | -| `request` | Request state passed to the hook | -| `response` | Response state passed to the hook | -| `properties` | Shared properties (e.g. `request.path`, `vm_config`, `plugin_config`) | -| `dotenvEnabled` | Optional per-call dotenv override. Use `applyDotenv()` for persistent changes. | -| `enforceProductionPropertyRules` | Defaults to `true`. Set to `false` to allow property reads that would be blocked on production CDN. | +| Field | Description | +| ---------------------------------- | --------------------------------------------------------------------------------------------------- | +| `hook` | Hook name: `"onRequestHeaders"`, `"onRequestBody"`, `"onResponseHeaders"`, `"onResponseBody"` | +| `request` | Request state passed to the hook | +| `response` | Response state passed to the hook | +| `properties` | Shared properties (e.g. `request.path`, `vm_config`, `plugin_config`) | +| `dotenvEnabled` | Optional per-call dotenv override. Use `applyDotenv()` for persistent changes. | +| `enforceProductionPropertyRules` | Defaults to `true`. Set to `false` to allow property reads that would be blocked on production CDN. | ### HookResult diff --git a/docs/TEST_CONFIG.md b/docs/TEST_CONFIG.md index f9c0d03..cc94698 100644 --- a/docs/TEST_CONFIG.md +++ b/docs/TEST_CONFIG.md @@ -6,37 +6,47 @@ Configuration file reference for `fastedge-config.test.json` β€” the per-test JS Each test scenario is described by a `fastedge-config.test.json` file. The file is validated against a JSON Schema at load time. The test runner (and `loadConfigFile`) applies Zod defaults at runtime, so fields with defaults do not need to be present in the file β€” but editors validating against the `$schema` URI will flag missing required fields unless you supply them explicitly. -**Required fields** (per JSON Schema `required` array): `request` and `properties` at the top level; `method`, `url`, `headers`, and `body` within `request`. +The config schema is a union of two variants selected by `appType`: -**Runtime defaults**: The Zod runtime fills in `method`, `headers`, and `body` if absent, so those fields are optional in practice. Similarly, `properties` defaults to `{}` at runtime even though the JSON Schema marks it required. The JSON Schema marks them as required because it cannot express Zod's default-filling behaviour. Supplying explicit values avoids editor warnings. +- **`proxy-wasm`** (CDN mode, default): The WASM module intercepts an upstream HTTP request. Uses `request.url` (full URL). Supports a mock origin `response`. +- **`http-wasm`**: The WASM module acts as an origin HTTP server. Uses `request.path` (path only). No `response` field. + +**Required fields** (per JSON Schema `required` arrays): +- Top-level: `properties`, `appType`, and `request` +- Within `request` (CDN): `method`, `url`, `headers`, `body` +- Within `request` (HTTP-WASM): `method`, `path`, `headers`, `body` + +**Runtime defaults**: The Zod runtime fills in `appType` (`"proxy-wasm"` for CDN), `method`, `headers`, `body`, and `properties` if absent, so those fields are optional in practice. The JSON Schema marks them required because it cannot express Zod's default-filling behaviour. Supplying explicit values avoids editor warnings. For HTTP-WASM configs, `appType: "http-wasm"` has no runtime default and **must** be specified. ## Schema Reference ### Top-Level Fields -| JSON Path | Type | Required (Schema) | Default | Description | -| ------------------ | --------- | ---------------------------------- | ------- | ------------------------------------------------------------------------------------------------ | -| `$schema` | `string` | No | β€” | URI pointing to the JSON Schema file for IDE autocompletion and validation. | -| `description` | `string` | No | β€” | Human-readable label for this test scenario. | -| `wasm` | `object` | No | β€” | WASM binary configuration. Required when running without a programmatic `wasmBuffer`. | -| `wasm.path` | `string` | Yes (if `wasm` present) | β€” | Path to the compiled `.wasm` binary, relative to the config file or absolute. | -| `wasm.description` | `string` | No | β€” | Human-readable label for the WASM binary. | -| `request` | `object` | **Yes** | β€” | Incoming HTTP request to simulate. | -| `request.method` | `string` | Yes (schema) / runtime default | `"GET"` | HTTP method (e.g. `"GET"`, `"POST"`). | -| `request.url` | `string` | **Yes** | β€” | Full URL or path for the simulated request (e.g. `"https://example.com/api"`). | -| `request.headers` | `object` | Yes (schema) / runtime default | `{}` | Key/value map of request headers. All keys and values must be strings. | -| `request.body` | `string` | Yes (schema) / runtime default | `""` | Request body as a plain string. Use an empty string for requests with no body. | -| `response` | `object` | No | β€” | Mock origin response for CDN mode. Not applicable to HTTP-WASM. | -| `response.headers` | `object` | Yes (if `response` present) | `{}` | Key/value map of mock origin response headers. | -| `response.body` | `string` | Yes (if `response` present) | `""` | Mock origin response body as a plain string. | -| `properties` | `object` | **Yes** (schema) / runtime default | `{}` | CDN property key/value pairs passed to the WASM execution context. Values may be any JSON type. | -| `dotenv` | `object` | No | β€” | Dotenv file loading configuration. | -| `dotenv.enabled` | `boolean` | No | β€” | Whether to load a `.env` file before execution. | -| `dotenv.path` | `string` | No | β€” | Path to the `.env` file. If omitted, resolves `.env` relative to the config file directory. | +| JSON Path | Type | Required (Schema) | Default | Description | +| -------------------- | --------- | ------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------- | +| `$schema` | `string` | No | β€” | URI pointing to the JSON Schema file for IDE autocompletion and validation. | +| `description` | `string` | No | β€” | Human-readable label for this test scenario. | +| `wasm` | `object` | No | β€” | WASM binary configuration. Required when running without a programmatic `wasmBuffer`. | +| `wasm.path` | `string` | Yes (if `wasm` present) | β€” | Path to the compiled `.wasm` binary, relative to the config file or absolute. | +| `wasm.description` | `string` | No | β€” | Human-readable label for the WASM binary. | +| `appType` | `string` | Yes (schema) / CDN has runtime default | `"proxy-wasm"` | App variant. `"proxy-wasm"` for CDN mode; `"http-wasm"` for HTTP mode. HTTP-WASM has no default. | +| `request` | `object` | **Yes** | β€” | Incoming HTTP request to simulate. | +| `request.method` | `string` | Yes (schema) / runtime default | `"GET"` | HTTP method (e.g. `"GET"`, `"POST"`). | +| `request.url` | `string` | **Yes** (CDN only) | β€” | Full URL for the simulated upstream request (e.g. `"https://example.com/api"`). CDN mode only. | +| `request.path` | `string` | **Yes** (HTTP-WASM only) | β€” | Request path (e.g. `"/api/submit"`). HTTP-WASM mode only. The WASM module acts as the origin server. | +| `request.headers` | `object` | Yes (schema) / runtime default | `{}` | Key/value map of request headers. All keys and values must be strings. | +| `request.body` | `string` | Yes (schema) / runtime default | `""` | Request body as a plain string. Use an empty string for requests with no body. | +| `response` | `object` | No | β€” | Mock origin response for CDN mode. Not applicable to HTTP-WASM. | +| `response.headers` | `object` | Yes (if `response` present) | `{}` | Key/value map of mock origin response headers. | +| `response.body` | `string` | Yes (if `response` present) | `""` | Mock origin response body as a plain string. | +| `properties` | `object` | **Yes** (schema) / runtime default | `{}` | CDN property key/value pairs passed to the WASM execution context. Values may be any JSON type. | +| `dotenv` | `object` | No | β€” | Dotenv file loading configuration. | +| `dotenv.enabled` | `boolean` | No | β€” | Whether to load a `.env` file before execution. | +| `dotenv.path` | `string` | No | β€” | Path to the `.env` file. If omitted, resolves `.env` relative to the config file directory. | ### Required vs. Default Distinction -The JSON Schema's `required` arrays drive editor validation. Fields like `request.method`, `request.headers`, `request.body`, and `properties` appear in the schema's `required` array (or top-level `required`), so a strict JSON Schema validator will flag them as missing. At runtime, the Zod schema fills in their defaults (`"GET"`, `{}`, `""`, and `{}` respectively), so the test runner accepts configs that omit them. +The JSON Schema's `required` arrays drive editor validation. Fields like `appType`, `request.method`, `request.headers`, `request.body`, and `properties` appear in the schema's `required` array, so a strict JSON Schema validator will flag them as missing. At runtime, the Zod schema fills in their defaults (`"proxy-wasm"`, `"GET"`, `{}`, `""`, and `{}` respectively), so the test runner accepts configs that omit them β€” with the exception of `appType: "http-wasm"`, which has no Zod default and must always be specified for HTTP-WASM configs. To avoid editor warnings while keeping configs concise, either supply the fields explicitly or add the `$schema` field and accept that your editor may warn on omission. @@ -58,11 +68,12 @@ When `dotenv.enabled` is `true`, the runner loads a `.env` file and merges its c ### Minimal CDN Configuration -The smallest valid config. `request` and `properties` are required; all other fields use runtime defaults or are omitted. +The smallest valid config. `appType`, `request`, and `properties` are required by the schema; `appType` and the `request` sub-fields `method`, `headers`, and `body` have runtime defaults and can be omitted in practice, but are included here for schema compliance. ```json { "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json", + "appType": "proxy-wasm", "wasm": { "path": "./dist/handler.wasm" }, @@ -84,6 +95,7 @@ A CDN scenario that passes property values to the WASM context and loads secrets { "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json", "description": "CDN handler with feature flags and auth secret", + "appType": "proxy-wasm", "wasm": { "path": "./dist/handler.wasm", "description": "Production CDN handler" @@ -118,18 +130,19 @@ DEBUG_MODE=false ### HTTP-WASM Configuration -An HTTP-WASM scenario simulating a `POST` request with a JSON body. The `response` field is not relevant to HTTP-WASM execution; `properties` is still required by the schema and defaults to `{}` at runtime. +An HTTP-WASM scenario simulating a `POST` request with a JSON body. `appType` must be `"http-wasm"` β€” there is no runtime default for this variant. Use `request.path` (not `request.url`); the WASM module acts as the origin server and receives only the path portion of the request. ```json { "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json", "description": "HTTP-WASM POST handler", + "appType": "http-wasm", "wasm": { "path": "./dist/http-handler.wasm" }, "request": { "method": "POST", - "url": "https://api.example.com/submit", + "path": "/submit", "headers": { "content-type": "application/json", "authorization": "Bearer test-token" @@ -148,6 +161,7 @@ A CDN scenario where the mock origin returns a specific response. Use this to te { "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json", "description": "CDN handler with custom mock origin response", + "appType": "proxy-wasm", "wasm": { "path": "./dist/handler.wasm" }, @@ -200,39 +214,78 @@ import type { TestConfig } from "@gcoredev/fastedge-test/test"; const config: TestConfig = await loadConfigFile("./fastedge-config.test.json"); -console.log(config.request.url); // string -console.log(config.properties); // Record -console.log(config.wasm?.path); // string | undefined +console.log(config.appType); // "proxy-wasm" | "http-wasm" +console.log(config.properties); // Record +console.log(config.wasm?.path); // string | undefined ``` `loadConfigFile` reads the file, parses JSON, and validates it through the Zod schema (applying defaults). It throws a descriptive `Error` if the file cannot be read, is not valid JSON, or fails schema validation. -The returned `TestConfig` type reflects the Zod-inferred shape after defaults are applied: +The returned `TestConfig` type is a union discriminated by `appType`: ```typescript -type TestConfig = { +type TestConfig = CdnConfig | HttpConfig; + +type CdnConfig = { $schema?: string; description?: string; + appType: "proxy-wasm"; // default applied at runtime wasm?: { path: string; description?: string; }; request: { - method: string; // default: "GET" + method: string; // default: "GET" url: string; - headers: Record; // default: {} - body: string; // default: "" + headers: Record; // default: {} + body: string; // default: "" }; response?: { - headers: Record; // default: {} - body: string; // default: "" + headers: Record; // default: {} + body: string; // default: "" }; - properties: Record; // default: {} + properties: Record; // default: {} dotenv?: { enabled?: boolean; path?: string; }; }; + +type HttpConfig = { + $schema?: string; + description?: string; + appType: "http-wasm"; // no default β€” must be specified + wasm?: { + path: string; + description?: string; + }; + request: { + method: string; // default: "GET" + path: string; + headers: Record; // default: {} + body: string; // default: "" + }; + properties: Record; // default: {} + dotenv?: { + enabled?: boolean; + path?: string; + }; +}; +``` + +Use `appType` to narrow the union before accessing variant-specific fields: + +```typescript +import { loadConfigFile } from "@gcoredev/fastedge-test/test"; + +const config = await loadConfigFile("./fastedge-config.test.json"); + +if (config.appType === "proxy-wasm") { + console.log(config.request.url); // string β€” CDN full URL + console.log(config.response); // ResponseConfig | undefined +} else { + console.log(config.request.path); // string β€” HTTP-WASM path +} ``` ## See Also diff --git a/docs/TEST_FRAMEWORK.md b/docs/TEST_FRAMEWORK.md index 34a5176..6d0cd75 100644 --- a/docs/TEST_FRAMEWORK.md +++ b/docs/TEST_FRAMEWORK.md @@ -640,4 +640,4 @@ await runAndExit(suite); - [RUNNER.md](RUNNER.md) β€” Low-level `IWasmRunner` interface, `RunnerConfig`, and `callFullFlow` - [API.md](API.md) β€” REST API for running tests via HTTP - [TEST_CONFIG.md](TEST_CONFIG.md) β€” `fastedge-config.test.json` schema and `loadConfigFile` config options -- [quickstart.md](quickstart.md) β€” Installation and first test walkthrough +- [DEBUGGER.md](DEBUGGER.md) β€” Interactive debugger server for step-through WASM execution diff --git a/docs/WEBSOCKET.md b/docs/WEBSOCKET.md index c097994..be3362a 100644 --- a/docs/WEBSOCKET.md +++ b/docs/WEBSOCKET.md @@ -68,13 +68,13 @@ interface WasmLoadedEvent { } ``` -| Field | Type | Description | -| --------------- | ----------------------------- | -------------------------------------------------------------------- | -| `filename` | `string` | Name of the loaded WASM file | -| `size` | `number` | File size in bytes | +| Field | Type | Description | +| --------------- | ----------------------------- | ------------------------------------------------------------------- | +| `filename` | `string` | Name of the loaded WASM file | +| `size` | `number` | File size in bytes | | `runnerPort?` | `number \| null` | Port the runner is listening on, if applicable. Omitted when not set | -| `wasmType` | `'proxy-wasm' \| 'http-wasm'` | The WASM filter type | -| `resolvedPath?` | `string \| null` | Absolute filesystem path to the loaded binary. Omitted when not set | +| `wasmType` | `'proxy-wasm' \| 'http-wasm'` | The WASM filter type | +| `resolvedPath?` | `string \| null` | Absolute filesystem path to the loaded binary. Omitted when not set | **Example:** diff --git a/docs/quickstart.md b/docs/quickstart.md index e580010..2bdb4c0 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -135,6 +135,7 @@ Place a `fastedge-config.test.json` file in your project root to define default ```json { "$schema": "./node_modules/@gcoredev/fastedge-test/schemas/fastedge-config.test.schema.json", + "appType": "proxy-wasm", "wasm": { "path": "./dist/my-module.wasm" }, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dc0d709..eda6b45 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,7 @@ function App() { hookResults, properties, mergeProperties, + setCalculatedProperties, // HTTP WASM state (for WebSocket event handling) setHttpResponse, @@ -155,22 +156,14 @@ function App() { // Full request completed (for proxy-wasm) - update all results and final response setHookResults(event.data.hookResults); setFinalResponse(event.data.finalResponse); - - // Update calculated properties from WebSocket event - console.log( - "[WebSocket] request_completed calculatedProperties:", - event.data.calculatedProperties, - ); + // Store calculated properties separately for read-only display. + // These are NOT in the editable `properties` store β€” no stale feedback loop. if (event.data.calculatedProperties) { - console.log("[WebSocket] Updating properties. Previous:", properties); - const propsToMerge: Record = {}; - for (const [key, value] of Object.entries( - event.data.calculatedProperties, - )) { - propsToMerge[key] = String(value); + const stringProps: Record = {}; + for (const [k, v] of Object.entries(event.data.calculatedProperties)) { + stringProps[k] = String(v); } - console.log("[WebSocket] Merging properties:", propsToMerge); - mergeProperties(propsToMerge); + setCalculatedProperties(stringProps); } break; @@ -248,6 +241,12 @@ function App() { throw new Error('Invalid config file structure'); } + // Warn about relative dotenv path β€” browser drag-drop hides the full + // file path, so relative paths will fall back to the server workspace root. + if (config.dotenv?.path && !config.dotenv.path.startsWith('/')) { + console.warn(`Config contains relative dotenv path "${config.dotenv.path}" β€” will resolve against server workspace root, not the config file location.`); + } + // Load config state loadFromConfig(config); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 88e67b9..9de4722 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,4 +1,6 @@ import { HookCall, HookResult } from "../types"; +import type { TestConfig } from "../stores/types"; +export type { TestConfig } from "../stores/types"; import { hasFilesystemAccess } from "../utils/environment"; import { getFilePath, hasFilePath, formatFileSize } from "../utils/filePath"; @@ -355,26 +357,7 @@ export async function sendFullFlow( }; } -export interface TestConfig { - appType?: 'proxy-wasm' | 'http-wasm'; - description?: string; - wasm?: { - path: string; - description?: string; - }; - request: { - method: string; - url: string; - headers: Record; - body: string; - }; - properties: Record; - logLevel: number; - dotenv?: { - enabled?: boolean; - path?: string; - }; -} +// TestConfig is imported from ../types export async function loadConfig(): Promise { const response = await fetch(`${API_BASE}/config`, { @@ -457,8 +440,20 @@ export async function executeHttpWasm( isBase64?: boolean; logs: Array<{ level: number; message: string }>; }> { + // Extract the path from the full internal URL and send `path` to the server. + // The server accepts both `path` and `url`, but `path` is the canonical form + // for HTTP WASM apps (mirrors how the real FastEdge server works). + let path: string; + try { + const parsed = new URL(url); + path = parsed.pathname + parsed.search; + } catch { + // If url is already a path (e.g. "/api/hello"), use it directly + path = url; + } + const payload = { - url, + path, method, headers, body, diff --git a/frontend/src/components/common/ConfigButtons/ConfigButtons.tsx b/frontend/src/components/common/ConfigButtons/ConfigButtons.tsx index f1dc7a4..268d432 100644 --- a/frontend/src/components/common/ConfigButtons/ConfigButtons.tsx +++ b/frontend/src/components/common/ConfigButtons/ConfigButtons.tsx @@ -17,6 +17,16 @@ export function ConfigButtons() { if (event.data.canceled) return; try { const config: TestConfig = JSON.parse(event.data.content); + + // Resolve relative dotenv.path against the config file's directory. + // Use URL API to normalize away . and .. segments (no Node path in browser). + if (config.dotenv?.path && event.data.configDir && !config.dotenv.path.startsWith('/')) { + config.dotenv.path = new URL( + config.dotenv.path, + `file://${event.data.configDir}/`, + ).pathname; + } + loadFromConfig(config); if (config.wasm?.path) { @@ -66,6 +76,13 @@ export function ConfigButtons() { const text = await file.text(); const config: TestConfig = JSON.parse(text); + + // Warn about relative dotenv path β€” browser file picker hides the full + // file path, so relative paths will fall back to the server workspace root. + if (config.dotenv?.path && !config.dotenv.path.startsWith('/')) { + console.warn(`Config contains relative dotenv path "${config.dotenv.path}" β€” will resolve against server workspace root, not the config file location.`); + } + loadFromConfig(config); // Auto-load WASM if path is specified diff --git a/frontend/src/components/common/DictionaryInput/DictionaryInput.tsx b/frontend/src/components/common/DictionaryInput/DictionaryInput.tsx index c279010..d00773c 100644 --- a/frontend/src/components/common/DictionaryInput/DictionaryInput.tsx +++ b/frontend/src/components/common/DictionaryInput/DictionaryInput.tsx @@ -80,11 +80,13 @@ export function DictionaryInput({ id: generateRowId(), key, value: val, - // Use the enabled state from defaultsMap if this key came from defaults, - // otherwise check if it exists in value prop (if so, it's enabled) - enabled: defaultsMap.has(key) - ? defaultsMap.get(key)! - : value.hasOwnProperty(key), + // If the key exists in the value prop (e.g., loaded from config), it's enabled. + // Otherwise, use the default's enabled state (unchecked defaults stay unchecked). + enabled: value.hasOwnProperty(key) + ? true + : defaultsMap.has(key) + ? defaultsMap.get(key)! + : false, // Use the placeholder from placeholdersMap if available placeholder: placeholdersMap.get(key), // Use the readOnly state from readOnlyMap if available @@ -111,7 +113,7 @@ export function DictionaryInput({ const updatedRows = currentRows.map((row) => { // If this key exists in the new value prop, update it if (row.key && value.hasOwnProperty(row.key)) { - return { ...row, value: value[row.key] }; + return { ...row, value: value[row.key], enabled: true }; } return row; }); diff --git a/frontend/src/components/proxy-wasm/PropertiesEditor/PropertiesEditor.tsx b/frontend/src/components/proxy-wasm/PropertiesEditor/PropertiesEditor.tsx index be16781..f95c06c 100644 --- a/frontend/src/components/proxy-wasm/PropertiesEditor/PropertiesEditor.tsx +++ b/frontend/src/components/proxy-wasm/PropertiesEditor/PropertiesEditor.tsx @@ -4,6 +4,7 @@ import styles from "./PropertiesEditor.module.css"; interface PropertiesEditorProps { value: Record; + calculatedProperties?: Record; onChange: (properties: Record) => void; } @@ -133,7 +134,23 @@ const getEnabledDefaults = (countryKey: string): Record => { return result; }; -export function PropertiesEditor({ value, onChange }: PropertiesEditorProps) { +/** Overlay server-calculated values onto the read-only default rows. */ +const getDefaultsWithCalculated = ( + countryKey: string, + calculated?: Record, +) => { + const defaults = getPropertiesForCountry(countryKey); + if (!calculated) return defaults; + const result = { ...defaults }; + for (const [key, val] of Object.entries(result)) { + if (typeof val === "object" && val.readOnly && key in calculated) { + result[key] = { ...val, value: calculated[key] }; + } + } + return result; +}; + +export function PropertiesEditor({ value, calculatedProperties, onChange }: PropertiesEditorProps) { const [selectedCountry, setSelectedCountry] = useState("luxembourg"); // Push default properties into the store on first mount only β€” @@ -170,13 +187,13 @@ export function PropertiesEditor({ value, onChange }: PropertiesEditorProps) { ))} ); diff --git a/frontend/src/components/proxy-wasm/ServerPropertiesPanel/ServerPropertiesPanel.tsx b/frontend/src/components/proxy-wasm/ServerPropertiesPanel/ServerPropertiesPanel.tsx index 883acb8..00ff4fd 100644 --- a/frontend/src/components/proxy-wasm/ServerPropertiesPanel/ServerPropertiesPanel.tsx +++ b/frontend/src/components/proxy-wasm/ServerPropertiesPanel/ServerPropertiesPanel.tsx @@ -3,16 +3,22 @@ import { PropertiesEditor } from "../PropertiesEditor"; interface ServerPropertiesPanelProps { properties: Record; + calculatedProperties: Record; onPropertiesChange: (properties: Record) => void; } export function ServerPropertiesPanel({ properties, + calculatedProperties, onPropertiesChange, }: ServerPropertiesPanelProps) { return ( - + ); } diff --git a/frontend/src/stores/index.test.ts b/frontend/src/stores/index.test.ts index 3a6498c..0b5b1d1 100644 --- a/frontend/src/stores/index.test.ts +++ b/frontend/src/stores/index.test.ts @@ -302,7 +302,7 @@ describe('Store Composition (index.ts)', () => { const config = result.current.exportConfig(); expect(config.request.method).toBe('DELETE'); - expect(config.request.url).toBe('https://test.com'); + expect((config.request as { url: string }).url).toBe('https://test.com'); expect(config.request.headers).toEqual({ 'X-Test': 'header' }); expect(config.properties).toEqual({ prop: 'value' }); expect(config.logLevel).toBe(4); diff --git a/frontend/src/stores/slices/configSlice.test.ts b/frontend/src/stores/slices/configSlice.test.ts index 90415c7..ffdca62 100644 --- a/frontend/src/stores/slices/configSlice.test.ts +++ b/frontend/src/stores/slices/configSlice.test.ts @@ -417,7 +417,7 @@ describe('ConfigSlice', () => { const config = result.current.exportConfig(); - expect(config.request.method).toBe('POST'); + expect(config.request.method).toBe('GET'); expect(config.properties).toEqual({}); expect(config.logLevel).toBe(2); expect(config.dotenv?.enabled).toBe(false); @@ -492,7 +492,7 @@ describe('ConfigSlice', () => { const config = result.current.exportConfig(); expect(config.request.method).toBe('DELETE'); - expect(config.request.url).toBe('https://api.example.com'); + expect((config.request as { url: string }).url).toBe('https://api.example.com'); }); it('should not affect request state when loading config', () => { diff --git a/frontend/src/stores/slices/configSlice.ts b/frontend/src/stores/slices/configSlice.ts index 776db07..a7606d7 100644 --- a/frontend/src/stores/slices/configSlice.ts +++ b/frontend/src/stores/slices/configSlice.ts @@ -1,8 +1,10 @@ import { StateCreator } from 'zustand'; -import { AppStore, ConfigSlice, ConfigState, TestConfig } from '../types'; +import { AppStore, ConfigSlice, ConfigState, TestConfig, CdnRequestConfig, HttpRequestConfig } from '../types'; +import { HTTP_WASM_HOST } from './httpWasmSlice'; const DEFAULT_CONFIG_STATE: ConfigState = { properties: {}, + calculatedProperties: {}, dotenv: { enabled: false, path: null, @@ -68,26 +70,41 @@ export const createConfigSlice: StateCreator< state.logLevel = level; }), + setCalculatedProperties: (properties) => + set((state) => { + state.calculatedProperties = properties; + }), + loadFromConfig: (config) => set((state) => { state.properties = { ...config.properties }; - state.logLevel = config.logLevel; + state.calculatedProperties = {}; + state.logLevel = config.logLevel ?? 0; state.dotenv = { enabled: config.dotenv?.enabled ?? false, path: config.dotenv?.path ?? null, }; - // Restore request fields into the correct slice based on app type + // Restore request fields into the correct slice based on app type. + // HTTP configs use `path`, CDN configs use `url`. if (config.appType === 'http-wasm') { - state.httpMethod = config.request.method; - state.httpUrl = config.request.url; - state.httpRequestHeaders = { ...config.request.headers }; - state.httpRequestBody = config.request.body; + const req = config.request as HttpRequestConfig | CdnRequestConfig; + state.httpMethod = req.method; + // Accept either `path` (new) or `url` (legacy) β€” normalise to full httpUrl + if ('path' in req) { + const p = req.path.startsWith('/') ? req.path : '/' + req.path; + state.httpUrl = HTTP_WASM_HOST.replace(/\/$/, '') + p; + } else { + state.httpUrl = req.url; + } + state.httpRequestHeaders = { ...req.headers }; + state.httpRequestBody = req.body ?? ''; } else { - state.method = config.request.method; - state.url = config.request.url; - state.requestHeaders = { ...config.request.headers }; - state.requestBody = config.request.body; + const req = config.request as CdnRequestConfig; + state.method = req.method; + state.url = req.url; + state.requestHeaders = { ...req.headers }; + state.requestBody = req.body ?? ''; if (config.response) { state.responseHeaders = { ...config.response.headers }; state.responseBody = config.response.body; @@ -99,14 +116,32 @@ export const createConfigSlice: StateCreator< const state = get(); const isHttp = state.wasmType === 'http-wasm'; + // Build the correct request shape: `path` for HTTP, `url` for CDN + let request: TestConfig['request']; + if (isHttp) { + // Strip the fixed host prefix to get the path portion + const hostPrefix = HTTP_WASM_HOST.replace(/\/$/, ''); + const httpPath = state.httpUrl.startsWith(hostPrefix) + ? state.httpUrl.slice(hostPrefix.length) || '/' + : state.httpUrl; + request = { + method: state.httpMethod, + path: httpPath, + headers: { ...state.httpRequestHeaders }, + body: state.httpRequestBody, + }; + } else { + request = { + method: state.method, + url: state.url, + headers: { ...state.requestHeaders }, + body: state.requestBody, + }; + } + const config: TestConfig = { appType: state.wasmType ?? 'proxy-wasm', - request: { - method: isHttp ? state.httpMethod : state.method, - url: isHttp ? state.httpUrl : state.url, - headers: isHttp ? { ...state.httpRequestHeaders } : { ...state.requestHeaders }, - body: isHttp ? state.httpRequestBody : state.requestBody, - }, + request, properties: { ...state.properties }, logLevel: state.logLevel, dotenv: { diff --git a/frontend/src/stores/slices/requestSlice.test.ts b/frontend/src/stores/slices/requestSlice.test.ts index 05aea9f..737f5b2 100644 --- a/frontend/src/stores/slices/requestSlice.test.ts +++ b/frontend/src/stores/slices/requestSlice.test.ts @@ -30,12 +30,12 @@ describe('RequestSlice', () => { it('should have correct default values', () => { const { result } = renderHook(() => useAppStore()); - expect(result.current.method).toBe('POST'); + expect(result.current.method).toBe('GET'); expect(result.current.url).toBe('http://fastedge-builtin.debug'); expect(result.current.requestHeaders).toEqual({}); - expect(result.current.requestBody).toBe('{"message": "Hello"}'); - expect(result.current.responseHeaders).toEqual({ 'content-type': 'application/json' }); - expect(result.current.responseBody).toBe('{"response": "OK"}'); + expect(result.current.requestBody).toBe(''); + expect(result.current.responseHeaders).toEqual({}); + expect(result.current.responseBody).toBe(''); }); }); @@ -277,12 +277,12 @@ describe('RequestSlice', () => { result.current.resetRequest(); }); - expect(result.current.method).toBe('POST'); + expect(result.current.method).toBe('GET'); expect(result.current.url).toBe('http://fastedge-builtin.debug'); expect(result.current.requestHeaders).toEqual({}); - expect(result.current.requestBody).toBe('{"message": "Hello"}'); - expect(result.current.responseHeaders).toEqual({ 'content-type': 'application/json' }); - expect(result.current.responseBody).toBe('{"response": "OK"}'); + expect(result.current.requestBody).toBe(''); + expect(result.current.responseHeaders).toEqual({}); + expect(result.current.responseBody).toBe(''); }); }); diff --git a/frontend/src/stores/slices/requestSlice.ts b/frontend/src/stores/slices/requestSlice.ts index 4926a1c..67a74fc 100644 --- a/frontend/src/stores/slices/requestSlice.ts +++ b/frontend/src/stores/slices/requestSlice.ts @@ -2,12 +2,12 @@ import { StateCreator } from 'zustand'; import { AppStore, RequestSlice, RequestState } from '../types'; const DEFAULT_REQUEST_STATE: RequestState = { - method: 'POST', + method: 'GET', url: 'http://fastedge-builtin.debug', requestHeaders: {}, - requestBody: '{"message": "Hello"}', - responseHeaders: { 'content-type': 'application/json' }, - responseBody: '{"response": "OK"}', + requestBody: '', + responseHeaders: {}, + responseBody: '', }; export const createRequestSlice: StateCreator< diff --git a/frontend/src/stores/types.ts b/frontend/src/stores/types.ts index 5108fc9..b876e08 100644 --- a/frontend/src/stores/types.ts +++ b/frontend/src/stores/types.ts @@ -86,6 +86,7 @@ export type ResultsSlice = ResultsState & ResultsActions; // Config Store export interface ConfigState { properties: Record; + calculatedProperties: Record; dotenv: { enabled: boolean; path: string | null; @@ -101,6 +102,7 @@ export interface ConfigActions { setDotenvEnabled: (enabled: boolean) => void; setDotenvPath: (path: string | null) => Promise; setLogLevel: (level: number) => void; + setCalculatedProperties: (properties: Record) => void; loadFromConfig: (config: TestConfig) => void; exportConfig: () => TestConfig; resetConfig: () => void; @@ -186,6 +188,21 @@ export type AppStore = RequestSlice & // UTILITY TYPES // ============================================================================ +// CDN (proxy-wasm) request uses a full URL; HTTP request uses a path. +export interface CdnRequestConfig { + method: string; + url: string; + headers: Record; + body: string; +} + +export interface HttpRequestConfig { + method: string; + path: string; + headers: Record; + body: string; +} + export interface TestConfig { appType?: "proxy-wasm" | "http-wasm"; description?: string; @@ -193,12 +210,7 @@ export interface TestConfig { path: string; description?: string; }; - request: { - method: string; - url: string; - headers: Record; - body: string; - }; + request: CdnRequestConfig | HttpRequestConfig; response?: { headers: Record; body: string; diff --git a/frontend/src/views/ProxyWasmView/ProxyWasmView.tsx b/frontend/src/views/ProxyWasmView/ProxyWasmView.tsx index f70d26a..a4b0449 100644 --- a/frontend/src/views/ProxyWasmView/ProxyWasmView.tsx +++ b/frontend/src/views/ProxyWasmView/ProxyWasmView.tsx @@ -30,10 +30,11 @@ export function ProxyWasmView() { // Config state properties, + calculatedProperties, dotenv, logLevel, setProperties, - mergeProperties, + setCalculatedProperties, setDotenvEnabled, setDotenvPath, setLogLevel, @@ -73,17 +74,14 @@ export function ProxyWasmView() { setHookResults(newHookResults); setFinalResponse(response); - // Merge calculated properties into the UI - console.log("[API] Received calculatedProperties:", calculatedProperties); + // Store calculated properties separately for read-only display. + // These are NOT in the editable `properties` store β€” no stale feedback loop. if (calculatedProperties) { - console.log("[API] Updating properties. Previous:", properties); - const propsToMerge: Record = {}; - // Always update calculated properties - they change with each request - for (const [key, value] of Object.entries(calculatedProperties)) { - propsToMerge[key] = String(value); + const stringProps: Record = {}; + for (const [k, v] of Object.entries(calculatedProperties)) { + stringProps[k] = String(v); } - console.log("[API] Merging properties:", propsToMerge); - mergeProperties(propsToMerge); + setCalculatedProperties(stringProps); } } catch (err) { // Show error in all hooks @@ -182,6 +180,7 @@ export function ProxyWasmView() { diff --git a/schemas/api-config.schema.json b/schemas/api-config.schema.json index 0ff50ca..9b13650 100644 --- a/schemas/api-config.schema.json +++ b/schemas/api-config.schema.json @@ -3,112 +3,211 @@ "type": "object", "properties": { "config": { - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "description": { - "type": "string" - }, - "wasm": { + "anyOf": [ + { "type": "object", "properties": { - "path": { + "$schema": { "type": "string" }, "description": { "type": "string" - } - }, - "required": [ - "path" - ], - "additionalProperties": false - }, - "request": { - "type": "object", - "properties": { - "method": { - "default": "GET", - "type": "string" }, - "url": { - "type": "string" + "wasm": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false }, - "headers": { + "properties": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, - "additionalProperties": { - "type": "string" - } + "additionalProperties": {} }, - "body": { - "default": "", - "type": "string" + "dotenv": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "appType": { + "type": "string", + "const": "http-wasm" + }, + "request": { + "type": "object", + "properties": { + "method": { + "default": "GET", + "type": "string" + }, + "path": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "body": { + "default": "", + "type": "string" + } + }, + "required": [ + "method", + "path", + "headers", + "body" + ], + "additionalProperties": false } }, "required": [ - "method", - "url", - "headers", - "body" + "properties", + "appType", + "request" ], "additionalProperties": false }, - "response": { + { "type": "object", "properties": { - "headers": { + "$schema": { + "type": "string" + }, + "description": { + "type": "string" + }, + "wasm": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "properties": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, - "additionalProperties": { - "type": "string" - } + "additionalProperties": {} }, - "body": { - "default": "", - "type": "string" + "dotenv": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "appType": { + "default": "proxy-wasm", + "type": "string", + "const": "proxy-wasm" + }, + "request": { + "type": "object", + "properties": { + "method": { + "default": "GET", + "type": "string" + }, + "url": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "body": { + "default": "", + "type": "string" + } + }, + "required": [ + "method", + "url", + "headers", + "body" + ], + "additionalProperties": false + }, + "response": { + "type": "object", + "properties": { + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "body": { + "default": "", + "type": "string" + } + }, + "required": [ + "headers", + "body" + ], + "additionalProperties": false } }, "required": [ - "headers", - "body" + "properties", + "appType", + "request" ], "additionalProperties": false - }, - "properties": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "dotenv": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "path": { - "type": "string" - } - }, - "additionalProperties": false } - }, - "required": [ - "request", - "properties" - ], - "additionalProperties": false + ] } }, "required": [ diff --git a/schemas/fastedge-config.test.schema.json b/schemas/fastedge-config.test.schema.json index a7d1ee1..53b4714 100644 --- a/schemas/fastedge-config.test.schema.json +++ b/schemas/fastedge-config.test.schema.json @@ -1,109 +1,208 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "description": { - "type": "string" - }, - "wasm": { + "anyOf": [ + { "type": "object", "properties": { - "path": { + "$schema": { "type": "string" }, "description": { "type": "string" - } - }, - "required": [ - "path" - ], - "additionalProperties": false - }, - "request": { - "type": "object", - "properties": { - "method": { - "default": "GET", - "type": "string" }, - "url": { - "type": "string" + "wasm": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false }, - "headers": { + "properties": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, - "additionalProperties": { - "type": "string" - } + "additionalProperties": {} }, - "body": { - "default": "", - "type": "string" + "dotenv": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "appType": { + "type": "string", + "const": "http-wasm" + }, + "request": { + "type": "object", + "properties": { + "method": { + "default": "GET", + "type": "string" + }, + "path": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "body": { + "default": "", + "type": "string" + } + }, + "required": [ + "method", + "path", + "headers", + "body" + ], + "additionalProperties": false } }, "required": [ - "method", - "url", - "headers", - "body" + "properties", + "appType", + "request" ], "additionalProperties": false }, - "response": { + { "type": "object", "properties": { - "headers": { + "$schema": { + "type": "string" + }, + "description": { + "type": "string" + }, + "wasm": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "properties": { "default": {}, "type": "object", "propertyNames": { "type": "string" }, - "additionalProperties": { - "type": "string" - } + "additionalProperties": {} }, - "body": { - "default": "", - "type": "string" + "dotenv": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "additionalProperties": false + }, + "appType": { + "default": "proxy-wasm", + "type": "string", + "const": "proxy-wasm" + }, + "request": { + "type": "object", + "properties": { + "method": { + "default": "GET", + "type": "string" + }, + "url": { + "type": "string" + }, + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "body": { + "default": "", + "type": "string" + } + }, + "required": [ + "method", + "url", + "headers", + "body" + ], + "additionalProperties": false + }, + "response": { + "type": "object", + "properties": { + "headers": { + "default": {}, + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "body": { + "default": "", + "type": "string" + } + }, + "required": [ + "headers", + "body" + ], + "additionalProperties": false } }, "required": [ - "headers", - "body" + "properties", + "appType", + "request" ], "additionalProperties": false - }, - "properties": { - "default": {}, - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "dotenv": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "path": { - "type": "string" - } - }, - "additionalProperties": false } - }, - "required": [ - "request", - "properties" - ], - "additionalProperties": false + ] } diff --git a/server/__tests__/integration/cdn-apps/headers/multi-value-headers.test.ts b/server/__tests__/integration/cdn-apps/headers/multi-value-headers.test.ts new file mode 100644 index 0000000..bc43015 --- /dev/null +++ b/server/__tests__/integration/cdn-apps/headers/multi-value-headers.test.ts @@ -0,0 +1,220 @@ +/** + * CDN WASM Runner - Multi-Value Headers Tests + * + * Tests that the proxy-wasm host correctly handles multi-valued headers: + * - add_header with same key creates separate entries + * - replace_header removes all entries for a key and sets one + * - remove_header sets entries to empty string (nginx behavior) + * - get_headers returns separate entries (not comma-joined) + * + * Uses the FastEdge-sdk-rust cdn/headers example which performs extensive + * header validation including add/set/remove with both string and bytes variants. + * + * App behavior (onRequestHeaders): + * - Validates initial headers exist (returns 550 if empty) + * - Validates host header present (returns 551 if missing β€” but on nginx, + * get_header for a missing key returns Some(""), so 551 only triggers + * when the header truly doesn't exist at the proxy level) + * - Adds new-header-01..03 and new-header-bytes-01..03 + * - Removes *-01 headers via set(None) β†’ expects empty string entries + * - Replaces *-02 headers with new values + * - Adds second value to *-03 headers (multi-value) + * - Validates diff against expected set (returns 552 on mismatch) + * - Validates response header access from request phase (returns 553-556 on failure) + * - Returns Action::Continue on success + * + * App behavior (onResponseHeaders): + * - Same pattern as onRequestHeaders but for response headers + * - Requires initial response headers to contain "host" for validation + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { readFile } from "fs/promises"; +import { ProxyWasmRunner } from "../../../../runner/ProxyWasmRunner"; +import { + createTestRunner, + createHookCall, +} from "../../utils/test-helpers"; +import { + CDN_APP_VARIANTS, + resolveCdnWasmPath, + cdnWasmExists, +} from "../shared/variants"; + +const WASM_FILE = "headers.wasm"; +const CATEGORY = "headers"; + +for (const variant of CDN_APP_VARIANTS) { + const wasmPath = resolveCdnWasmPath(variant, CATEGORY, WASM_FILE); + const describeFn = cdnWasmExists(variant, CATEGORY, WASM_FILE) + ? describe + : describe.skip; + + describeFn(`CDN WASM - Multi-Value Headers [${variant.name}]`, () => { + let runner: ProxyWasmRunner; + // Rust SDK has _bytes header variants; AS SDK does not + const hasBytesVariants = variant.name === "rust"; + + beforeAll(async () => { + const wasmBinary = new Uint8Array(await readFile(wasmPath)); + runner = createTestRunner(); + await runner.load(Buffer.from(wasmBinary)); + }, 30000); + + afterAll(async () => { + await runner.cleanup(); + }); + + it("should load headers WASM binary successfully", () => { + expect(runner.getType()).toBe("proxy-wasm"); + }); + + describe("onRequestHeaders", () => { + it("should return Continue (0) β€” all header validations pass", async () => { + const result = await runner.callHook( + createHookCall("onRequestHeaders", { + host: "example.com", + }), + ); + // returnCode 0 = Action::Continue, 1 = Action::Pause (error) + expect(result.returnCode).toBe(0); + }); + + it("should add multi-valued headers as separate entries visible in output", async () => { + const result = await runner.callHook( + createHookCall("onRequestHeaders", { + host: "example.com", + }), + ); + expect(result.returnCode).toBe(0); + + const outputHeaders = result.output.request.headers; + // Multi-valued headers appear comma-joined in the Record output + expect(outputHeaders["new-header-03"]).toContain("value-03"); + expect(outputHeaders["new-header-03"]).toContain("value-03-a"); + if (hasBytesVariants) { + expect(outputHeaders["new-header-bytes-03"]).toContain("value-bytes-03"); + expect(outputHeaders["new-header-bytes-03"]).toContain("value-bytes-03-a"); + } + }); + + it("should replace headers correctly (set with new value)", async () => { + const result = await runner.callHook( + createHookCall("onRequestHeaders", { + host: "example.com", + }), + ); + expect(result.returnCode).toBe(0); + + const outputHeaders = result.output.request.headers; + expect(outputHeaders["new-header-02"]).toBe("new-value-02"); + if (hasBytesVariants) { + expect(outputHeaders["new-header-bytes-02"]).toBe("new-value-bytes-02"); + } + }); + + it("should handle remove β€” headers set to empty string (nginx behavior)", async () => { + const result = await runner.callHook( + createHookCall("onRequestHeaders", { + host: "example.com", + }), + ); + expect(result.returnCode).toBe(0); + + const outputHeaders = result.output.request.headers; + // set(None) / remove() calls proxy_remove_header_map_value which sets to empty (nginx behavior) + expect(outputHeaders["new-header-01"]).toBe(""); + if (hasBytesVariants) { + expect(outputHeaders["new-header-bytes-01"]).toBe(""); + } + }); + + it("should return 550 (Pause) if no headers provided", async () => { + const result = await runner.callHook( + createHookCall("onRequestHeaders", {}), + ); + expect(result.returnCode).toBe(1); // Action::Pause + }); + + it("should validate response header access from request phase", async () => { + // The WASM app modifies response headers during onRequestHeaders + // (add, replace, remove) and then validates them. This tests that + // response headers are accessible and modifiable from the request phase. + const result = await runner.callHook( + createHookCall("onRequestHeaders", { + host: "example.com", + }), + ); + expect(result.returnCode).toBe(0); + + const outputResponseHeaders = result.output.response.headers; + // The app adds "new-response-header" then replaces its value + expect(outputResponseHeaders["new-response-header"]).toBe("value-02"); + }); + }); + + describe("onResponseHeaders", () => { + it("should return Continue (0) when response has host header", async () => { + const result = await runner.callHook({ + hook: "onResponseHeaders", + request: { + headers: {}, + body: "", + }, + response: { + headers: { host: "" }, + body: "", + }, + properties: {}, + }); + expect(result.returnCode).toBe(0); + }); + + it("should add and validate multi-valued response headers", async () => { + const result = await runner.callHook({ + hook: "onResponseHeaders", + request: { + headers: {}, + body: "", + }, + response: { + headers: { host: "" }, + body: "", + }, + properties: {}, + }); + expect(result.returnCode).toBe(0); + + const outputHeaders = result.output.response.headers; + expect(outputHeaders["new-header-02"]).toBe("new-value-02"); + if (hasBytesVariants) { + expect(outputHeaders["new-header-bytes-02"]).toBe("new-value-bytes-02"); + } + expect(outputHeaders["new-header-03"]).toContain("value-03"); + expect(outputHeaders["new-header-03"]).toContain("value-03-a"); + }); + + it("should handle remove in response headers β€” set to empty (nginx behavior)", async () => { + const result = await runner.callHook({ + hook: "onResponseHeaders", + request: { + headers: {}, + body: "", + }, + response: { + headers: { host: "" }, + body: "", + }, + properties: {}, + }); + expect(result.returnCode).toBe(0); + + const outputHeaders = result.output.response.headers; + expect(outputHeaders["new-header-01"]).toBe(""); + if (hasBytesVariants) { + expect(outputHeaders["new-header-bytes-01"]).toBe(""); + } + }); + }); + }); +} diff --git a/server/__tests__/unit/runner/HeaderManager.test.ts b/server/__tests__/unit/runner/HeaderManager.test.ts index 38be359..b831520 100644 --- a/server/__tests__/unit/runner/HeaderManager.test.ts +++ b/server/__tests__/unit/runner/HeaderManager.test.ts @@ -695,4 +695,125 @@ describe("HeaderManager", () => { expect(result.length).toBeGreaterThanOrEqual(expectedMinSize); }); }); + + describe("recordToTuples()", () => { + it("should convert a Record to tuples with lowercase keys", () => { + const record: HeaderMap = { "Content-Type": "text/html", "X-Foo": "bar" }; + const result = HeaderManager.recordToTuples(record); + expect(result).toEqual([ + ["content-type", "text/html"], + ["x-foo", "bar"], + ]); + }); + + it("should handle empty record", () => { + expect(HeaderManager.recordToTuples({})).toEqual([]); + }); + }); + + describe("tuplesToRecord()", () => { + it("should convert tuples to Record", () => { + const tuples: [string, string][] = [ + ["content-type", "text/html"], + ["x-foo", "bar"], + ]; + expect(HeaderManager.tuplesToRecord(tuples)).toEqual({ + "content-type": "text/html", + "x-foo": "bar", + }); + }); + + it("should comma-join multi-valued headers", () => { + const tuples: [string, string][] = [ + ["x-multi", "val1"], + ["x-other", "solo"], + ["x-multi", "val2"], + ["x-multi", "val3"], + ]; + expect(HeaderManager.tuplesToRecord(tuples)).toEqual({ + "x-multi": "val1,val2,val3", + "x-other": "solo", + }); + }); + + it("should handle empty tuples", () => { + expect(HeaderManager.tuplesToRecord([])).toEqual({}); + }); + }); + + describe("normalizeTuples()", () => { + it("should lowercase all keys", () => { + const tuples: [string, string][] = [ + ["Content-Type", "text/html"], + ["X-FOO", "bar"], + ]; + expect(HeaderManager.normalizeTuples(tuples)).toEqual([ + ["content-type", "text/html"], + ["x-foo", "bar"], + ]); + }); + }); + + describe("serializeTuples()", () => { + it("should produce same binary as serialize() for unique keys", () => { + const record: HeaderMap = { host: "example.com", "content-type": "text/html" }; + const tuples: [string, string][] = Object.entries(record); + const fromRecord = HeaderManager.serialize(record); + const fromTuples = HeaderManager.serializeTuples(tuples); + expect(fromTuples).toEqual(fromRecord); + }); + + it("should serialize duplicate keys as separate entries", () => { + const tuples: [string, string][] = [ + ["x-multi", "val1"], + ["x-multi", "val2"], + ]; + const bytes = HeaderManager.serializeTuples(tuples); + const view = new DataView(bytes.buffer); + // Should have 2 pairs + expect(view.getUint32(0, true)).toBe(2); + + // Deserialize back to tuples β€” should get 2 separate entries + const result = HeaderManager.deserializeBinaryToTuples(bytes); + expect(result).toEqual([ + ["x-multi", "val1"], + ["x-multi", "val2"], + ]); + }); + + it("should handle empty tuples", () => { + const bytes = HeaderManager.serializeTuples([]); + const view = new DataView(bytes.buffer); + expect(view.getUint32(0, true)).toBe(0); + expect(bytes.length).toBe(4); + }); + }); + + describe("deserializeBinaryToTuples()", () => { + it("should preserve duplicate keys", () => { + const tuples: [string, string][] = [ + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ["content-type", "text/html"], + ]; + const bytes = HeaderManager.serializeTuples(tuples); + const result = HeaderManager.deserializeBinaryToTuples(bytes); + expect(result).toEqual(tuples); + }); + + it("should handle empty bytes", () => { + expect(HeaderManager.deserializeBinaryToTuples(new Uint8Array(0))).toEqual([]); + }); + }); + + describe("deserializeToTuples()", () => { + it("should preserve duplicate keys from null-separated string", () => { + const payload = "x-multi\0val1\0x-multi\0val2\0"; + const result = HeaderManager.deserializeToTuples(payload); + expect(result).toEqual([ + ["x-multi", "val1"], + ["x-multi", "val2"], + ]); + }); + }); }); diff --git a/server/__tests__/unit/schemas/config.test.ts b/server/__tests__/unit/schemas/config.test.ts index fec7efd..8a57755 100644 --- a/server/__tests__/unit/schemas/config.test.ts +++ b/server/__tests__/unit/schemas/config.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { TestConfigSchema, RequestConfigSchema, + HttpRequestConfigSchema, WasmConfigSchema, ResponseConfigSchema, } from '../../../schemas/config'; @@ -149,4 +150,113 @@ describe('TestConfigSchema', () => { expect(result.success).toBe(true); }); }); + + describe('HTTP WASM configs (appType: http-wasm)', () => { + it('should accept a minimal HTTP config with path', () => { + const result = TestConfigSchema.safeParse({ + appType: 'http-wasm', + request: { path: '/api/hello' }, + }); + expect(result.success).toBe(true); + }); + + it('should accept path with query parameters', () => { + const result = TestConfigSchema.safeParse({ + appType: 'http-wasm', + request: { path: '/api/hello/world?name=FastEdge&lang=Rust' }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect((result.data.request as { path: string }).path).toBe('/api/hello/world?name=FastEdge&lang=Rust'); + } + }); + + it('should default method to GET for HTTP config', () => { + const result = TestConfigSchema.safeParse({ + appType: 'http-wasm', + request: { path: '/' }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.request.method).toBe('GET'); + }); + + it('should accept HTTP config with all fields', () => { + const result = TestConfigSchema.safeParse({ + appType: 'http-wasm', + description: 'Hello world test', + request: { + method: 'POST', + path: '/api/data', + headers: { 'Content-Type': 'application/json' }, + body: '{"key":"value"}', + }, + dotenv: { enabled: true, path: '/fixtures' }, + }); + expect(result.success).toBe(true); + }); + + it('should reject HTTP config without path', () => { + const result = TestConfigSchema.safeParse({ + appType: 'http-wasm', + request: { method: 'GET' }, + }); + expect(result.success).toBe(false); + }); + + it('should reject HTTP config with url instead of path', () => { + const result = TestConfigSchema.safeParse({ + appType: 'http-wasm', + request: { url: 'https://example.com' }, + }); + expect(result.success).toBe(false); + }); + }); + + describe('CDN backward compatibility (no appType)', () => { + it('should accept config without appType as CDN', () => { + const result = TestConfigSchema.safeParse({ + request: { url: 'https://example.com' }, + }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.appType).toBe('proxy-wasm'); + }); + + it('should accept explicit proxy-wasm appType', () => { + const result = TestConfigSchema.safeParse({ + appType: 'proxy-wasm', + request: { url: 'https://example.com' }, + }); + expect(result.success).toBe(true); + }); + }); +}); + +describe('HttpRequestConfigSchema', () => { + it('should require path', () => { + expect(HttpRequestConfigSchema.safeParse({}).success).toBe(false); + }); + + it('should default method to GET', () => { + const result = HttpRequestConfigSchema.safeParse({ path: '/' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.method).toBe('GET'); + }); + + it('should default headers to {}', () => { + const result = HttpRequestConfigSchema.safeParse({ path: '/' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.headers).toEqual({}); + }); + + it('should default body to empty string', () => { + const result = HttpRequestConfigSchema.safeParse({ path: '/' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.body).toBe(''); + }); + + it('should accept path with query string', () => { + const result = HttpRequestConfigSchema.safeParse({ path: '/api/users?id=89' }); + expect(result.success).toBe(true); + if (result.success) expect(result.data.path).toBe('/api/users?id=89'); + }); }); diff --git a/server/__tests__/unit/test-framework/suite-runner.test.ts b/server/__tests__/unit/test-framework/suite-runner.test.ts index 4485f6c..3fd84cc 100644 --- a/server/__tests__/unit/test-framework/suite-runner.test.ts +++ b/server/__tests__/unit/test-framework/suite-runner.test.ts @@ -189,6 +189,39 @@ describe('loadConfigFile', () => { mockReadFile.mockResolvedValue(JSON.stringify({ wasm: { path: './app.wasm' } })); await expect(loadConfigFile('./fastedge-config.test.json')).rejects.toThrow('Invalid test config'); }); + + it('resolves relative dotenv.path against the config file directory', async () => { + mockReadFile.mockResolvedValue( + JSON.stringify({ + request: { url: 'https://example.com' }, + dotenv: { enabled: true, path: './fixtures' }, + }) + ); + const config = await loadConfigFile('/home/user/project/fastedge-config.test.json'); + expect(config.dotenv?.path).toBe('/home/user/project/fixtures'); + }); + + it('leaves absolute dotenv.path unchanged', async () => { + mockReadFile.mockResolvedValue( + JSON.stringify({ + request: { url: 'https://example.com' }, + dotenv: { enabled: true, path: '/abs/path/to/fixtures' }, + }) + ); + const config = await loadConfigFile('/home/user/project/fastedge-config.test.json'); + expect(config.dotenv?.path).toBe('/abs/path/to/fixtures'); + }); + + it('resolves parent-relative dotenv.path correctly', async () => { + mockReadFile.mockResolvedValue( + JSON.stringify({ + request: { url: 'https://example.com' }, + dotenv: { enabled: true, path: '../shared/fixtures' }, + }) + ); + const config = await loadConfigFile('/home/user/project/config/fastedge-config.test.json'); + expect(config.dotenv?.path).toBe('/home/user/project/shared/fixtures'); + }); }); // ─── runAndExit ─────────────────────────────────────────────────────────────── diff --git a/server/__tests__/unit/utils/dotenv-loader.test.ts b/server/__tests__/unit/utils/dotenv-loader.test.ts index 3805c1a..b9e6ba6 100644 --- a/server/__tests__/unit/utils/dotenv-loader.test.ts +++ b/server/__tests__/unit/utils/dotenv-loader.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { loadDotenvFiles, hasDotenvFiles } from "../../../utils/dotenv-loader.js"; +import { loadDotenvFiles, hasDotenvFiles, resolveDotenvPath } from "../../../utils/dotenv-loader.js"; import fs from "fs/promises"; import path from "path"; @@ -843,4 +843,30 @@ DB_PASSWORD=prod-db-pass`; expect(result.dictionary).toEqual({}); }); }); + + describe("resolveDotenvPath", () => { + it("returns undefined for undefined input", () => { + expect(resolveDotenvPath(undefined, "/base")).toBeUndefined(); + }); + + it("returns undefined for empty string input", () => { + expect(resolveDotenvPath("", "/base")).toBeUndefined(); + }); + + it("returns absolute paths unchanged", () => { + expect(resolveDotenvPath("/abs/path/to/fixtures", "/base")).toBe("/abs/path/to/fixtures"); + }); + + it("resolves relative path against base directory", () => { + expect(resolveDotenvPath("./fixtures", "/home/user/project")).toBe("/home/user/project/fixtures"); + }); + + it("resolves bare relative path against base directory", () => { + expect(resolveDotenvPath("fixtures", "/home/user/project")).toBe("/home/user/project/fixtures"); + }); + + it("resolves parent-relative path against base directory", () => { + expect(resolveDotenvPath("../shared/fixtures", "/home/user/project/config")).toBe("/home/user/project/shared/fixtures"); + }); + }); }); diff --git a/server/runner/HeaderManager.ts b/server/runner/HeaderManager.ts index 58d86f7..bb87b2d 100644 --- a/server/runner/HeaderManager.ts +++ b/server/runner/HeaderManager.ts @@ -1,4 +1,4 @@ -import type { HeaderMap } from "./types"; +import type { HeaderMap, HeaderTuples } from "./types"; const textEncoder = new TextEncoder(); @@ -101,4 +101,101 @@ export class HeaderManager { } return headers; } + + // --- Tuple-based methods for multi-valued header support --- + + static recordToTuples(headers: HeaderMap): HeaderTuples { + return Object.entries(headers).map(([k, v]) => [k.toLowerCase(), String(v)]); + } + + static tuplesToRecord(tuples: HeaderTuples): HeaderMap { + const record: HeaderMap = {}; + for (const [key, value] of tuples) { + const existing = record[key]; + record[key] = existing !== undefined ? `${existing},${value}` : value; + } + return record; + } + + static normalizeTuples(tuples: HeaderTuples): HeaderTuples { + return tuples.map(([k, v]) => [k.toLowerCase(), String(v)]); + } + + static serializeTuples(tuples: HeaderTuples): Uint8Array { + const numPairs = tuples.length; + + const encoded: Array<{ key: Uint8Array; value: Uint8Array }> = []; + let dataSize = 0; + for (const [key, value] of tuples) { + const keyBytes = textEncoder.encode(key); + const valBytes = textEncoder.encode(value); + encoded.push({ key: keyBytes, value: valBytes }); + dataSize += keyBytes.length + 1 + valBytes.length + 1; + } + + const totalSize = 4 + numPairs * 2 * 4 + dataSize; + const buffer = new ArrayBuffer(totalSize); + const view = new DataView(buffer); + const bytes = new Uint8Array(buffer); + + view.setUint32(0, numPairs, true); + + let offset = 4; + for (const { key, value } of encoded) { + view.setUint32(offset, key.length, true); + offset += 4; + view.setUint32(offset, value.length, true); + offset += 4; + } + + for (const { key, value } of encoded) { + bytes.set(key, offset); + offset += key.length; + bytes[offset] = 0; + offset += 1; + + bytes.set(value, offset); + offset += value.length; + bytes[offset] = 0; + offset += 1; + } + + return bytes; + } + + static deserializeBinaryToTuples(bytes: Uint8Array): HeaderTuples { + if (bytes.length < 4) return []; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const numPairs = view.getUint32(0, true); + const lengths: Array<{ keyLen: number; valLen: number }> = []; + let offset = 4; + for (let i = 0; i < numPairs; i++) { + const keyLen = view.getUint32(offset, true); + offset += 4; + const valLen = view.getUint32(offset, true); + offset += 4; + lengths.push({ keyLen, valLen }); + } + const decoder = new TextDecoder(); + const tuples: HeaderTuples = []; + for (const { keyLen, valLen } of lengths) { + const key = decoder.decode(bytes.slice(offset, offset + keyLen)); + offset += keyLen + 1; + const value = decoder.decode(bytes.slice(offset, offset + valLen)); + offset += valLen + 1; + tuples.push([key.toLowerCase(), value]); + } + return tuples; + } + + static deserializeToTuples(payload: string): HeaderTuples { + const parts = payload.split("\0").filter((part) => part.length > 0); + const tuples: HeaderTuples = []; + for (let i = 0; i < parts.length; i += 2) { + const key = parts[i]; + const value = parts[i + 1] ?? ""; + tuples.push([key.toLowerCase(), value]); + } + return tuples; + } } diff --git a/server/runner/HostFunctions.ts b/server/runner/HostFunctions.ts index 967d184..57c23ea 100644 --- a/server/runner/HostFunctions.ts +++ b/server/runner/HostFunctions.ts @@ -1,4 +1,4 @@ -import type { HeaderMap, LogEntry } from "./types"; +import type { HeaderMap, HeaderTuples, LogEntry } from "./types"; import { ProxyStatus, BufferType, MapType } from "./types"; import { MemoryManager } from "./MemoryManager"; import { HeaderManager } from "./HeaderManager"; @@ -28,8 +28,8 @@ export class HostFunctions { private propertyAccessControl: PropertyAccessControl; private getCurrentHook: () => HookContext | null; private logs: LogEntry[] = []; - private requestHeaders: HeaderMap = {}; - private responseHeaders: HeaderMap = {}; + private requestHeaders: HeaderTuples = []; + private responseHeaders: HeaderTuples = []; private requestBody = ""; private responseBody = ""; private vmConfig = ""; @@ -94,8 +94,8 @@ export class HostFunctions { reqBody: string, resBody: string, ): void { - this.requestHeaders = reqHeaders; - this.responseHeaders = resHeaders; + this.requestHeaders = HeaderManager.recordToTuples(reqHeaders); + this.responseHeaders = HeaderManager.recordToTuples(resHeaders); this.requestBody = reqBody; this.responseBody = resBody; } @@ -162,11 +162,11 @@ export class HostFunctions { } getRequestHeaders(): HeaderMap { - return this.requestHeaders; + return HeaderManager.tuplesToRecord(this.requestHeaders); } getResponseHeaders(): HeaderMap { - return this.responseHeaders; + return HeaderManager.tuplesToRecord(this.responseHeaders); } getRequestBody(): string { @@ -327,14 +327,17 @@ export class HostFunctions { `proxy_get_header_map_value map=${mapType} keyLen=${keyLen}`, ); const key = this.memory.readString(keyPtr, keyLen).toLowerCase(); - const map = this.getHeaderMap(mapType); - const value = map[key]; - if (value === undefined) { + const tuples = this.getInternalHeaders(mapType); + const found = tuples.find(([k]) => k === key); + if (found === undefined) { this.logDebug(`get_header_map_value miss: map=${mapType} key=${key}`); + // nginx behavior: missing headers return Ok with empty string. + // The writeStringResult writes a non-null pointer, so the Rust SDK + // interprets this as Some("") rather than None. this.memory.writeStringResult("", valuePtrPtr, valueLenPtr); return ProxyStatus.Ok; } - this.memory.writeStringResult(value, valuePtrPtr, valueLenPtr); + this.memory.writeStringResult(found[1], valuePtrPtr, valueLenPtr); return ProxyStatus.Ok; }, @@ -344,8 +347,8 @@ export class HostFunctions { valueLenPtr: number, ) => { this.setLastHostCall(`proxy_get_header_map_pairs map=${mapType}`); - const map = this.getHeaderMap(mapType); - const bytes = HeaderManager.serialize(map); + const tuples = this.getInternalHeaders(mapType); + const bytes = HeaderManager.serializeTuples(tuples); const ptr = this.memory.writeToWasm(bytes); if (valuePtrPtr) { this.memory.writeU32(valuePtrPtr, ptr); @@ -359,16 +362,15 @@ export class HostFunctions { .map((b) => b.toString(16).padStart(2, "0")) .join(" "); this.logDebug( - `header_map_pairs ptr=${ptr} len=${bytes.length} count=${Object.keys(map).length} hex=${hexDump}`, + `header_map_pairs ptr=${ptr} len=${bytes.length} count=${tuples.length} hex=${hexDump}`, ); return ProxyStatus.Ok; }, proxy_get_header_map_size: (mapType: number, sizePtr: number) => { this.setLastHostCall(`proxy_get_header_map_size map=${mapType}`); - const map = this.getHeaderMap(mapType); - // Return number of header pairs - const size = Object.keys(map).length; + const tuples = this.getInternalHeaders(mapType); + const size = tuples.length; this.memory.writeU32(sizePtr, size); this.logDebug( `header_map_size map=${mapType} size=${size} (pair count)`, @@ -388,10 +390,9 @@ export class HostFunctions { ); const key = this.memory.readString(keyPtr, keyLen).toLowerCase(); const value = this.memory.readString(valuePtr, valueLen); - const map = this.getHeaderMap(mapType); - const existing = map[key]; - map[key] = existing ? `${existing},${value}` : value; - this.setHeaderMap(mapType, map); + const tuples = this.getInternalHeaders(mapType); + tuples.push([key, value]); + this.setInternalHeaders(mapType, tuples); return ProxyStatus.Ok; }, @@ -407,9 +408,10 @@ export class HostFunctions { ); const key = this.memory.readString(keyPtr, keyLen).toLowerCase(); const value = this.memory.readString(valuePtr, valueLen); - const map = this.getHeaderMap(mapType); - map[key] = value; - this.setHeaderMap(mapType, map); + const tuples = this.getInternalHeaders(mapType); + const filtered = tuples.filter(([k]) => k !== key); + filtered.push([key, value]); + this.setInternalHeaders(mapType, filtered); return ProxyStatus.Ok; }, @@ -422,9 +424,16 @@ export class HostFunctions { `proxy_remove_header_map_value map=${mapType} keyLen=${keyLen}`, ); const key = this.memory.readString(keyPtr, keyLen).toLowerCase(); - const map = this.getHeaderMap(mapType); - delete map[key]; - this.setHeaderMap(mapType, map); + const tuples = this.getInternalHeaders(mapType); + const exists = tuples.some(([k]) => k === key); + if (exists) { + // Match nginx behavior: set to empty string instead of deleting. + // nginx does not allow truly removing headers β€” remove() sets value to "". + const filtered = tuples.filter(([k]) => k !== key); + filtered.push([key, ""]); + this.setInternalHeaders(mapType, filtered); + } + // If header doesn't exist, remove is a no-op return ProxyStatus.Ok; }, @@ -437,8 +446,8 @@ export class HostFunctions { `proxy_set_header_map_pairs map=${mapType} valueLen=${valueLen}`, ); const raw = this.memory.readString(valuePtr, valueLen); - const map = HeaderManager.deserialize(raw); - this.setHeaderMap(mapType, map); + const tuples = HeaderManager.deserializeToTuples(raw); + this.setInternalHeaders(mapType, tuples); return ProxyStatus.Ok; }, @@ -772,7 +781,7 @@ export class HostFunctions { }; } - private getHeaderMap(mapType: number): HeaderMap { + private getInternalHeaders(mapType: number): HeaderTuples { if ( mapType === MapType.ResponseHeaders || mapType === MapType.ResponseTrailers @@ -783,19 +792,20 @@ export class HostFunctions { mapType === MapType.HttpCallResponseHeaders || mapType === MapType.HttpCallResponseTrailers ) { - return this.httpCallResponse?.headers ?? {}; + return HeaderManager.recordToTuples(this.httpCallResponse?.headers ?? {}); } return this.requestHeaders; } - private setHeaderMap(mapType: number, map: HeaderMap): void { + private setInternalHeaders(mapType: number, tuples: HeaderTuples): void { + const normalized = HeaderManager.normalizeTuples(tuples); if ( mapType === MapType.ResponseHeaders || mapType === MapType.ResponseTrailers ) { - this.responseHeaders = HeaderManager.normalize(map); + this.responseHeaders = normalized; } else { - this.requestHeaders = HeaderManager.normalize(map); + this.requestHeaders = normalized; } } diff --git a/server/runner/PropertyAccessControl.ts b/server/runner/PropertyAccessControl.ts index 4e2014d..416614e 100644 --- a/server/runner/PropertyAccessControl.ts +++ b/server/runner/PropertyAccessControl.ts @@ -132,6 +132,19 @@ export const BUILT_IN_PROPERTIES: PropertyDefinition[] = [ description: 'Request path extension', }, + // Client IP (read-only) + { + path: 'request.x_real_ip', + type: 'string', + access: { + [HookContext.OnRequestHeaders]: PropertyAccess.ReadOnly, + [HookContext.OnRequestBody]: PropertyAccess.ReadOnly, + [HookContext.OnResponseHeaders]: PropertyAccess.ReadOnly, + [HookContext.OnResponseBody]: PropertyAccess.ReadOnly, + }, + description: 'Client IP address (X-Real-IP)', + }, + // Geolocation properties (read-only) { path: 'request.country', diff --git a/server/runner/ProxyWasmRunner.ts b/server/runner/ProxyWasmRunner.ts index 49b30f0..77358e3 100644 --- a/server/runner/ProxyWasmRunner.ts +++ b/server/runner/ProxyWasmRunner.ts @@ -228,7 +228,11 @@ export class ProxyWasmRunner implements IWasmRunner { // Normalise the "built-in" shorthand to a real URL so every downstream // consumer (URL parsing, pseudo-headers, property extraction) just works. - const isBuiltIn = targetUrl === BUILTIN_SHORTHAND || targetUrl === BUILTIN_URL; + // Allow query params on built-in URL (e.g. http://fastedge-builtin.debug?key=value) + const isBuiltIn = targetUrl === BUILTIN_SHORTHAND + || targetUrl === BUILTIN_URL + || targetUrl.startsWith(BUILTIN_URL + '?') + || targetUrl.startsWith(BUILTIN_URL + '/'); if (targetUrl === BUILTIN_SHORTHAND) { targetUrl = BUILTIN_URL; this.logDebug(`Substituted "${BUILTIN_SHORTHAND}" β†’ ${BUILTIN_URL}`); @@ -857,7 +861,7 @@ export class ProxyWasmRunner implements IWasmRunner { const resp = await fetch(url, { method, headers: fetchHeaders, - body: pending.body ? Buffer.from(pending.body) : undefined, + body: pending.body && method !== 'GET' && method !== 'HEAD' ? Buffer.from(pending.body) : undefined, signal: AbortSignal.timeout(pending.timeoutMs), }); resp.headers.forEach((v, k) => { responseHeaders[k] = v; }); @@ -867,7 +871,10 @@ export class ProxyWasmRunner implements IWasmRunner { ); } catch (err) { // Timeout or network error β†’ numHeaders = 0 (proxy-wasm contract for failed calls) - this.logDebug(`http_call failed for ${url}: ${String(err)}`); + const errMsg = `http_call failed for ${url}: ${String(err)}`; + this.logDebug(errMsg); + // Always surface fetch failures in the WASM-visible logs so developers can diagnose + this.logs.push({ level: 3, message: `[host] ${errMsg}` }); responseHeaders = {}; responseBody = new Uint8Array(0); } diff --git a/server/runner/types.ts b/server/runner/types.ts index d1ae487..b9096ba 100644 --- a/server/runner/types.ts +++ b/server/runner/types.ts @@ -1,4 +1,5 @@ export type HeaderMap = Record; +export type HeaderTuples = [string, string][]; export type HookCall = { hook: string; diff --git a/server/schemas/config.ts b/server/schemas/config.ts index 9125e96..d04fb78 100644 --- a/server/schemas/config.ts +++ b/server/schemas/config.ts @@ -5,32 +5,64 @@ export const WasmConfigSchema = z.object({ description: z.string().optional(), }); -export const RequestConfigSchema = z.object({ +// CDN (proxy-wasm) request: full URL required (upstream target) +export const CdnRequestConfigSchema = z.object({ method: z.string().default('GET'), url: z.string(), headers: z.record(z.string(), z.string()).optional().default({}), body: z.string().optional().default(''), }); +// HTTP request: path-only (the app IS the origin server) +export const HttpRequestConfigSchema = z.object({ + method: z.string().default('GET'), + path: z.string(), + headers: z.record(z.string(), z.string()).optional().default({}), + body: z.string().optional().default(''), +}); + export const ResponseConfigSchema = z.object({ headers: z.record(z.string(), z.string()).optional().default({}), body: z.string().optional().default(''), }); -export const TestConfigSchema = z.object({ +const DotenvSchema = z.object({ + enabled: z.boolean().optional(), + path: z.string().optional(), +}); + +const BaseConfigSchema = z.object({ $schema: z.string().optional(), description: z.string().optional(), wasm: WasmConfigSchema.optional(), - request: RequestConfigSchema, - response: ResponseConfigSchema.optional(), properties: z.record(z.string(), z.unknown()).optional().default({}), - dotenv: z.object({ - enabled: z.boolean().optional(), - path: z.string().optional(), - }).optional(), + dotenv: DotenvSchema.optional(), +}); + +// CDN config: full URL + optional mock response +const CdnConfigSchema = BaseConfigSchema.extend({ + appType: z.literal('proxy-wasm').default('proxy-wasm'), + request: CdnRequestConfigSchema, + response: ResponseConfigSchema.optional(), }); +// HTTP config: path only, no mock response +const HttpConfigSchema = BaseConfigSchema.extend({ + appType: z.literal('http-wasm'), + request: HttpRequestConfigSchema, +}); + +// Discriminated union β€” appType determines which schema validates +export const TestConfigSchema = z.union([HttpConfigSchema, CdnConfigSchema]); + +// Backward-compat alias: the old flat RequestConfigSchema is the CDN variant +export const RequestConfigSchema = CdnRequestConfigSchema; + export type WasmConfig = z.infer; -export type RequestConfig = z.infer; +export type CdnRequestConfig = z.infer; +export type HttpRequestConfig = z.infer; +export type RequestConfig = z.infer; export type ResponseConfig = z.infer; +export type CdnConfig = z.infer; +export type HttpConfig = z.infer; export type TestConfig = z.infer; diff --git a/server/server.ts b/server/server.ts index 9575357..691c4e7 100644 --- a/server/server.ts +++ b/server/server.ts @@ -14,6 +14,7 @@ import { HttpWasmRunner } from "./runner/HttpWasmRunner.js"; import { WebSocketManager, StateManager } from "./websocket/index.js"; import { detectWasmType } from "./utils/wasmTypeDetector.js"; import { validatePath } from "./utils/pathValidator.js"; +import { resolveDotenvPath } from "./utils/dotenv-loader.js"; import { ApiLoadBodySchema, ApiSendBodySchema, @@ -233,7 +234,7 @@ app.post("/api/load", async (req: Request, res: Response) => { // Precedence: client-provided path β†’ WORKSPACE_PATH (VSCode) β†’ undefined (CWD). // When running inside VSCode the server CWD is the extension's dist/debugger/ // directory, so WORKSPACE_PATH is the fallback. A client-provided path wins. - const dotenvPath = dotenv?.path || process.env.WORKSPACE_PATH || undefined; + const dotenvPath = resolveDotenvPathFromWorkspace(dotenv?.path) || process.env.WORKSPACE_PATH || undefined; // Load WASM (accepts either Buffer or string path) await currentRunner.load(bufferOrPath, { @@ -287,7 +288,7 @@ app.patch("/api/dotenv", async (req: Request, res: Response) => { try { const dotenvPath = - (typeof dotenv.path === "string" ? dotenv.path : undefined) || + resolveDotenvPathFromWorkspace(typeof dotenv.path === "string" ? dotenv.path : undefined) || process.env.WORKSPACE_PATH || undefined; await currentRunner.applyDotenv(dotenv.enabled, dotenvPath); @@ -298,7 +299,7 @@ app.patch("/api/dotenv", async (req: Request, res: Response) => { }); app.post("/api/execute", async (req: Request, res: Response) => { - const { url, method, headers, body } = req.body ?? {}; + const { url, path: reqPath, method, headers, body } = req.body ?? {}; if (!currentRunner) { res.status(400).json({ @@ -310,17 +311,32 @@ app.post("/api/execute", async (req: Request, res: Response) => { try { if (currentRunner.getType() === "http-wasm") { - // HTTP WASM: Simple request/response - if (!url || typeof url !== "string") { + // HTTP WASM: Accept either `path` (preferred) or `url` (legacy). + // When `path` is provided, use it directly (e.g. "/api/hello?q=1"). + // When `url` is provided, extract pathname + search from it. + let resolvedPath: string; + if (reqPath && typeof reqPath === "string") { + resolvedPath = reqPath; + } else if (url && typeof url === "string") { + let urlObj: URL; + try { + urlObj = new URL(url); + } catch { + res + .status(400) + .json({ ok: false, error: `Invalid url: ${url} (must be an absolute URL)` }); + return; + } + resolvedPath = urlObj.pathname + urlObj.search; + } else { res .status(400) - .json({ ok: false, error: "Missing url for HTTP WASM request" }); + .json({ ok: false, error: "Missing path (or url) for HTTP WASM request" }); return; } - const urlObj = new URL(url); const result = await currentRunner.execute({ - path: urlObj.pathname + urlObj.search, + path: resolvedPath, method: method || "GET", headers: headers || {}, body: body || "", @@ -479,12 +495,25 @@ function resolveConfigDir(): string { return path.join(root, ".fastedge-debug"); } +/** Resolve a potentially relative dotenv path using the same base as resolveConfigDir(). */ +function resolveDotenvPathFromWorkspace(dotenvPath: string | undefined): string | undefined { + const base = process.env.WORKSPACE_PATH || path.join(__dirname, ".."); + return resolveDotenvPath(dotenvPath, base); +} + // Get test configuration app.get("/api/config", async (req: Request, res: Response) => { try { - const configPath = path.join(resolveConfigDir(), "fastedge-config.test.json"); + const configDir = resolveConfigDir(); + const configPath = path.join(configDir, "fastedge-config.test.json"); const configData = await fs.readFile(configPath, "utf-8"); const config = JSON.parse(configData); + + // Resolve relative dotenv.path against the config file's directory + if (config.dotenv?.path && !path.isAbsolute(config.dotenv.path)) { + config.dotenv.path = path.resolve(configDir, config.dotenv.path); + } + // Validate config against schema, include validation result in response const validation = TestConfigSchema.safeParse(config); res.json({ diff --git a/server/test-framework/suite-runner.ts b/server/test-framework/suite-runner.ts index 97d5a16..48c9fad 100644 --- a/server/test-framework/suite-runner.ts +++ b/server/test-framework/suite-runner.ts @@ -1,4 +1,5 @@ import { readFile } from "fs/promises"; +import path from "path"; import { createRunner, createRunnerFromBuffer } from "../runner/standalone.js"; import { BUILTIN_SHORTHAND, BUILTIN_URL } from "../runner/ProxyWasmRunner.js"; import { TestConfigSchema } from "../schemas/config.js"; @@ -41,6 +42,13 @@ export async function loadConfigFile(configPath: string): Promise { `Invalid test config '${configPath}':\n${JSON.stringify(result.error.flatten(), null, 2)}`, ); } + + // Resolve relative dotenv.path against the config file's directory + if (result.data.dotenv?.path && !path.isAbsolute(result.data.dotenv.path)) { + const configDir = path.dirname(path.resolve(configPath)); + result.data.dotenv.path = path.resolve(configDir, result.data.dotenv.path); + } + return result.data; } diff --git a/server/utils/dotenv-loader.ts b/server/utils/dotenv-loader.ts index 3b816a1..f34db47 100644 --- a/server/utils/dotenv-loader.ts +++ b/server/utils/dotenv-loader.ts @@ -113,3 +113,17 @@ export async function hasDotenvFiles( return false; } + +/** + * Resolve a potentially relative dotenv path to an absolute path. + * Relative paths are resolved against the provided base directory. + * Returns undefined for falsy input; absolute paths pass through unchanged. + */ +export function resolveDotenvPath( + dotenvPath: string | undefined, + base: string, +): string | undefined { + if (!dotenvPath) return undefined; + if (path.isAbsolute(dotenvPath)) return dotenvPath; + return path.resolve(base, dotenvPath); +} diff --git a/test-applications/cdn-apps/rust/Cargo.lock b/test-applications/cdn-apps/rust/Cargo.lock index 11f71d9..ee03172 100644 --- a/test-applications/cdn-apps/rust/Cargo.lock +++ b/test-applications/cdn-apps/rust/Cargo.lock @@ -26,6 +26,13 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cdn-headers" +version = "0.1.0" +dependencies = [ + "proxy-wasm", +] + [[package]] name = "cdn-http-call" version = "0.1.0" diff --git a/test-applications/cdn-apps/rust/Cargo.toml b/test-applications/cdn-apps/rust/Cargo.toml index b0790f5..202b3bc 100644 --- a/test-applications/cdn-apps/rust/Cargo.toml +++ b/test-applications/cdn-apps/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["cdn-variables-and-secrets", "cdn-http-call"] +members = ["cdn-variables-and-secrets", "cdn-http-call", "cdn-headers"] resolver = "2" [workspace.dependencies] diff --git a/test-applications/cdn-apps/rust/cdn-headers/Cargo.toml b/test-applications/cdn-apps/rust/cdn-headers/Cargo.toml new file mode 100644 index 0000000..020fb4e --- /dev/null +++ b/test-applications/cdn-apps/rust/cdn-headers/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cdn-headers" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +proxy-wasm = { workspace = true } diff --git a/test-applications/cdn-apps/rust/cdn-headers/src/lib.rs b/test-applications/cdn-apps/rust/cdn-headers/src/lib.rs new file mode 100644 index 0000000..b9ca2ed --- /dev/null +++ b/test-applications/cdn-apps/rust/cdn-headers/src/lib.rs @@ -0,0 +1,354 @@ +use proxy_wasm::traits::*; +use proxy_wasm::types::*; +use std::collections::HashSet; + +proxy_wasm::main! {{ + proxy_wasm::set_log_level(LogLevel::Trace); + proxy_wasm::set_root_context(|_| -> Box { Box::new(HttpHeadersRoot) }); +}} + +struct HttpHeadersRoot; + +impl Context for HttpHeadersRoot {} + +impl RootContext for HttpHeadersRoot { + fn create_http_context(&self, context_id: u32) -> Option> { + Some(Box::new(HttpHeaders { context_id })) + } + + fn get_type(&self) -> Option { + Some(ContextType::HttpContext) + } +} + +struct HttpHeaders { + context_id: u32, +} + +impl Context for HttpHeaders {} + +impl HttpContext for HttpHeaders { + fn on_http_request_headers(&mut self, _: usize, _: bool) -> Action { + let mut original_headers = HashSet::new(); + let mut original_headers_bytes = HashSet::new(); + + // iterate over the headers and print them + for (name, value) in self.get_http_request_headers().into_iter() { + println!("#{} -> {}: {}", self.context_id, name, value); + original_headers.insert((name, value)); + } + for (name, value) in self.get_http_request_headers_bytes().into_iter() { + println!("#{} -> {}: {:?}", self.context_id, name, value); + original_headers_bytes.insert((name, value)); + } + if original_headers.is_empty() || original_headers_bytes.is_empty() { + self.send_http_response(550, vec![], None); + return Action::Pause; + } + + // check if the host header is present + if self.get_http_request_header("host").is_none() { + self.send_http_response(551, vec![], None); + return Action::Pause; + } + if self.get_http_request_header_bytes("host").is_none() { + self.send_http_response(551, vec![], None); + return Action::Pause; + } + + // add new headers + self.add_http_request_header("new-header-01", "value-01"); + self.add_http_request_header_bytes("new-header-bytes-01", b"value-bytes-01"); + + self.add_http_request_header("new-header-02", "value-02"); + self.add_http_request_header_bytes("new-header-bytes-02", b"value-bytes-02"); + + self.add_http_request_header("new-header-03", "value-03"); + self.add_http_request_header_bytes("new-header-bytes-03", b"value-bytes-03"); + + //remove header new-header-01, expected empty value + self.set_http_request_header("new-header-01", None); + self.set_http_request_header_bytes("new-header-bytes-01", None); + + // changing header value + self.set_http_request_header("new-header-02", Some("new-value-02")); + self.set_http_request_header_bytes("new-header-bytes-02", Some(b"new-value-bytes-02")); + + // add new header with existing name + self.add_http_request_header("new-header-03", "value-03-a"); + self.add_http_request_header_bytes("new-header-bytes-03", b"value-bytes-03-a"); + + // try to set/add response headers + self.add_http_response_header("new-response-header", "value-01"); + self.set_http_response_header("cache-control", None); + self.set_http_response_header("new-response-header", Some("value-02")); + + // get new headers + let headers = self + .get_http_request_headers() + .into_iter() + .collect::>(); + let headers_bytes = self + .get_http_request_headers_bytes() + .into_iter() + .collect::>(); + + let expected = [ + ("new-header-01".to_string(), "".to_string()), + ("new-header-bytes-01".to_string(), "".to_string()), + ("new-header-02".to_string(), "new-value-02".to_string()), + ( + "new-header-bytes-02".to_string(), + "new-value-bytes-02".to_string(), + ), + ("new-header-03".to_string(), "value-03".to_string()), + ( + "new-header-bytes-03".to_string(), + "value-bytes-03".to_string(), + ), + ("new-header-03".to_string(), "value-03-a".to_string()), + ( + "new-header-bytes-03".to_string(), + "value-bytes-03-a".to_string(), + ), + ]; + + let expected = expected.iter().collect::>(); + + let expected_bytes = [ + ("new-header-01".to_string(), b"".to_vec()), + ("new-header-bytes-01".to_string(), b"".to_vec()), + ("new-header-02".to_string(), b"new-value-02".to_vec()), + ( + "new-header-bytes-02".to_string(), + b"new-value-bytes-02".to_vec(), + ), + ("new-header-03".to_string(), b"value-03".to_vec()), + ( + "new-header-bytes-03".to_string(), + b"value-bytes-03".to_vec(), + ), + ("new-header-03".to_string(), b"value-03-a".to_vec()), + ( + "new-header-bytes-03".to_string(), + b"value-bytes-03-a".to_vec(), + ), + ]; + + let expected_bytes = expected_bytes.iter().collect::>(); + + let diff = headers + .difference(&original_headers) + .collect::>(); + + let diff_bytes = headers_bytes + .difference(&original_headers_bytes) + .collect::>(); + + let diff = diff.difference(&expected).collect::>(); + + if !diff.is_empty() { + println!("different headers: {:?}", diff); + self.send_http_response(552, vec![], None); + return Action::Pause; + } + + let diff_bytes = diff_bytes.difference(&expected_bytes).collect::>(); + if !diff_bytes.is_empty() { + println!("different headers bytes: {:?}", diff_bytes); + self.send_http_response(552, vec![], None); + return Action::Pause; + } + + // check if the response header is not returned + let Some(value) = self.get_http_response_header("host") else { + self.send_http_response(553, vec![], None); + return Action::Pause; + }; + if !value.is_empty() { + self.send_http_response(554, vec![], None); + return Action::Pause; + } + let Some(value) = self.get_http_response_header_bytes("host") else { + self.send_http_response(553, vec![], None); + return Action::Pause; + }; + if !value.is_empty() { + self.send_http_response(554, vec![], None); + return Action::Pause; + } + + let response_headers = self.get_http_response_headers(); + if response_headers.len() != 1 { + self.send_http_response(555, vec![], None); + return Action::Pause; + } + let Some((name, value)) = response_headers.into_iter().next() else { + self.send_http_response(555, vec![], None); + return Action::Pause; + }; + if name != "new-response-header" || value != "value-02" { + self.send_http_response(556, vec![], None); + return Action::Pause; + } + + Action::Continue + } + + fn on_http_response_headers(&mut self, _: usize, _: bool) -> Action { + let mut original_headers = HashSet::new(); + let mut original_headers_bytes = HashSet::new(); + + // iterate over the headers and print them + for (name, value) in self.get_http_response_headers().into_iter() { + println!("#{} -> {}: {}", self.context_id, name, value); + original_headers.insert((name, value)); + } + for (name, value) in self.get_http_response_headers_bytes().into_iter() { + println!("#{} -> {}: {:?}", self.context_id, name, value); + original_headers_bytes.insert((name, value)); + } + if original_headers.is_empty() || original_headers_bytes.is_empty() { + self.send_http_response(550, vec![], None); + return Action::Pause; + } + + // check if the host header is present + if self.get_http_response_header("host").is_none() { + self.send_http_response(551, vec![], None); + return Action::Pause; + } + if self.get_http_response_header_bytes("host").is_none() { + self.send_http_response(551, vec![], None); + return Action::Pause; + } + + // add new headers + self.add_http_response_header("new-header-01", "value-01"); + self.add_http_response_header_bytes("new-header-bytes-01", b"value-bytes-01"); + + self.add_http_response_header("new-header-02", "value-02"); + self.add_http_response_header_bytes("new-header-bytes-02", b"value-bytes-02"); + + self.add_http_response_header("new-header-03", "value-03"); + self.add_http_response_header_bytes("new-header-bytes-03", b"value-bytes-03"); + + //remove header new-header-01, expected empty value + self.set_http_response_header("new-header-01", None); + self.set_http_response_header_bytes("new-header-bytes-01", None); + + // changing header value + self.set_http_response_header("new-header-02", Some("new-value-02")); + self.set_http_response_header_bytes("new-header-bytes-02", Some(b"new-value-bytes-02")); + + // add new header with existing name + self.add_http_response_header("new-header-03", "value-03-a"); + self.add_http_response_header_bytes("new-header-bytes-03", b"value-bytes-03-a"); + + // get new headers + let headers = self + .get_http_response_headers() + .into_iter() + .collect::>(); + let headers_bytes = self + .get_http_response_headers_bytes() + .into_iter() + .collect::>(); + + let expected = [ + ("new-header-01".to_string(), "".to_string()), + ("new-header-bytes-01".to_string(), "".to_string()), + ("new-header-02".to_string(), "new-value-02".to_string()), + ( + "new-header-bytes-02".to_string(), + "new-value-bytes-02".to_string(), + ), + ("new-header-03".to_string(), "value-03".to_string()), + ( + "new-header-bytes-03".to_string(), + "value-bytes-03".to_string(), + ), + ("new-header-03".to_string(), "value-03-a".to_string()), + ( + "new-header-bytes-03".to_string(), + "value-bytes-03-a".to_string(), + ), + ]; + + let expected = expected.iter().collect::>(); + + let expected_bytes = [ + ("new-header-01".to_string(), b"".to_vec()), + ("new-header-bytes-01".to_string(), b"".to_vec()), + ("new-header-02".to_string(), b"new-value-02".to_vec()), + ( + "new-header-bytes-02".to_string(), + b"new-value-bytes-02".to_vec(), + ), + ("new-header-03".to_string(), b"value-03".to_vec()), + ( + "new-header-bytes-03".to_string(), + b"value-bytes-03".to_vec(), + ), + ("new-header-03".to_string(), b"value-03-a".to_vec()), + ( + "new-header-bytes-03".to_string(), + b"value-bytes-03-a".to_vec(), + ), + ]; + + let expected_bytes = expected_bytes.iter().collect::>(); + + let diff = headers + .difference(&original_headers) + .collect::>(); + + let diff_bytes = headers_bytes + .difference(&original_headers_bytes) + .collect::>(); + + if expected != diff { + let diff = diff.difference(&expected).collect::>(); + println!("different headers: {:?}", diff); + self.send_http_response(552, vec![], None); + return Action::Pause; + } + + if expected_bytes != diff_bytes { + let diff = diff_bytes.difference(&expected_bytes).collect::>(); + println!("different headers bytes: {:?}", diff_bytes); + self.send_http_response(552, vec![], None); + return Action::Pause; + } + + // check if the response header is not returned + let Some(value) = self.get_http_response_header("host") else { + self.send_http_response(553, vec![], None); + return Action::Pause; + }; + if !value.is_empty() { + self.send_http_response(554, vec![], None); + return Action::Pause; + } + let Some(value) = self.get_http_response_header_bytes("host") else { + self.send_http_response(553, vec![], None); + return Action::Pause; + }; + if !value.is_empty() { + self.send_http_response(554, vec![], None); + return Action::Pause; + } + + let request_headers = self.get_http_response_headers(); + if request_headers.is_empty() { + self.send_http_response(555, vec![], None); + return Action::Pause; + } + + Action::Continue + } + + fn on_log(&mut self) { + println!("#{} completed.", self.context_id); + } +}