diff --git a/README.md b/README.md index 766b0e6..6d9215a 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A framework for building minimal but powerful **web app backends**, **REST APIs* ▸ [Get started](#getting-started) or [read more](#why-webx) about the project below. > **⚠️ WebX is still in early development and not ready for production use.** +> See [current runtime support and limitations](docs/runtime.md).
@@ -276,59 +277,25 @@ It also disables hot reloading and response-logging to reduce sensitive debug in ## Examples -The following is an example of a simple WebX to-do list application. +The [todo CRUD example](examples/todo/README.md) is a runnable HTMX browser UI +with an in-memory backend, covered by HTTP integration tests. From this repository: -```typescript -include "../common.webx" - -global { - const db = new Database("sqlite://db.sqlite"); -} - -model User { - id: number; - name: string[maxLength(50))] - email: string?; - isAdmin: boolean; -} - -model Todo { - id: number; - task: string; - userId: number; -} - -handler initServices() { - return { - userService: new UserService(db), - todoService: new TodoService(db) - }; -} - -handler auth(userService: UserService, id: number) { - const user = userService.getCurrentUser(id); - if (!user) error("User not found."); - return { user }; -} - -handler isAdmin(user: User) { - if (!user.isAdmin()) error("User is not an admin."); -} - -handler renderTodos(todos: Todo[], user: User): HTML { - return (
-

Welcome, {user.name}!

- -
); -} - -get /todos/(id: number) -> initServices:s, auth(s.userService, id):a, isAdmin(a.user) { - const todos = s.todoService.getAllTodosForUser(a.user.id); -} -> renderTodos(todos, a.user) +```sh +cargo run --locked -- run examples/todo ``` +Open . HTMX sends CRUD requests and swaps server-rendered +HTML fragments. The example uses `global` JavaScript functions, path parameters, +and `static("index.html")`; it loads pinned HTMX 2.0.8 from jsDelivr. No database, +frontend build step, request-body decoding, or JSX rendering is required. + +See [Runtime behavior](docs/runtime.md) for the implemented routing, reload, +request-state, and body-handling contract. The roadmap above includes planned +features; it is not a statement of current runtime support. + +Run the tests with `cargo test --locked`. They assert actual HTTP responses and +join the server/runtime workers without terminating the test process. +
## Why Web X? diff --git a/docs/runtime.md b/docs/runtime.md index 73032be..edafaa3 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -35,3 +35,79 @@ reports a diagnostic and retains the complete last good snapshot. HTML/route-onl edits preserve module globals. Changing a module's `global` block resets that module's state; removal discards it. Development checks read the source tree on each request and are intended for correctness and convenience, not throughput. + +## Request state and execution + +One actor thread owns one V8 runtime per module. Requests can arrive concurrently +through Hyper, but JavaScript runs synchronously, one request at a time. No V8 +handle crosses threads or isolates. + +Path parameters, named handler outputs (`-> fn(...): name`), and `out` (the route +HTML body, available to post-handlers) belong to a single request. Expressions +receive these values as function arguments. They are never installed on the +module's global object and are released after success or failure. Every handler +argument is evaluated once; each named output, including the last in a chain, +is available to subsequent handlers. A failed handler stops the chain. + +Module `global` variables are deliberately shared application state, separate +from request bindings. Functions defined there receive request data through their +arguments. Deliberately storing a request value in a global retains it; this is +not a sandbox or per-request VM. Modules have separate globals. Shared objects +returned by handlers remain shared objects, not deep copies. Shared state changes +are serialized but not transactional: a later handler failure does not undo them. + +## Supported requests and responses + +- Methods are matched exactly (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, + CONNECT, TRACE where supported by the HTTP/1 transport). Bodyless POST/PUT/PATCH + routes are valid. GET/HEAD/DELETE body declarations are rejected during analysis. +- Route parameters are strings. Request headers, query parameters, and body fields + are not exposed as JavaScript bindings. There is no request object or middleware + context API. +- `json(...)`, `form(...)`, and model body declarations are parsed but **not + decoded or validated**. Invoking a route with such a declaration returns **501** + before any handler executes. On routes without a body declaration, incoming + body bytes are ignored. Sending JSON does not bind its properties. +- Calls to synchronous functions in a module's root `global` block and the native + `static(path)` handler work. `static` reads a UTF-8 text file relative to the + project root on every call; it is not a binary asset server or MIME detector. +- Parenthesized route bodies and string handler results are literal HTML + (`text/html; charset=utf-8`). No JSX interpolation or HTML escaping is performed. + Other serializable values become JSON (`application/json`); `undefined` becomes + `null`. Cyclic/nonserializable results and thrown exceptions return 500 without + stopping the runtime. Successful results use 200; custom response statuses and + headers are not yet supported. +- Route `{ ... }` code blocks and Promise/async results return 501. Use synchronous + JavaScript functions inside `global` instead. TypeScript transpilation, `.wx` + `handler` declarations, model validation, includes/import execution, and nested + location globals/handlers are not implemented. Nested locations do support + routing with the module's root global functions. + +## Limits + +The actor remains a throughput bottleneck: slow or nonterminating JavaScript +blocks all requests and can prevent shutdown. There is no worker pool, execution +budget, asynchronous event-loop dispatch, or durable database. In-memory state +is lost on restart or a changed global block. The concurrency tests prove request +binding isolation under simultaneous HTTP traffic, not parallel JavaScript +execution or a production throughput guarantee. + +The CLI currently binds loopback port 8080 in development and tries 80/443 in +production (plain HTTP, not TLS); configured host/port settings are not yet +honored. Configuration entries for databases, CORS, caching, and rate limiting +are not implementations of those services. The todo example requires none of them. + +## Verification + +With stable Rust and the platform's C/C++ build tools installed: + +```sh +cargo build --locked +cargo test --locked +``` + +The test harness binds `127.0.0.1:0`, sends real HTTP/1 requests, collects response +bodies, and asserts status, content type, and content. It uses independent temporary +projects, checks simultaneous clients with a barrier, and joins both server and +runtime threads on drop. Five-second request deadlines fail stuck tests; elapsed +time or process exit is never treated as successful verification. diff --git a/examples/todo/README.md b/examples/todo/README.md new file mode 100644 index 0000000..1c449c7 --- /dev/null +++ b/examples/todo/README.md @@ -0,0 +1,50 @@ +# Todo CRUD example with HTMX + +From the repository root, run: + +```sh +cargo run --locked -- run examples/todo +``` + +Open http://localhost:8080 to add, list, edit, complete, and delete todos. The +page loads pinned **HTMX 2.0.8** from jsDelivr with an integrity hash, so the +browser needs network access to that CDN. No database or frontend build step is +required. + +HTMX owns all client/server interaction: `hx-get` loads the list and edit form, +`hx-post` creates a todo, `hx-put` saves it, and `hx-delete` removes it. Every +endpoint returns a server-rendered `#todo-panel` HTML fragment, which HTMX swaps +with `hx-target` and `hx-swap="outerHTML"`. Titles are escaped on the server in +both text and input-value attributes. Validation messages appear in the returned +fragment's status region; the browser does not render JSON or construct list rows. + +A small `htmx:configRequest` hook encodes form values into URL segments because +WebX does not yet decode form/JSON bodies. `hx-params="none"` prevents HTMX from +also submitting those values in the body. There is no custom fetch/XHR code. + +Try the complete HTTP lifecycle (responses are HTML): + +```sh +curl http://localhost:8080/todos +curl -X POST http://localhost:8080/todos/Buy%20milk +curl http://localhost:8080/todos/1 +curl -X PUT http://localhost:8080/todos/1/Buy%20oat%20milk/true +curl -X DELETE http://localhost:8080/todos/1 +curl http://localhost:8080/todos +``` + +`GET /todos/1` renders the list with todo 1's edit form open. Titles are decoded +with `decodeURIComponent`, trimmed, and restricted to 1–120 characters. IDs are +generated monotonically; completion values are `true` or `false`. Missing IDs and +invalid inputs return an HTML validation message without changing stored data. +These responses use HTTP 200 so HTMX swaps the message into the page; custom +application status codes are not supported yet. + +This is a small in-memory demo. Titles appear in URLs and request logs. +Restarting, or editing the module's global block, resets all data; HTML-only +edits preserve it. There is no persistence, authentication, or user separation. +See the [runtime contract](../../docs/runtime.md) for supported facilities and +limitations. + +`cargo test --locked todo_example` checks the real example over HTTP, including +the CRUD lifecycle, HTMX fragment controls, validation, and escaped titles. diff --git a/examples/todo/index.html b/examples/todo/index.html index e055879..36aab39 100644 --- a/examples/todo/index.html +++ b/examples/todo/index.html @@ -1,71 +1,40 @@ - - - - - - - - - - Document - + + + WebX todos with HTMX + + - -
-

A WebX todo list

-

Welcome! Here is a list of things to do:

- - -
- - -
-
+
+

WebX todos with HTMX

+

Add a todo, edit its title or completion state, save it, or delete it.

+
Loading todos…
+ +

This demo keeps data in memory. Restarting or editing the global code resets it.

+
+ - - - \ No newline at end of file + diff --git a/examples/todo/index.wx b/examples/todo/index.wx index ec803ce..efd48c3 100644 --- a/examples/todo/index.wx +++ b/examples/todo/index.wx @@ -1,121 +1,81 @@ -/* import { Todo, renderAllTodos, renderTodo } from "./todos"; */ - -get /public/(asset: String) -> static("assets/" + asset) - -get / ( - - - - - - - Web X Demo - - -

Hello from WebX!

-

- This is an example index page. -

- About - Todos - -) - -get /about (
-

About

-

This is an example WebX project.

- Home -
) - -post /about/(id: number) form(text: string) -> renderAllTodos(a, b): c { - const a = [5]; - if true { console.log(text); } -} -> renderAllTodos(a, b, c) - -post list/all/(data : number)/data json(a: T, b : U) -> renderAllTodos(todos, test): out, hc() : u - -// This is an example WebX todo app project. +// HTMX CRUD: return HTML fragments using existing synchronous global functions. +// Request body decoding is not implemented; titles travel as encoded URL segments. global { - // Global in-memory database of todos for this example. - const todos = [ - { title: "Example Item", completed: true, createdAt: new Date().setMinutes(new Date().getMinutes() - 1) }, - { title: "Example Item", completed: false, createdAt: new Date().setMinutes(new Date().getMinutes() - 3) }, - { title: "Example Item", completed: false, createdAt: new Date().setMinutes(new Date().getMinutes() - 10) }, - ]; - function test() { return todos[2]; } - let a = test(); - console.log(a); -} + let todos = []; + let nextId = 1; -model Todo { - title: string, - completed: bool, - createdAt: Date, -} - -handler renderTodo(todo: Todo) (
  • -

    - - {todo.title} - {getTimeDiff(todo.createdAt)} -

    -
  • ) - -handler renderAllTodos(todos: Todo) ( - -) - -handler authenticate(user_id: Number) { - if (user_id === 1) { - return Guard::Allow; - } else { - return Guard::Deny; + function escapeHtml(value) { + return String(value).replaceAll("&", "&").replaceAll("<", "<") + .replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); } -} - -location todos { - // The main todo app page. - get / -> static("index.html") - - get /list () - - // Display the global list of todos as HTML. - get list/all/(data : Int)/data -> renderAllTodos(todos) - - // Add a new todo to the list with the given title. - // { title: "My Todo" } - // returns HTML - post add/ json(title: String) { - let newTodo = { - title, - completed: false, - createdAt: new Date() - }; - todos.push(newTodo); - } -> renderTodo(newTodo) - - // Toggle the completed status of the todo with the given id. - // { id: 0 } - // returns HTML - post /toggle json(id: Int) { - const todo = todos.find(t => t.id === id); - if (todo) { - todo.completed = !todo.completed; - return renderTodo(todo); - } else { - return error("Todo not found."); + function titleFromPath(encoded) { + try { + const title = decodeURIComponent(encoded).trim(); + return title.length > 0 && title.length <= 120 ? title : null; + } catch (error) { return null; } + } + function findTodo(id) { return todos.find(item => String(item.id) === id); } + function renderTodo(todo, editingId) { + if (String(todo.id) === editingId) { + return `
  • +
    + + + + + +
    +
  • `; } - } -> renderTodo(todo) + return `
  • + ${escapeHtml(todo.title)} + ${todo.completed ? "Completed" : "Pending"} + + +
  • `; + } + function renderTodos(message, editingId) { + const rows = todos.map(todo => renderTodo(todo, editingId)).join(""); + return `
    +

    ${escapeHtml(message || "")}

    + +
    + + + +
    +
    `; + } + function listTodos() { return renderTodos(""); } + function readTodo(id) { + return findTodo(id) ? renderTodos("", id) : renderTodos("Todo not found"); + } + function createTodo(encoded) { + const title = titleFromPath(encoded); + if (!title) return renderTodos("Title must contain 1-120 characters"); + todos.push({ id: nextId++, title, completed: false }); + return renderTodos("Todo added"); + } + function updateTodo(id, encoded, completed) { + const todo = findTodo(id); + if (!todo) return renderTodos("Todo not found"); + const title = titleFromPath(encoded); + if (!title) return renderTodos("Title must contain 1-120 characters", id); + if (completed !== "true" && completed !== "false") return renderTodos("Invalid completion state", id); + todo.title = title; + todo.completed = completed === "true"; + return renderTodos("Todo updated"); + } + function deleteTodo(id) { + if (!findTodo(id)) return renderTodos("Todo not found"); + todos = todos.filter(item => String(item.id) !== id); + return renderTodos("Todo deleted"); + } } + +get / -> static("index.html") +get /todos -> listTodos() +get /todos/(id: String) -> readTodo(id) +post /todos/(title: String) -> createTodo(title) +put /todos/(id: String)/(title: String)/(completed: String) -> updateTodo(id, title, completed) +delete /todos/(id: String) -> deleteTodo(id) diff --git a/examples/todo/webx.config.json b/examples/todo/webx.config.json index d2835c4..41838eb 100644 --- a/examples/todo/webx.config.json +++ b/examples/todo/webx.config.json @@ -1,12 +1,7 @@ { - "$schema": "https://raw.githubusercontent.com/webx-net/webx/main/schemas/webx.config.schema.json", - "name": "My todo WebX Project", - "description": "An example WebX project.", - "version": "1.0.0", - "port": 8080, - "tls": { - "key": "path/to/key.pem", - "cert": "path/to/cert.pem" - }, - "cors": { "allowOrigin": "*" } + "$schema": "https://raw.githubusercontent.com/webx-net/webx/main/schemas/webx.config.schema.json", + "name": "WebX todo CRUD example", + "description": "An in-memory CRUD API and browser UI using supported runtime facilities.", + "version": "1.0.0", + "port": 8080 } diff --git a/src/engine/http.rs b/src/engine/http.rs index 9618ede..3570431 100644 --- a/src/engine/http.rs +++ b/src/engine/http.rs @@ -24,7 +24,7 @@ pub mod responses { use deno_core::v8::{self, Global, HandleScope, Local, Value}; use hyper::{body::Bytes, Method, Response}; - use crate::runner::WXMode; + use crate::{engine::runtime::WXRuntimeError, runner::WXMode}; pub fn server_header(mode: WXMode) -> String { if mode.is_dev() { @@ -72,12 +72,25 @@ pub mod responses { .unwrap() } - pub fn ok_json(body: &Global, scope: &mut HandleScope, mode: WXMode) -> Response { + pub fn ok_json( + body: &Global, + scope: &mut HandleScope, + mode: WXMode, + ) -> Result, WXRuntimeError> { + let scope = &mut v8::TryCatch::new(scope); let local = Local::new(scope, body); - let value = v8::json::stringify(scope, local).expect("Failed to serialize JSON value"); - let json = value.to_rust_string_lossy(scope); + let json = if local.is_undefined() { + "null".to_string() + } else { + v8::json::stringify(scope, local) + .ok_or_else(|| WXRuntimeError { + code: 500, + message: "Response is not JSON-serializable".into(), + })? + .to_rust_string_lossy(scope) + }; let bytes = Bytes::from(json); - Response::builder() + Ok(Response::builder() .status(hyper::StatusCode::OK) // .header("Access-Control-Allow-Origin", "*") .header("Content-Type", "application/json") @@ -89,6 +102,16 @@ pub mod responses { // .header("Pragma", "no-cache") // .header("Expires", "0") .body(bytes) + .unwrap()) + } + + pub fn not_implemented(mode: WXMode, message: String) -> Response { + Response::builder() + .status(hyper::StatusCode::NOT_IMPLEMENTED) + .header("Content-Type", "text/plain; charset=utf-8") + .header("Connection", "close") + .header("Server", server_header(mode)) + .body(Bytes::from(message)) .unwrap() } diff --git a/src/engine/runtime.rs b/src/engine/runtime.rs index 0e5b7dc..cb13332 100644 --- a/src/engine/runtime.rs +++ b/src/engine/runtime.rs @@ -66,19 +66,37 @@ impl WXRTContext { } } -fn init_context<'a>( - scope: &'a mut v8::HandleScope, - ctx: &WXRTContext, -) -> v8::Local<'a, v8::Context> { - let js_ctx = v8::Context::new(scope); - let global = js_ctx.global(scope); - for (key, value) in ctx.values.iter() { - // Maybe replace key with Symbol instead? - let key = v8::String::new(scope, key).unwrap(); - let value = Local::new(scope, value); - global.set(scope, key.into(), value); +/// Invoke a function inside its owning isolate. Request bindings are function +/// arguments, never properties of the persistent module global object. +fn call_js_function( + function: Global, + args: &[Global], + rt: &mut JsRuntime, +) -> Result, WXRuntimeError> { + let scope = &mut rt.handle_scope(); + let scope = &mut v8::TryCatch::new(scope); + let function = Local::::try_from(Local::new(scope, function)).map_err(|_| { + WXRuntimeError { + code: 500, + message: "Handler is not a function".into(), + } + })?; + let args: Vec<_> = args.iter().map(|arg| Local::new(scope, arg)).collect(); + let receiver = v8::undefined(scope).into(); + match function.call(scope, receiver, &args) { + Some(value) if value.is_promise() => Err(WXRuntimeError { + code: 501, + message: "Async handlers/Promises are not supported".into(), + }), + Some(value) => Ok(Global::new(scope, value)), + None => { + let message = scope + .exception() + .map(|err| err.to_rust_string_lossy(scope)) + .unwrap_or_else(|| "JavaScript execution failed".into()); + Err(WXRuntimeError { code: 500, message }) + } } - js_ctx } fn eval_js_expression( @@ -86,99 +104,47 @@ fn eval_js_expression( rt: &mut JsRuntime, ctx: &WXRTContext, ) -> Result, WXRuntimeError> { - { - let mut scope = rt.handle_scope(); - init_context(&mut scope, ctx); - } - let val = rt.execute_script("[webx expression]", expr.into()); - match val { - Ok(val) => Ok(val), - Err(err) => Err(WXRuntimeError { + let mut names: Vec<_> = ctx.values.keys().cloned().collect(); + names.sort(); + let args: Vec<_> = names.iter().map(|name| ctx.values[name].clone()).collect(); + let source = format!( + "(function({}) {{ \"use strict\"; return ({}); }})", + names.join(","), + expr + ); + let function = rt + .execute_script("[webx expression]", source.into()) + .map_err(|err| WXRuntimeError { code: 500, - message: format!("Expression threw an error:\n{}", err), - }), - } + message: format!("Expression error: {}", err), + })?; + call_js_function(function, &args, rt) } + impl WXRouteHandlerCall { - /// Execute the handler in the given context and return the result. fn execute( &self, ctx: &WXRTContext, rt: &mut JsRuntime, info: &WXRuntimeInfo, ) -> Result, WXRuntimeError> { - match self.try_execute_native_script(rt, ctx, info) { - Some(result) => result, - None => self.execute_user_script(rt), - } - } - - fn extract_arguments( - &self, - global_args: v8::Global, - rt: &mut JsRuntime, - ) -> Result>, WXRuntimeError> { - let mut js_args = Vec::new(); - - { - // Isolate the HandleScope to this block to avoid lifetime issues + // Evaluate each argument once, including calls with side effects. + let args = eval_js_expression(format!("[{}]", self.args), rt, ctx)?; + let args = { let scope = &mut rt.handle_scope(); - let local_args = Local::new(scope, global_args); - - let arr_args = match Local::<'_, v8::Array>::try_from(local_args) { - Ok(args) => args, - Err(err) => { - return Err(WXRuntimeError { - code: 500, - message: format!( - "Handler '{}' expected an array, got: {:?} and failed with error: {:?}", - self.name, local_args, err - ), - }) - } - }; - - let len = arr_args.length() as usize; - - for i in 0..len { - let arg = arr_args.get_index(scope, i as u32).unwrap(); - let global_arg = Global::new(scope, arg); - js_args.push(global_arg); - } - } - - Ok(js_args) - } - - fn try_execute_native_script( - &self, - rt: &mut JsRuntime, - ctx: &WXRTContext, - info: &WXRuntimeInfo, - ) -> Option, WXRuntimeError>> { - let global_args = match eval_js_expression(format!("[{}]", self.args), rt, ctx) { - Ok(val) => val, - Err(err) => { - return Some(Err(WXRuntimeError { - code: 500, - message: format!("Handler '{}' threw an error:\n{}", self.name, err), - })) - } - }; - let js_args = match self.extract_arguments(global_args, rt) { - Ok(args) => args, - Err(err) => return Some(Err(err)), + let array = Local::::try_from(Local::new(scope, args)).unwrap(); + (0..array.length()) + .map(|i| { + let value = array.get_index(scope, i).unwrap(); + Global::new(scope, value) + }) + .collect::>() }; - stdlib::try_call(&self.name, &js_args, rt, info) - } - - fn execute_user_script(&self, rt: &mut JsRuntime) -> Result, WXRuntimeError> { - let js_call = format!("{}({})", self.name, self.args); - let call_res = rt.execute_script("[webx handler call]", js_call.into()); - call_res.map_err(|e| WXRuntimeError { - code: 500, - message: format!("Handler '{}' threw an error:\n{}", self.name, e), - }) + if let Some(result) = stdlib::try_call(&self.name, &args, rt, info) { + return result; + } + let function = eval_js_expression(self.name.clone(), rt, ctx)?; + call_js_function(function, &args, rt) } } @@ -228,6 +194,7 @@ impl WXUrlPath { } /// Possible values produced by a handler or route body. +#[derive(Clone)] pub enum WXRouteResult { Html(String), Js(Global), @@ -236,11 +203,8 @@ pub enum WXRouteResult { /// A runtime flat-route. #[derive(Debug, Clone)] pub struct WXRTRoute { - // TODO: Add support for: - // TODO: - handler functions - // TODO: - global typescript code - // TODO: - models ORM and types module_path: WXModulePath, + declares_body: bool, body: Option, pre_handlers: Vec, post_handlers: Vec, @@ -260,7 +224,12 @@ impl WXRTRoute { }); }; match body.body_type { - WXBodyType::Ts => todo!("TS body type is not supported yet"), + WXBodyType::Ts => Err(WXRuntimeError { + code: 501, + message: + "Route code blocks are not supported; call a function defined in global instead" + .into(), + }), // TODO: - Resolve bindings, render and execute JSX (dynamic) // TODO: - Use JSX runtime to render JSX WXBodyType::Tsx => Ok(WXRouteResult::Html(body.body.clone())), @@ -273,21 +242,16 @@ impl WXRTRoute { ctx: &mut WXRTContext, rt: &mut JsRuntime, info: &WXRuntimeInfo, - ) -> Option> { - let mut handlers = handlers.iter(); - for _ in 0..self.pre_handlers.len() - 1 { - let handler = handlers.next().unwrap(); - let result = match handler.execute(ctx, rt, info) { - Ok(result) => result, - Err(err) => return Some(Err(err)), - }; + ) -> Result, WXRuntimeError> { + let mut last = None; + for handler in handlers { + let result = handler.execute(ctx, rt, info)?; if let Some(output) = &handler.output { - ctx.bind(output, result); + ctx.bind(output, result.clone()); } + last = Some(WXRouteResult::Js(result)); } - handlers - .last() - .map(|last| last.execute(ctx, rt, info).map(WXRouteResult::Js)) + Ok(last) } fn bind_out(ctx: &mut WXRTContext, value: WXRouteResult, scope: &mut v8::HandleScope) { @@ -305,12 +269,12 @@ impl WXRTRoute { value: WXRouteResult, scope: &mut v8::HandleScope, mode: WXMode, - ) -> hyper::Response { + ) -> Result, WXRuntimeError> { match value { WXRouteResult::Html(body) => { let body = hyper::body::Bytes::from(body); let len = body.len(); - ok_html(body, len, mode) + Ok(ok_html(body, len, mode)) } WXRouteResult::Js(value) => { if let Ok(str_val) = @@ -318,7 +282,7 @@ impl WXRTRoute { { let str = hyper::body::Bytes::from(str_val.to_rust_string_lossy(scope)); let len = str.len(); - ok_html(str, len, mode) + Ok(ok_html(str, len, mode)) } else { ok_json(&value, scope, mode) } @@ -338,75 +302,26 @@ impl WXRTRoute { info: &WXRuntimeInfo, mode: WXMode, ) -> Result, WXRuntimeError> { - // TODO: Refactor this function to combine all logic into a better structure. - let has_pre_handlers: bool = !self.pre_handlers.is_empty(); - let has_body: bool = self.body.is_some(); - let has_post_handlers: bool = !self.post_handlers.is_empty(); - return match (has_pre_handlers, has_body, has_post_handlers) { - // All three are present, execute pre-handlers, body, and post-handlers. - (true, true, true) => { - self.execute_handlers(&self.pre_handlers, ctx, rt, info); - let value = self.execute_body(ctx, rt, info)?; - Self::bind_out(ctx, value, &mut rt.handle_scope()); - Ok(Self::to_response( - self.execute_handlers(&self.post_handlers, ctx, rt, info) - .unwrap()?, - &mut rt.handle_scope(), - mode, - )) - } - // Execute pre-handlers and body. - (true, true, false) => { - self.execute_handlers(&self.pre_handlers, ctx, rt, info); - Ok(Self::to_response( - self.execute_body(ctx, rt, info)?, - &mut rt.handle_scope(), - mode, - )) - } - // Execute pre and post-handlers. - (true, false, true) => { - self.execute_handlers(&self.pre_handlers, ctx, rt, info); - Ok(Self::to_response( - self.execute_handlers(&self.post_handlers, ctx, rt, info).unwrap()?, - &mut rt.handle_scope(), - mode, - )) - } - // Execute only pre-handlers. - (true, false, false) => Ok(Self::to_response( - self.execute_handlers(&self.pre_handlers, ctx, rt, info).unwrap()?, - &mut rt.handle_scope(), - mode, - )), - // Execute body and post-handlers. - (false, true, true) => { - let value = self.execute_body(ctx, rt, info)?; - Self::bind_out(ctx, value, &mut rt.handle_scope()); - Ok(Self::to_response( - self.execute_handlers(&self.post_handlers, ctx, rt, info) - .unwrap()?, - &mut rt.handle_scope(), - mode, - )) - } - // Execute only body - (false, true, false) => Ok(Self::to_response( - self.execute_body(ctx, rt, info)?, - &mut rt.handle_scope(), - mode, - )), - // Execute only post-handlers - (false, false, true) => Ok(Self::to_response( - self.execute_handlers(&self.post_handlers, ctx, rt, info).unwrap()?, - &mut rt.handle_scope(), - mode, - )), - (false, false, false) => Err(WXRuntimeError { - code: 500, - message: format!("Route execution not implemented for: pre_handlers={}, body={}, post_handlers={}", has_pre_handlers, has_body, has_post_handlers), - }), - }; + if self.declares_body { + return Err(WXRuntimeError { + code: 501, + message: "Request body decoding (json/form/models) is not supported".into(), + }); + } + let mut result = self.execute_handlers(&self.pre_handlers, ctx, rt, info)?; + if self.body.is_some() { + let body = self.execute_body(ctx, rt, info)?; + Self::bind_out(ctx, body.clone(), &mut rt.handle_scope()); + result = Some(body); + } + if let Some(post) = self.execute_handlers(&self.post_handlers, ctx, rt, info)? { + result = Some(post); + } + let result = result.ok_or_else(|| WXRuntimeError { + code: 500, + message: "Route has no response".into(), + })?; + Self::to_response(result, &mut rt.handle_scope(), mode) } } @@ -435,6 +350,7 @@ impl WXRouteMap { path.clone(), WXRTRoute { module_path: route.info.path, + declares_body: route.body_format.is_some(), body: route.body, pre_handlers: route.pre_handlers, post_handlers: route.post_handlers, @@ -687,24 +603,27 @@ impl WXRuntime { // println!("Shutting down runtime..."); break; // Exit the loop and stop the runtime. } - if let Ok(msg) = self.messages.recv_timeout(timeout_duration(self.mode)) { - match msg { - WXRuntimeMessage::Shutdown => break, - WXRuntimeMessage::Reload => { - if self.mode.is_dev() { - if let Err(err) = self.refresh_sources() { - error_code(err.message, err.code, self.mode.date_specifier()); - } + let msg = match self.messages.recv_timeout(timeout_duration(self.mode)) { + Ok(msg) => msg, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, + }; + match msg { + WXRuntimeMessage::Shutdown => break, + WXRuntimeMessage::Reload => { + if self.mode.is_dev() { + if let Err(err) = self.refresh_sources() { + error_code(err.message, err.code, self.mode.date_specifier()); } } - WXRuntimeMessage::ExecuteRoute { - request, - addr, - respond_to, - } => { - // A disconnected client must not stop the runtime. - let _ = respond_to.send(self.execute_route(request, addr)); - } + } + WXRuntimeMessage::ExecuteRoute { + request, + addr, + respond_to, + } => { + // A disconnected client must not stop the runtime. + let _ = respond_to.send(self.execute_route(request, addr)); } } } @@ -740,7 +659,11 @@ impl WXRuntime { err.code, self.mode.date_specifier(), ); - responses::internal_server_error_default_webx(self.mode, err.message) + if err.code == 501 { + responses::not_implemented(self.mode, err.message) + } else { + responses::internal_server_error_default_webx(self.mode, err.message) + } } }; if self.mode.debug_level().is_max() { diff --git a/src/engine/test.rs b/src/engine/test.rs index 9fb221b..dae46d5 100644 --- a/src/engine/test.rs +++ b/src/engine/test.rs @@ -133,6 +133,10 @@ impl TestServer { } async fn request(addr: SocketAddr, method: &str, path: &str) -> TestResponse { + request_body(addr, method, path, "").await +} + +async fn request_body(addr: SocketAddr, method: &str, path: &str, body: &str) -> TestResponse { tokio::time::timeout(Duration::from_secs(5), async { let stream = tokio::net::TcpStream::connect(addr).await.unwrap(); let (mut client, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream)) @@ -146,7 +150,7 @@ async fn request(addr: SocketAddr, method: &str, path: &str) -> TestResponse { .uri(path) .header("Host", "localhost") .header("Connection", "close") - .body(Full::new(Bytes::new())) + .body(Full::new(Bytes::copy_from_slice(body.as_bytes()))) .unwrap(), ) .await @@ -346,6 +350,7 @@ async fn reload_adds_removes_and_renames_modules_and_keeps_last_good_snapshot() assert_eq!(server.request("GET", "/").await.body, "7"); for bad in [ "get / (unfinished", + "post / json(title: String,", "get / (a)\nget / (b)\n", "global { this is invalid JS; }\nget / (broken)\n", ] { @@ -400,3 +405,219 @@ async fn production_does_not_reload_modules_but_static_files_are_read_each_time( assert_eq!(server.request("GET", "/").await.body, "before"); assert_eq!(server.request("GET", "/file").await.body, "after"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_requests_isolate_parameters_outputs_and_failures() { + let source = r#" +global { + let calls = 0; + function echo(value) { return value; } + function pack(token, before, middle, rendered) { return { token, before, middle, rendered, call: ++calls }; } + function fail(value) { throw new Error(value); } +} +get /work/(token: String) -> echo(token): before, echo(before): middle (body) -> echo(out): rendered, pack(token, before, middle, rendered) +get /fail/(token: String) -> echo(token): before, fail(before) (must not run) +get /probe -> echo([typeof token, typeof before, typeof middle, typeof out, typeof rendered]) +"#; + for mode in [WXMode::Dev(DebugLevel::Low), WXMode::Prod] { + let server = TestServer::start_mode(&[("index.wx", source)], mode); + let barrier = Arc::new(tokio::sync::Barrier::new(33)); + let mut requests = tokio::task::JoinSet::new(); + for i in 0..32 { + let barrier = barrier.clone(); + let addr = server.addr; + requests.spawn(async move { + barrier.wait().await; + let token = format!("request{i}"); + let response = request(addr, "GET", &format!("/work/{token}")).await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.headers["content-type"], "application/json"); + let json: serde_json::Value = serde_json::from_str(&response.body).unwrap(); + for name in ["token", "before", "middle"] { + assert_eq!(json[name], token); + } + assert_eq!(json["rendered"], "body"); + let failed = request(addr, "GET", &format!("/fail/{token}")).await; + assert_eq!(failed.status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(failed.body.contains(&token)); + json["call"].as_u64().unwrap() + }); + } + barrier.wait().await; + let mut calls = Vec::new(); + while let Some(result) = requests.join_next().await { + calls.push(result.unwrap()); + } + calls.sort(); + // Explicit module state is shared and serialized; request bindings are not. + assert_eq!(calls, (1..=32).collect::>()); + let probe = server.request("GET", "/probe").await; + assert_eq!( + probe.body, + r#"["undefined","undefined","undefined","undefined","undefined"]"# + ); + } +} + +#[tokio::test] +async fn handler_arguments_run_once_and_failed_or_unsupported_work_does_not_crash() { + let source = r#" +global { + let count = 0; + function echo(value) { return value; } + function bad() { const value = {}; value.self = value; return value; } + function nothing() {} + function later() { return Promise.resolve(1); } +} +get /once -> echo(++count) +get /bad -> bad() +get /nothing -> nothing() +get /async -> later() +get /code { return 1; } +post /body json(title: String) -> echo(title) +get /post (body) -> echo(out): first, echo(first): second, echo(second) +get /raw/(value: Int) -> echo(value) +post /ignored -> echo(typeof title) +"#; + let server = TestServer::start(&[("index.wx", source)]); + assert_eq!(server.request("GET", "/once").await.body, "1"); + assert_eq!(server.request("GET", "/once").await.body, "2"); + assert_eq!( + server.request("GET", "/bad").await.status, + StatusCode::INTERNAL_SERVER_ERROR + ); + assert_eq!(server.request("GET", "/nothing").await.body, "null"); + for path in ["/async", "/code"] { + assert_eq!( + server.request("GET", path).await.status, + StatusCode::NOT_IMPLEMENTED + ); + } + assert_eq!( + server.request("POST", "/body").await.status, + StatusCode::NOT_IMPLEMENTED + ); + let unsupported = request_body(server.addr, "POST", "/body", r#"{"title":"body value"}"#).await; + assert_eq!(unsupported.status, StatusCode::NOT_IMPLEMENTED); + let ignored = request_body(server.addr, "POST", "/ignored", r#"{"title":"body value"}"#).await; + assert_eq!(ignored.status, StatusCode::OK); + assert_eq!(ignored.body, "undefined"); + assert_eq!(server.request("GET", "/post").await.body, "body"); + assert_eq!( + server + .request("GET", "/raw/hello%20world?unused=true") + .await + .body, + "hello%20world" + ); + assert_eq!(server.request("GET", "/once").await.body, "3"); +} + +fn todo_server() -> TestServer { + TestServer::start(&[ + ("index.wx", include_str!("../../examples/todo/index.wx")), + ("index.html", include_str!("../../examples/todo/index.html")), + ]) +} + +async fn todo_fragment(server: &TestServer, method: &str, path: &str) -> String { + let response = server.request(method, path).await; + assert_eq!(response.status, StatusCode::OK, "{}", response.body); + assert_eq!(response.headers["content-type"], "text/html; charset=utf-8"); + assert!(response.body.starts_with(r#"
    "#)); + assert!(response.body.contains(r#"role="status""#)); + response.body +} + +#[tokio::test] +async fn todo_example_completes_a_crud_lifecycle() { + let server = todo_server(); + let page = server.request("GET", "/").await; + assert_eq!(page.status, StatusCode::OK); + assert!(page.body.contains("htmx.org@2.0.8")); + assert!(page.body.contains(r#"hx-get="/todos" hx-trigger="load""#)); + assert!(page + .body + .contains(r##"hx-target="#todo-panel" hx-swap="outerHTML""##)); + + let empty = todo_fragment(&server, "GET", "/todos").await; + assert!(empty.contains("No todos yet.")); + assert!(empty.contains(r#"hx-post="/todos" hx-params="none""#)); + + let created = todo_fragment(&server, "POST", "/todos/First%20task").await; + assert!(created.contains("Todo added")); + assert!(created.contains("First task")); + assert!(created.contains(r#"hx-get="/todos/1""#)); + assert!(created.contains(r#"hx-delete="/todos/1""#)); + assert!(!created.contains("No todos yet.")); + + let edit = todo_fragment(&server, "GET", "/todos/1").await; + assert!(edit.contains(r#"hx-put="/todos/1" hx-params="none""#)); + assert!(edit.contains(r#"value="First task""#)); + assert!(!edit.contains("checked")); + + let updated = todo_fragment(&server, "PUT", "/todos/1/Edited%2Ftask/true").await; + assert!(updated.contains("Todo updated")); + assert!(updated.contains("Edited/task")); + assert!(updated.contains(">Completed")); + assert!(!updated.contains("First task")); + let listed = todo_fragment(&server, "GET", "/todos").await; + assert!(listed.contains("Edited/task")); + assert!(todo_fragment(&server, "GET", "/todos/1") + .await + .contains("checked")); + let unchecked = todo_fragment(&server, "PUT", "/todos/1/Edited%2Ftask/false").await; + assert!(unchecked.contains(">Pending")); + + let deleted = todo_fragment(&server, "DELETE", "/todos/1").await; + assert!(deleted.contains("Todo deleted")); + assert!(deleted.contains("No todos yet.")); + assert!(!deleted.contains("Edited/task")); + assert!(todo_fragment(&server, "GET", "/todos/1") + .await + .contains("Todo not found")); + assert!(todo_fragment(&server, "GET", "/todos") + .await + .contains("No todos yet.")); +} + +#[tokio::test] +async fn todo_example_escapes_titles_and_rejects_invalid_changes() { + let server = todo_server(); + let escaped = "<img src=x onerror=alert(1)>&"' / 🌍"; + let created = todo_fragment( + &server, + "POST", + "/todos/%3Cimg%20src%3Dx%20onerror%3Dalert(1)%3E%26%22%27%20%2F%20%F0%9F%8C%8D", + ) + .await; + assert!(created.contains(escaped)); + assert!(!created.contains("Pending")); + } + let long_title = format!("/todos/{}", "x".repeat(121)); + assert!(todo_fragment(&server, "POST", &long_title) + .await + .contains("Title must contain 1-120 characters")); +} diff --git a/src/runner.rs b/src/runner.rs index 5a1e2d8..01f7a09 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -9,7 +9,7 @@ use std::time::Instant; use crate::analysis::{dependencies::analyze_module_deps, routes::analyze_module_routes}; use crate::engine::filewatcher::WXFileWatcher; -use crate::engine::runtime::{WXRuntime, WXRuntimeInfo}; +use crate::engine::runtime::{WXRuntime, WXRuntimeInfo, WXRuntimeMessage}; use crate::engine::server::WXServer; use crate::file::project::{load_modules, load_project_config, ProjectConfig}; use crate::file::webx::WXModule; @@ -233,6 +233,7 @@ pub fn run(root: &Path, mode: WXMode, running: Arc) { let sv_rt_tx = rt_tx.clone(); let mut server = WXServer::new(mode, config, sv_rt_tx); server.run(running).expect("Failed to run server"); + let _ = rt_tx.send(WXRuntimeMessage::Shutdown); if runtime_hnd.join().is_err() { warning(mode, "Failed to stop runtime".into()); } @@ -250,6 +251,7 @@ pub fn run(root: &Path, mode: WXMode, running: Arc) { let sv_rt_tx = rt_tx.clone(); let mut server = WXServer::new(mode, config, sv_rt_tx); server.run(running).expect("Failed to run server"); + let _ = rt_tx.send(WXRuntimeMessage::Shutdown); if runtime_hnd.join().is_err() { warning(mode, "Failed to stop runtime".into()) }