diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 545b762e4397..e374862dfcc0 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -658,6 +658,14 @@ struct TemplatedApp { return std::move(*this); } + /* While on, a route is registered with its pattern as a literal path. See HttpRouter::setLiteral. */ + TemplatedApp &&literalRoutes(bool on) { + if (httpContext) { + httpContext->getSocketContextData()->router.setLiteral(on); + } + return std::move(*this); + } + TemplatedApp &&get(std::string_view pattern, MoveOnlyFunction *, HttpRequest *)> &&handler) { if (httpContext) { httpContext->onHttp("GET", pattern, std::move(handler)); diff --git a/packages/bun-uws/src/Http2App.h b/packages/bun-uws/src/Http2App.h index 40ab5dda54e1..2e4779a8d458 100644 --- a/packages/bun-uws/src/Http2App.h +++ b/packages/bun-uws/src/Http2App.h @@ -50,6 +50,8 @@ struct H2App { bool drain() { return http2Context->drain(); } void clearRoutes() { http2Context->clearRoutes(); } + /* While on, a route is registered with its pattern as a literal path. See HttpRouter::setLiteral. */ + void literalRoutes(bool on) { http2Context->router.setLiteral(on); } /* GOAWAY + close every connection. */ void close() { http2Context->closeAll(); } void *getNativeHandle() { return http2Context; } diff --git a/packages/bun-uws/src/Http3App.h b/packages/bun-uws/src/Http3App.h index bde4a4828e01..6b621bd1fd68 100644 --- a/packages/bun-uws/src/Http3App.h +++ b/packages/bun-uws/src/Http3App.h @@ -53,6 +53,8 @@ struct H3App { void clearRoutes() { http3Context->getContextData()->router = decltype(http3Context->getContextData()->router){}; } + /* While on, a route is registered with its pattern as a literal path. See HttpRouter::setLiteral. */ + void literalRoutes(bool on) { http3Context->getContextData()->router.setLiteral(on); } /* GOAWAY + drain. The engine itself is torn down in the destructor. */ void close() { http3Context->shutdown(); } bool addServerNameWithOptions(const char *hostname, SocketContextOptions options) { diff --git a/packages/bun-uws/src/HttpRouter.h b/packages/bun-uws/src/HttpRouter.h index 8a48cd6b8ea7..e4c899486687 100644 --- a/packages/bun-uws/src/HttpRouter.h +++ b/packages/bun-uws/src/HttpRouter.h @@ -44,6 +44,9 @@ struct HttpRouter { /* Handler ids are 32-bit */ static constexpr uint32_t HANDLER_MASK = 0x0fffffff; + /* Name of the child of a method node that holds the literal keys: executeHandlers runs it as a wildcard, and no pattern segment has a '/' */ + static constexpr std::string_view LITERAL_TOKEN = "*/"; + /* List of handlers */ std::vector> handlers; @@ -52,6 +55,9 @@ struct HttpRouter { std::string_view urlSegmentVector[MAX_URL_SEGMENTS] = {}; int urlSegmentTop = -1; + /* See setLiteral() */ + bool literalKeys = false; + /* The matching tree */ struct Node { std::string name = {}; @@ -71,7 +77,8 @@ struct HttpRouter { return 1; } if (name[0] == '*') { - return 0; + /* The literal container goes after every other high priority child (the WebSocket upgrades) */ + return name == LITERAL_TOKEN ? -1 : 0; } return 2; } @@ -204,12 +211,74 @@ struct HttpRouter { return false; } + /* Whether a pattern cannot hold this key byte for byte. No call of getUrlSegment: one more caller stops its inlining into executeHandlers */ + bool needsLiteralNode(std::string_view key) { + unsigned int segments = 0; + for (; !key.empty() && segments < MAX_URL_SEGMENTS; segments++) { + key.remove_prefix(1); + std::string_view segment = key.substr(0, key.find('/')); + if (lexicalOrder(segment) < 2) { + return true; + } + key.remove_prefix(segment.length()); + } + /* A URL is read up to this many segments, so a pattern of this many also matches a longer URL */ + return segments == MAX_URL_SEGMENTS; + } + + /* The node of a literal key: a child of the LITERAL_TOKEN container, named by the whole key */ + Node *literalNode(Node *methodNode, std::string_view key, bool create) { + Node *container = nullptr; + for (const std::unique_ptr &node : methodNode->children) { + if (node->name == LITERAL_TOKEN) { + container = node.get(); + break; + } + } + if (!container) { + if (!create) { + return nullptr; + } + container = getNode(methodNode, LITERAL_TOKEN, true); + container->handlers.push_back(HIGH_PRIORITY | uint32_t(handlers.size())); + handlers.emplace_back([container](HttpRouter *router) { + /* Segment 0 is parsed: the URL starts one byte before it and ends where currentUrl ends */ + const char *begin = router->urlSegmentVector[0].data() - 1; + std::string_view url(begin, size_t(router->currentUrl.data() + router->currentUrl.length() - begin)); + for (const std::unique_ptr &node : container->children) { + if (node->name == url) { + for (uint32_t handler : node->handlers) { + if (router->handlers[handler & HANDLER_MASK](router)) { + return true; + } + } + break; + } + } + return false; + }); + } + if (create) { + return getNode(container, key, false); + } + for (const std::unique_ptr &node : container->children) { + if (node->name == key) { + return node.get(); + } + } + return nullptr; + } + /* Scans for one matching handler, returning the handler and its priority or UINT32_MAX for not found */ - uint32_t findHandler(std::string_view method, std::string_view pattern, uint32_t priority) { + uint32_t findHandler(std::string_view method, std::string_view pattern, uint32_t priority, bool literal) { for (const std::unique_ptr &node : root.children) { if (method == node->name) { - setUrl(pattern); - Node *n = node.get(); + /* A literal key is one node and has no segments to walk */ + setUrl(literal ? std::string_view() : pattern); + Node *n = literal ? literalNode(node.get(), pattern, false) : node.get(); + if (!n) { + return UINT32_MAX; + } for (int i = 0; !getUrlSegment(i).second; i++) { /* Go to next segment or quit */ std::string segment(getUrlSegment(i).first); @@ -251,6 +320,11 @@ struct HttpRouter { return userData; } + /* While on, add() and remove() take their pattern as a literal path: ':' and '*' do not start a parameter or a wildcard */ + void setLiteral(bool on) { + literalKeys = on; + } + /* Fast path */ bool route(std::string_view method, std::string_view url) { /* Reset url parsing cache */ @@ -278,14 +352,27 @@ struct HttpRouter { /* Adds the corresponding entires in matching tree and handler list */ void add(std::span methods, std::string_view pattern, MoveOnlyFunction &&handler, uint32_t priority = MEDIUM_PRIORITY) { + /* Most literal keys are byte-exact as a pattern already */ + const bool literal = literalKeys && needsLiteralNode(pattern); + /* First remove existing handler */ remove(methods[0], pattern, priority); + if (literal) { + /* A new container adds a handler of its own. Add them first: the id below has to be the same for every method. */ + for (const std::string_view method : methods) { + literalNode(getNode(&root, method, false), pattern, true); + } + } + for (const std::string_view method : methods) { /* Lookup method */ Node *node = getNode(&root, method, false); + if (literal) { + node = literalNode(node, pattern, true); + } /* Iterate over all segments */ - setUrl(pattern); + setUrl(literal ? std::string_view() : pattern); for (int i = 0; !getUrlSegment(i).second; i++) { std::string strippedSegment(getUrlSegment(i).first); if (strippedSegment.length() > 1 && strippedSegment[0] == ':') { @@ -359,7 +446,7 @@ struct HttpRouter { * Removing a wildcard is done by removing ONE OF the methods the wildcard would match with. * Example: If wildcard includes POST, GET, PUT, you can remove ALL THREE by removing GET. */ bool remove(std::string_view method, std::string_view pattern, uint32_t priority) { - uint32_t handler = findHandler(method, pattern, priority); + uint32_t handler = findHandler(method, pattern, priority, literalKeys && needsLiteralNode(pattern)); if (handler == UINT32_MAX) { /* Not found or already removed, do nothing */ return false; diff --git a/src/runtime/server/HTMLBundle.rs b/src/runtime/server/HTMLBundle.rs index fd39b91d699f..91244fbf6d86 100644 --- a/src/runtime/server/HTMLBundle.rs +++ b/src/runtime/server/HTMLBundle.rs @@ -581,21 +581,21 @@ impl Route { continue; } - bun_core::handle_oom(server.append_static_route( + server.append_bundled_file_route( route_path, AnyRoute::Static(RefPtr::new(static_route)), MethodOptional::Any, - )); + ); } let (mut html_route, html_route_path) = this_html_route.expect("the loop above visited html_index"); let html_route_clone = html_route.clone(global_this); - bun_core::handle_oom(server.append_static_route( + server.append_bundled_file_route( &html_route_path, AnyRoute::Static(RefPtr::new(html_route)), MethodOptional::Any, - )); + ); self.state.set(State::Html(html_route_clone)); if !bun_core::handle_oom(server.reload_static_routes()) { diff --git a/src/runtime/server/ServerConfig.rs b/src/runtime/server/ServerConfig.rs index 27575cf6cd4e..97c7ec7a191d 100644 --- a/src/runtime/server/ServerConfig.rs +++ b/src/runtime/server/ServerConfig.rs @@ -208,9 +208,34 @@ impl Default for RouteDeclaration { } } +/// The path of a static route, and how the router reads it. +pub(crate) enum RouteKey { + /// A key of `routes`: a segment that starts with `:` or `*` is a parameter or a wildcard. + Pattern(Box<[u8]>), + /// The URL path of a file the bundler wrote. The route answers at exactly these bytes. + Literal(Box<[u8]>), +} + +impl RouteKey { + /// Whether this is the pattern `pattern`. A file of that name is not. + pub(crate) fn is_pattern(&self, pattern: &[u8]) -> bool { + matches!(self, RouteKey::Pattern(bytes) if **bytes == *pattern) + } +} + +impl core::ops::Deref for RouteKey { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + match self { + RouteKey::Pattern(bytes) | RouteKey::Literal(bytes) => bytes, + } + } +} + // TODO: rename to StaticRoute.Entry pub(crate) struct StaticRouteEntry { - pub path: Box<[u8]>, + pub path: RouteKey, pub(crate) route: AnyRoute, pub method: MethodOptional, } @@ -219,6 +244,20 @@ impl StaticRouteEntry { fn memory_cost(&self) -> usize { self.path.len() + self.route.memory_cost() } + + /// Adds the route of a file the bundler wrote. It answers at `path`, byte for byte. + pub(crate) fn push_bundled_file( + routes: &mut Vec, + path: &[u8], + route: AnyRoute, + method: MethodOptional, + ) { + routes.push(StaticRouteEntry { + path: RouteKey::Literal(Box::from(path)), + route, + method, + }); + } } impl ServerConfig { @@ -233,6 +272,11 @@ impl ServerConfig { } } } + // A file named like a pattern is not that pattern. + hasher.update(match &route.path { + RouteKey::Pattern(_) => b"pattern", + RouteKey::Literal(_) => b"literal", + }); hasher.update(&route.path); hasher.final_() } @@ -313,27 +357,13 @@ impl ServerConfig { Ok(that) } - - pub(crate) fn append_static_route( - &mut self, - path: &[u8], - route: AnyRoute, - method: MethodOptional, - ) -> Result<(), crate::Error> { - self.static_routes.push(StaticRouteEntry { - path: Box::<[u8]>::from(path), - route, - method, - }); - Ok(()) - } } -pub(crate) fn apply_static_route( +pub(crate) fn apply_static_route( server: AnyServer, - app: &mut uws::NewApp, + app: &mut A, entry: ThisPtr, - path: &[u8], + path: &RouteKey, method: http_method::Optional, path_has_user_head_route: bool, ) { @@ -342,19 +372,24 @@ pub(crate) fn apply_static_route( // Only answer HEAD from an entry that serves GET (HEAD must mirror GET, // RFC 9110 section 9.3.2) or HEAD itself, and never displace an explicit HEAD // handler route: uWS keeps the last registration for the same method and path. - if !path_has_user_head_route && serves_head(&method) { - app.method_this(Method::HEAD, path, T::on_head_request, entry); - } - match method { - http_method::Optional::Any => { - app.any_this(path, T::on_request, entry); + let head = !path_has_user_head_route && serves_head(&method); + let register = |app: &mut A| { + if head { + app.method_this(Method::HEAD, path, T::on_head_request, entry); } - http_method::Optional::Method(m) => { - let mut iter = m.iter(); - while let Some(method_) = iter.next() { - app.method_this(method_, path, T::on_request, entry); + match method { + http_method::Optional::Any => app.any_this(path, T::on_request, entry), + http_method::Optional::Method(m) => { + let mut iter = m.iter(); + while let Some(method_) = iter.next() { + app.method_this(method_, path, T::on_request, entry); + } } } + }; + match path { + RouteKey::Pattern(_) => register(app), + RouteKey::Literal(_) => app.literal_routes(register), } } @@ -368,20 +403,20 @@ fn serves_head(method: &http_method::Optional) -> bool { } } -/// The route-registration surface shared by `uws::h2::App` and -/// `uws::h3::App`; both hand the handler type-erased `AnyRequest`/`AnyResponse`. -pub(crate) trait MuxApp { +/// The route-registration surface shared by `uws::NewApp`, `uws::h2::App` and `uws::h3::App`. +pub(crate) trait RouteApp { fn method_this(&mut self, m: Method, p: &[u8], h: H, this: ThisPtr) where H: Fn(ThisPtr, uws::AnyRequest, uws::AnyResponse) + Copy + 'static; fn any_this(&mut self, p: &[u8], h: H, this: ThisPtr) where H: Fn(ThisPtr, uws::AnyRequest, uws::AnyResponse) + Copy + 'static; + fn literal_routes(&mut self, register: impl FnOnce(&mut Self)); } -macro_rules! impl_mux_app { - ($app:ty) => { - impl MuxApp for $app { +macro_rules! impl_route_app { + ($app:ty $(, $($generics:tt)*)?) => { + impl$(<$($generics)*>)? RouteApp for $app { #[inline] fn method_this(&mut self, m: Method, p: &[u8], h: H, this: ThisPtr) where @@ -396,37 +431,18 @@ macro_rules! impl_mux_app { { <$app>::any_this(self, p, h, this) } - } - }; -} -impl_mux_app!(uws::h2::App); -impl_mux_app!(uws::h3::App); - -pub(crate) fn apply_static_route_mux( - server: AnyServer, - app: &mut A, - entry: ThisPtr, - path: &[u8], - method: http_method::Optional, - path_has_user_head_route: bool, -) { - entry.set_server(server); - - if !path_has_user_head_route && serves_head(&method) { - app.method_this(Method::HEAD, path, T::on_head_request, entry); - } - match method { - http_method::Optional::Any => app.any_this(path, T::on_request, entry), - http_method::Optional::Method(m) => { - let mut iter = m.iter(); - while let Some(method_) = iter.next() { - app.method_this(method_, path, T::on_request, entry); + #[inline] + fn literal_routes(&mut self, register: impl FnOnce(&mut Self)) { + <$app>::literal_routes(self, register) } } - } + }; } +impl_route_app!(uws::NewApp, const SSL: bool); +impl_route_app!(uws::h2::App); +impl_route_app!(uws::h3::App); -/// Per-route trait that `apply_static_route{,_mux}` monomorphizes over +/// Per-route trait that `apply_static_route` monomorphizes over /// (`StaticRoute`/`FileRoute`/`DirectoryRoute`/`HTMLBundle.Route`). The route /// is the uWS route userdata; the route table holds a ref on it for as long as /// it is registered. @@ -821,7 +837,7 @@ impl ServerConfig { method_set.insert(method); init_ctx.user_routes.push(StaticRouteEntry { - path: Box::<[u8]>::from(&*path), + path: RouteKey::Pattern(Box::<[u8]>::from(&*path)), route: html_route, method: http_method::Optional::Method(method_set), }); @@ -877,7 +893,7 @@ impl ServerConfig { ))); }; init_ctx.user_routes.push(StaticRouteEntry { - path, + path: RouteKey::Pattern(path), route, method: http_method::Optional::Any, }); diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 90c6f0f3b2f5..9183f1e30e8c 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -112,12 +112,12 @@ pub(crate) mod any_request_context; pub(crate) use any_request_context::AnyRequestContext; /// Run `$body` once for each attached multiplexed app (`h3_app`, then -/// `h2_app`) with `$mux: &mut impl server_config::MuxApp`. A macro rather than +/// `h2_app`) with `$mux: &mut impl server_config::RouteApp`. A macro rather than /// a closure because the two apps are different types. macro_rules! for_each_mux_app { ($self:expr, |$mux:ident| $body:block) => {{ #[allow(unused_imports)] - use server_config::MuxApp as _; + use server_config::RouteApp as _; if Self::HAS_H3 { if let Some(app) = $self.h3_app { // S008: `h3::App` is an `opaque_ffi!` ZST — safe deref. @@ -2466,7 +2466,7 @@ impl NewServer { let mut has_static_route_for_star_path = false; for entry in &self.config.static_routes { - if &*entry.path == b"/*" { + if entry.path.is_pattern(b"/*") { has_static_route_for_star_path = true; match &entry.method { server_config::MethodOptional::Any => { @@ -2502,7 +2502,7 @@ impl NewServer { // `app`/`h2_app`/`h3_app` are the live uWS app handles owned by `self`. match &entry.route { AnyRoute::Static(r) => { - server_config::apply_static_route::( + server_config::apply_static_route::( any_server, app, r.this_ptr(), @@ -2511,7 +2511,7 @@ impl NewServer { path_has_user_head_route, ); for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( + server_config::apply_static_route::( any_server, mux, r.this_ptr(), @@ -2522,7 +2522,7 @@ impl NewServer { }); } AnyRoute::File(r) => { - server_config::apply_static_route::( + server_config::apply_static_route::( any_server, app, r.this_ptr(), @@ -2531,7 +2531,7 @@ impl NewServer { path_has_user_head_route, ); for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( + server_config::apply_static_route::( any_server, mux, r.this_ptr(), @@ -2542,7 +2542,7 @@ impl NewServer { }); } AnyRoute::Directory(r) => { - server_config::apply_static_route::( + server_config::apply_static_route::( any_server, app, r.this_ptr(), @@ -2551,7 +2551,7 @@ impl NewServer { path_has_user_head_route, ); for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( + server_config::apply_static_route::( any_server, mux, r.this_ptr(), @@ -2562,7 +2562,7 @@ impl NewServer { }); } AnyRoute::Html(r) => { - server_config::apply_static_route::( + server_config::apply_static_route::( any_server, app, r.this_ptr(), @@ -2571,7 +2571,7 @@ impl NewServer { path_has_user_head_route, ); for_each_mux_app!(self, |mux| { - server_config::apply_static_route_mux::( + server_config::apply_static_route::( any_server, mux, r.this_ptr(), @@ -4202,13 +4202,20 @@ impl AnyServer { any_server_dispatch_mut!(self, |s| s.get_or_load_plugins(callback)) } - pub(crate) fn append_static_route( + pub(crate) fn append_bundled_file_route( &self, path: &[u8], route: AnyRoute, method: server_config::MethodOptional, - ) -> Result<(), crate::Error> { - any_server_dispatch_mut!(self, |s| s.config.append_static_route(path, route, method)) + ) { + any_server_dispatch_mut!(self, |s| { + server_config::StaticRouteEntry::push_bundled_file( + &mut s.config.static_routes, + path, + route, + method, + ) + }) } pub(crate) fn reload_static_routes(&self) -> Result { diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index 81a6905cdffb..a731a847b6c6 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -652,11 +652,12 @@ impl AnyRoute { methods.insert(Method::GET); methods.insert(Method::HEAD); - init_ctx.user_routes.push(server_config::StaticRouteEntry { - path: builder.into_boxed_slice(), + server_config::StaticRouteEntry::push_bundled_file( + init_ctx.user_routes, + &builder, route, - method: methods, - }); + methods, + ); Ok(None) } diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 5a130245d53f..6deb13b72df0 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -154,6 +154,13 @@ impl App { c::uws_app_clear_routes(Self::SSL_FLAG, self.as_raw()) } + /// Runs `register` with literal route keys: `:` and `*` do not start a parameter or a wildcard. + pub fn literal_routes(&mut self, register: impl FnOnce(&mut Self)) { + c::uws_app_literal_routes(Self::SSL_FLAG, self.as_raw(), true); + register(self); + c::uws_app_literal_routes(Self::SSL_FLAG, self.as_raw(), false); + } + pub(crate) fn publish_with_options( &mut self, topic: &[u8], @@ -672,6 +679,7 @@ pub mod c { ); pub(crate) safe fn uws_app_clear_routes(ssl_flag: c_int, app: &mut uws_app_t); + pub(crate) safe fn uws_app_literal_routes(ssl_flag: c_int, app: &mut uws_app_t, on: bool); } #[repr(C)] diff --git a/src/uws_sys/h2.rs b/src/uws_sys/h2.rs index d0270c3c8d1a..710bef9a4372 100644 --- a/src/uws_sys/h2.rs +++ b/src/uws_sys/h2.rs @@ -384,6 +384,12 @@ impl App { pub fn clear_routes(&mut self) { c::uws_h2_app_clear_routes(self) } + /// See [`crate::app::App::literal_routes`]. + pub fn literal_routes(&mut self, register: impl FnOnce(&mut Self)) { + c::uws_h2_app_literal_routes(self, true); + register(self); + c::uws_h2_app_literal_routes(self, false); + } fn route(which: RouteKind, this: &mut App, pattern: &[u8], ud: *mut UD, _handler: H) where @@ -522,6 +528,7 @@ mod c { ); pub(super) safe fn uws_h2_app_drain(app: &mut App) -> bool; pub(super) safe fn uws_h2_app_clear_routes(app: &mut App); + pub(super) safe fn uws_h2_app_literal_routes(app: &mut App, on: bool); pub(super) safe fn uws_h2_res_write_continue(res: &mut Response); pub(super) fn uws_h2_app_get( app: *mut App, diff --git a/src/uws_sys/h3.rs b/src/uws_sys/h3.rs index caabd299d636..8b32e9aa8b78 100644 --- a/src/uws_sys/h3.rs +++ b/src/uws_sys/h3.rs @@ -421,6 +421,12 @@ impl App { pub fn clear_routes(&mut self) { c::uws_h3_app_clear_routes(self) } + /// See [`crate::app::App::literal_routes`]. + pub fn literal_routes(&mut self, register: impl FnOnce(&mut Self)) { + c::uws_h3_app_literal_routes(self, true); + register(self); + c::uws_h3_app_literal_routes(self, false); + } fn route(which: RouteKind, this: &mut App, pattern: &[u8], ud: *mut UD, _handler: H) where @@ -596,6 +602,7 @@ mod c { pub(super) fn uws_h3_app_destroy(app: *mut App); pub(super) safe fn uws_h3_app_close(app: &mut App); pub(super) safe fn uws_h3_app_clear_routes(app: &mut App); + pub(super) safe fn uws_h3_app_literal_routes(app: &mut App, on: bool); pub(super) fn uws_h3_app_add_server_name( app: *mut App, hostname: *const c_char, diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index 8d1faf19ebaa..61c22df87777 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -145,6 +145,20 @@ extern "C" } } + void uws_app_literal_routes(int ssl, uws_app_t *app, bool on) + { + if (ssl) + { + uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; + uwsApp->literalRoutes(on); + } + else + { + uWS::App *uwsApp = (uWS::App *)app; + uwsApp->literalRoutes(on); + } + } + void uws_app_get(int ssl, uws_app_t *app, const char *pattern_ptr, size_t pattern_len, uws_method_handler handler, void *user_data) { std::string_view pattern = std::string_view(pattern_ptr, pattern_len); diff --git a/src/uws_sys/libuwsockets_h2.cpp b/src/uws_sys/libuwsockets_h2.cpp index 59a9173e956f..7af5d38a736f 100644 --- a/src/uws_sys/libuwsockets_h2.cpp +++ b/src/uws_sys/libuwsockets_h2.cpp @@ -45,6 +45,7 @@ void uws_h2_app_on_schedule_drain(uws_h2_app_t* app, void (*cb)(void*, void*), v bool uws_h2_app_drain(uws_h2_app_t* app) { return ((H2App*)app)->drain(); } void uws_h2_app_close(uws_h2_app_t* app) { ((H2App*)app)->close(); } void uws_h2_app_clear_routes(uws_h2_app_t* app) { ((H2App*)app)->clearRoutes(); } +void uws_h2_app_literal_routes(uws_h2_app_t* app, bool on) { ((H2App*)app)->literalRoutes(on); } #define H2_ROUTE(name, method) \ void uws_h2_app_##name(uws_h2_app_t* app, const char* pattern, size_t pattern_len, \ diff --git a/src/uws_sys/libuwsockets_h3.cpp b/src/uws_sys/libuwsockets_h3.cpp index fa06529014b0..f1b91cb82551 100644 --- a/src/uws_sys/libuwsockets_h3.cpp +++ b/src/uws_sys/libuwsockets_h3.cpp @@ -52,6 +52,7 @@ uws_h3_app_t* uws_h3_create_app(struct us_bun_socket_context_options_t options, void uws_h3_app_destroy(uws_h3_app_t* app) { delete (H3App*)app; } void uws_h3_app_close(uws_h3_app_t* app) { ((H3App*)app)->close(); } void uws_h3_app_clear_routes(uws_h3_app_t* app) { ((H3App*)app)->clearRoutes(); } +void uws_h3_app_literal_routes(uws_h3_app_t* app, bool on) { ((H3App*)app)->literalRoutes(on); } bool uws_h3_app_add_server_name(uws_h3_app_t* app, const char* hostname, struct us_bun_socket_context_options_t options) diff --git a/test/bundler/bundler_html_server.test.ts b/test/bundler/bundler_html_server.test.ts index 83a918b1111f..bd17e8cb3364 100644 --- a/test/bundler/bundler_html_server.test.ts +++ b/test/bundler/bundler_html_server.test.ts @@ -1,4 +1,5 @@ import { describe } from "bun:test"; +import { isWindows } from "harness"; import { itBundled } from "./expectBundled"; describe.concurrent("bundler", () => { @@ -53,6 +54,58 @@ describe.concurrent("bundler", () => { }, }); + // The compiled server registers each embedded file under its output path. + // That path is a file name: a leading ":" or "*" does not make it a route + // parameter or a wildcard, so the file answers only its own path. Windows + // cannot have the source files of this fixture on disk. + (isWindows ? itBundled.skip : itBundled)(`compile/${backend}/HTMLServerAssetNamesThatReadAsRoutePatterns`, { + compile: true, + backend: backend, + files: { + "/entry.ts": /* js */ ` + import index from "./index.html"; + + using server = Bun.serve({ + port: 0, + routes: { + "/": index, + }, + fetch: () => new Response("no route", { status: 404 }), + }); + + const html = await (await fetch(server.url)).text(); + const paths = [...html.matchAll(/ match[1]); + for (const path of [...paths, "/about", "/favicon.ico", "/a/b"]) { + const res = await fetch(new URL(path, server.url)); + console.log(path.replace(/-[a-z0-9]+\\.png$/, ".png"), res.status, await res.text()); + } + `, + "/index.html": /* html */ ` + + + + + + + + + `, + "/:smile:.png": "colon", + "/*.png": "star", + "/plain.png": "plain", + }, + run: { + stdout: [ + "/:smile:.png 200 colon", + "/*.png 200 star", + "/plain.png 200 plain", + "/about 404 no route", + "/favicon.ico 404 no route", + "/a/b 404 no route", + ].join("\n"), + }, + }); + itBundled(`compile/${backend}/HTMLServerMultipleRoutes`, { compile: true, backend: backend, diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts index b9a878210821..39082994ce63 100644 --- a/test/js/bun/http/bun-serve-html-manifest.test.ts +++ b/test/js/bun/http/bun-serve-html-manifest.test.ts @@ -389,6 +389,192 @@ describe("Bun.serve HTML manifest", () => { `); }); + // The built server registers each file of the manifest as a route under the + // file's output path. That path is a file name, not a route pattern: a + // segment that starts with ":" is not a parameter and one that starts with + // "*" is not a wildcard. Windows cannot have these files on disk. + describe.skipIf(isWindows)("file whose output path reads as a route pattern", () => { + /** Builds `server.ts` with `--target bun` and runs the built server to its end. */ + async function buildAndRun(dir: string, ...buildOptions: string[]) { + await using buildProc = Bun.spawn({ + cmd: [bunExe(), "build", "server.ts", "--outdir", "dist", "--target", "bun", ...buildOptions], + cwd: dir, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [, buildStderr, buildExitCode] = await Promise.all([ + buildProc.stdout.text(), + buildProc.stderr.text(), + buildProc.exited, + ]); + expect({ buildStderr, buildExitCode }).toEqual({ buildStderr: "", buildExitCode: 0 }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "server.js"], + cwd: join(dir, "dist"), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout === "" ? stderr : JSON.parse(stdout), exitCode }; + } + + // The server asks for each image of the page and then for `others`, and + // prints [method, path, status, body] for each request. + const server = (others: string) => ` + import { unlinkSync } from "node:fs"; + import { join } from "node:path"; + import index from "./index.html"; + + using server = Bun.serve({ + port: 0, + routes: { + "/": index, + }, + fetch: () => new Response("no route", { status: 404 }), + }); + + const html = await (await fetch(server.url)).text(); + const images = [...html.matchAll(/ match[1]); + const seen = []; + async function request(method, path) { + const res = await fetch(new URL(path, server.url), { method }); + seen.push([method, path.replace(/-[a-z0-9]+\\.png$/, ".png"), res.status, await res.text()]); + } + for (const image of images) await request("GET", image); + ${others} + console.log(JSON.stringify(seen)); + `; + + it.concurrent("serves a file named like a parameter or a wildcard only at its own path", async () => { + using dir = tempDir("serve-html-pattern-names", { + "server.ts": server(` + for (const path of ["/about", "/favicon.ico", "/a/b"]) await request("GET", path); + await request("HEAD", images[0]); + await request("HEAD", "/about"); + // A file that is gone from disk gives its path to the next route. + unlinkSync(join(import.meta.dir, images[0])); + await request("GET", images[0]); + await request("GET", images[1]); + `), + "index.html": ``, + ":smile:.png": "colon", + "*.png": "star", + "plain.png": "plain", + }); + + expect(await buildAndRun(String(dir))).toEqual({ + stdout: [ + ["GET", "./:smile:.png", 200, "colon"], + ["GET", "./*.png", 200, "star"], + ["GET", "./plain.png", 200, "plain"], + ["GET", "/about", 404, "no route"], + ["GET", "/favicon.ico", 404, "no route"], + ["GET", "/a/b", 404, "no route"], + ["HEAD", "./:smile:.png", 200, ""], + ["HEAD", "/about", 404, ""], + ["GET", "./:smile:.png", 404, "no route"], + ["GET", "./*.png", 200, "star"], + ], + exitCode: 0, + }); + }); + + // `[dir]` in the asset name keeps the directory of the source file. The + // router reads 100 segments of a pattern and of a URL, so a path of 100 + // segments or more also answered for every longer path that starts with it. + it.concurrent("serves a file under a directory named like a parameter or a wildcard", async () => { + // 101 directories, and 99 of them: a path of 102 segments and one of 100. + const deep = Buffer.alloc(202, "d/").toString(); + const hundred = deep.slice(0, 198) + "f.png"; + using dir = tempDir("serve-html-pattern-directories", { + "server.ts": server(` + const deep = ${JSON.stringify("/" + deep)}; + for (const path of [ + "/other/b.png", + "/:v", + "/:v/", + "/:v/other.png", + "/:v/b.png/", + "/other/a.png", + "/*dir/", + "/*dir/other.png", + "/sub/other/c.png", + ]) { + await request("GET", path); + } + await request("HEAD", "/other/b.png"); + seen.push((await fetch(new URL(deep + "e.png", server.url))).status); + seen.push((await fetch(new URL(deep + "other/e.png", server.url))).status); + seen.push((await fetch(new URL(deep.slice(2) + "e.png", server.url))).status); + seen.push((await fetch(new URL(${JSON.stringify("/" + hundred)} + "/below", server.url))).status); + `), + "index.html": ``, + ":v/b.png": "in colon directory", + "*dir/a.png": "in star directory", + "sub/:x/c.png": "below a directory", + [deep + "e.png"]: "deep", + [hundred]: "hundred segments", + }); + + expect(await buildAndRun(String(dir), "--asset-naming", "[dir]/[name].[ext]")).toEqual({ + stdout: [ + ["GET", "./:v/b.png", 200, "in colon directory"], + ["GET", "./*dir/a.png", 200, "in star directory"], + ["GET", "./sub/:x/c.png", 200, "below a directory"], + ["GET", `./${deep}e.png`, 200, "deep"], + ["GET", `./${hundred}`, 200, "hundred segments"], + ["GET", "/other/b.png", 404, "no route"], + ["GET", "/:v", 404, "no route"], + ["GET", "/:v/", 404, "no route"], + ["GET", "/:v/other.png", 404, "no route"], + ["GET", "/:v/b.png/", 404, "no route"], + ["GET", "/other/a.png", 404, "no route"], + ["GET", "/*dir/", 404, "no route"], + ["GET", "/*dir/other.png", 404, "no route"], + ["GET", "/sub/other/c.png", 404, "no route"], + ["HEAD", "/other/b.png", 404, ""], + // The deep file, a path one directory longer, a path one directory shorter, + // and a path below the file of 100 segments. + 200, + 404, + 404, + 404, + ], + exitCode: 0, + }); + }); + + // A file named "*" has the output path "/*". That is the pattern of the + // `fetch` handler, and the file took its place for GET. + it.concurrent("serves a file named * only at its own path", async () => { + using dir = tempDir("serve-html-star-file", { + "server.ts": server(` + for (const path of ["/about", "/a/b", "/*x", "/*/"]) await request("GET", path); + await request("POST", "/about"); + `), + "index.html": ``, + "*": "star file", + }); + + expect(await buildAndRun(String(dir), "--asset-naming", "[name]")).toEqual({ + stdout: [ + ["GET", "./*", 200, "star file"], + ["GET", "/about", 404, "no route"], + ["GET", "/a/b", 404, "no route"], + ["GET", "/*x", 404, "no route"], + ["GET", "/*/", 404, "no route"], + ["POST", "/about", 404, "no route"], + ], + exitCode: 0, + }); + }); + }); + // With --splitting, a module that is only dynamically imported becomes its // own chunk. The assets that only it imports still have to end up in the // manifest, otherwise the page's lazy code requests files the server never diff --git a/test/js/bun/http/bun-serve-html.test.ts b/test/js/bun/http/bun-serve-html.test.ts index 928f19dc0891..bfdc795b8c5f 100644 --- a/test/js/bun/http/bun-serve-html.test.ts +++ b/test/js/bun/http/bun-serve-html.test.ts @@ -1,6 +1,6 @@ import type { Server, Subprocess } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, tempDir, tempDirWithFiles } from "harness"; +import { bunEnv, bunExe, isDebug, isWindows, tempDir, tempDirWithFiles, tls } from "harness"; import { join } from "path"; function replaceHash(html: string) { @@ -1561,6 +1561,392 @@ test("wildcard static routes", async () => { } }); +// A production build registers every file it wrote as a route under the +// file's output path. That path is a file name, not a route pattern: a segment +// that starts with ":" is not a parameter and one that starts with "*" is not +// a wildcard. Read as a pattern, such a file answered paths that are not its +// own, one file answered for another, and it replaced a user route of the same +// shape. +describe("bundled file whose name reads as a route pattern", () => { + // `get` answers [status, body]. The `fetch` handler answers a path that has no route. + const helpers = /*ts*/ ` + const noRoute = () => new Response("no route", { status: 404 }); + async function get(server, path, init) { + const response = await fetch(new URL(path, server.url), init); + return [response.status, await response.text()]; + } + async function head(server, path, init) { + const response = await fetch(new URL(path, server.url), { ...init, method: "HEAD" }); + return [response.status, response.headers.get("content-type"), response.headers.get("content-length")]; + } + // The first request for the page builds it and registers its files. + async function imageSources(server, init) { + const html = await (await fetch(server.url, init)).text(); + return [...html.matchAll(/ match[1]); + } + const withoutHash = path => path.replace(/-[a-z0-9]+\\.png$/, ".png"); + `; + + // A plugin gives the page its images, so that no file with ":" or "*" in its + // name has to be on disk. Windows does not allow such a file. + const virtualImages = (images: Record) => ({ + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "plugin.ts": /*ts*/ ` + const images = ${JSON.stringify(images)}; + export default { + name: "virtual-images", + setup(build) { + build.onResolve({ filter: /^virtual\\// }, args => ({ + path: args.path.slice("virtual/".length), + namespace: "virtual-image", + })); + build.onLoad({ filter: /.*/, namespace: "virtual-image" }, args => ({ + contents: images[args.path], + loader: "file", + })); + }, + }; + `, + "index.html": `${Object.keys(images) + .map(name => ``) + .join("")}`, + }); + + // The same page, with the images as files next to it. + const diskImages = (images: Record) => ({ + ...images, + "index.html": `${Object.keys(images) + .map(name => ``) + .join("")}`, + }); + + // The output names. On Windows the bundler does not have to keep the ":" or "*". + const names = (...expected: string[]) => (isWindows ? expect.any(Array) : expected); + + async function resultOf(dir: { toString(): string }) { + const { stdout, stderr, exitCode } = await runServeFixture(dir); + return { result: stdout === "" ? stderr : JSON.parse(stdout), exitCode }; + } + + test.concurrent("an asset named like a parameter or a wildcard answers only its own path", async () => { + using dir = tempDir("bun-serve-html-pattern-assets", { + // Only a ":" or "*" that starts the name makes it read as a pattern. + ...virtualImages({ + ":smile:.png": "colon", + "*.png": "star", + "plain.png": "plain", + "@:x.png": "colon second", + "a*b.png": "star inside", + }), + "serve.ts": /*ts*/ ` + import page from "./index.html"; + ${helpers} + using server = Bun.serve({ port: 0, development: false, routes: { "/": page }, fetch: noRoute }); + const sources = await imageSources(server); + const [colon, star] = sources; + console.log(JSON.stringify({ + names: sources.map(withoutHash), + images: await Promise.all(sources.map(source => get(server, source))), + headOfImage: await head(server, colon), + imageWithQuery: await get(server, colon + "?v=1"), + about: await get(server, "/about"), + favicon: await get(server, "/favicon.ico"), + nested: await get(server, "/a/b/c"), + aboutWithQuery: await get(server, "/about?v=1"), + postAbout: await get(server, "/about", { method: "POST" }), + headOfAbout: (await head(server, "/about"))[0], + longerThanImage: [ + await get(server, colon + "/"), + await get(server, colon + "x"), + await get(server, colon + "/extra"), + await get(server, star + "/a"), + ], + })); + `, + }); + const noRoute = [404, "no route"]; + expect(await resultOf(dir)).toEqual({ + result: { + names: names("/:smile:.png", "/*.png", "/plain.png", "/@:x.png", "/a*b.png"), + images: [ + [200, "colon"], + [200, "star"], + [200, "plain"], + [200, "colon second"], + [200, "star inside"], + ], + headOfImage: [200, "image/png", "5"], + imageWithQuery: [200, "colon"], + about: noRoute, + favicon: noRoute, + nested: noRoute, + aboutWithQuery: noRoute, + postAbout: noRoute, + headOfAbout: 404, + longerThanImage: [noRoute, noRoute, noRoute, noRoute], + }, + exitCode: 0, + }); + }); + + // Windows cannot have a file with ":" or "*" in its name. + describe.skipIf(isWindows)("with the files on disk", () => { + test.concurrent.each([ + [ + "static", + "{ hmr: false }", + `"/:id": new Response("user static route")`, + "user static route", + "user static route", + ], + [ + "handler", + "false", + `"/:slug": request => new Response("user handler " + request.params.slug)`, + "user handler about", + "user handler :a.pngx", + ], + ])( + "two such assets serve their own bytes and leave a user %s route of that shape alone (development: %s)", + async (_, development, userRoute, atAbout, atLongerThanA) => { + using dir = tempDir("bun-serve-html-two-colon-assets", { + ...diskImages({ ":a.png": "first", ":b.png": "the second" }), + "serve.ts": /*ts*/ ` + import page from "./index.html"; + ${helpers} + using server = Bun.serve({ + port: 0, + development: ${development}, + routes: { "/": page, ${userRoute} }, + fetch: noRoute, + }); + const beforeBuild = await get(server, "/about"); + const [a, b] = await imageSources(server); + console.log(JSON.stringify({ + beforeBuild, + a: await get(server, a), + b: await get(server, b), + headOfB: await head(server, b), + afterBuild: await get(server, "/about"), + headAfterBuild: (await head(server, "/about"))[0], + // One more byte than the name of the image: the route of the user. + longerThanA: (await get(server, a + "x"))[1].replace(/-[a-z0-9]+\\.pngx$/, ".pngx"), + belowA: await get(server, a + "/"), + })); + `, + }); + expect(await resultOf(dir)).toEqual({ + result: { + beforeBuild: [200, atAbout], + a: [200, "first"], + b: [200, "the second"], + headOfB: [200, "image/png", "10"], + afterBuild: [200, atAbout], + headAfterBuild: 200, + longerThanA: atLongerThanA, + belowA: [404, "no route"], + }, + exitCode: 0, + }); + }, + ); + + // A server has a router for each of HTTP/1.1, HTTP/1.1 over TLS, HTTP/2 and HTTP/3. + test.concurrent("over HTTPS/1.1, HTTP/2 and HTTP/3", async () => { + using dir = tempDir("bun-serve-html-colon-asset-tls", { + ...diskImages({ ":smile:.png": "colon" }), + "serve.ts": /*ts*/ ` + import page from "./index.html"; + ${helpers} + using server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + tls: ${JSON.stringify(tls)}, + http2: true, + http3: true, + development: false, + routes: { "/": page }, + fetch: noRoute, + }); + const results = {}; + for (const protocol of ["http1.1", "http2", "http3"]) { + const init = { protocol, tls: { rejectUnauthorized: false } }; + const [colon] = await imageSources(server, init); + results[protocol] = { + colon: await get(server, colon, init), + headOfColon: await head(server, colon, init), + about: await get(server, "/about", init), + headOfAbout: (await head(server, "/about", init))[0], + belowColon: await get(server, colon + "/", init), + }; + } + console.log(JSON.stringify(results)); + `, + }); + const served = { + colon: [200, "colon"], + headOfColon: [200, "image/png", "5"], + about: [404, "no route"], + headOfAbout: 404, + belowColon: [404, "no route"], + }; + expect(await resultOf(dir)).toEqual({ + result: { "http1.1": served, http2: served, http3: served }, + exitCode: 0, + }); + }); + + // The router tries a WebSocket upgrade ahead of every other route. The route + // of a bundled file does not change that. + test.concurrent("a WebSocket upgrade at the path of such an asset still reaches the server", async () => { + using dir = tempDir("bun-serve-html-colon-asset-websocket", { + ...diskImages({ ":smile:.png": "colon" }), + "serve.ts": /*ts*/ ` + import page from "./index.html"; + ${helpers} + using server = Bun.serve({ + port: 0, + development: false, + routes: { "/": page }, + fetch(request, server) { + if (server.upgrade(request, { data: new URL(request.url).pathname })) return; + return noRoute(); + }, + websocket: { + open(socket) { + socket.send("upgraded at " + socket.data); + }, + message() {}, + }, + }); + const [colon] = await imageSources(server); + function firstMessage(path) { + const { promise, resolve, reject } = Promise.withResolvers(); + const socket = new WebSocket(new URL(path, server.url).href.replace("http", "ws")); + socket.onmessage = event => { + resolve(event.data.replace(colon, "the image")); + socket.close(); + }; + socket.onerror = () => reject(new Error("no upgrade at " + path)); + return promise; + } + console.log(JSON.stringify({ + upgradeAtImage: await firstMessage(colon), + upgradeAtAbout: await firstMessage("/about"), + image: await get(server, colon), + about: await get(server, "/about"), + })); + `, + }); + expect(await resultOf(dir)).toEqual({ + result: { + upgradeAtImage: "upgraded at the image", + upgradeAtAbout: "upgraded at /about", + image: [200, "colon"], + about: [404, "no route"], + }, + exitCode: 0, + }); + }); + + test.concurrent("a page named like a parameter answers only its own path", async () => { + using dir = tempDir("bun-serve-html-colon-page", { + ":page.html": `

colon page

`, + "a:b/index.html": `

page in a directory

`, + "serve.ts": /*ts*/ ` + import colonPage from "./:page.html"; + import directoryPage from "./a:b/index.html"; + ${helpers} + using server = Bun.serve({ + port: 0, + development: false, + routes: { + "/colon": colonPage, + "/directory": directoryPage, + // The same bytes as the output path of the first page. This one is a pattern. + "/:page.html": new Response("user static route"), + }, + fetch: noRoute, + }); + async function heading(path) { + const [status, body] = await get(server, path); + return [status, body.match(/

(.*?)<\\/h1>/)?.[1] ?? body]; + } + const beforeBuild = await heading("/about"); + console.log(JSON.stringify({ + beforeBuild, + colonRoute: await heading("/colon"), + directoryRoute: await heading("/directory"), + colonOutputPath: await heading("/:page.html"), + about: await heading("/about"), + otherDirectory: await heading("/other/index.html"), + })); + `, + }); + expect(await resultOf(dir)).toEqual({ + result: { + beforeBuild: [200, "user static route"], + colonRoute: [200, "colon page"], + directoryRoute: [200, "page in a directory"], + colonOutputPath: [200, "colon page"], + about: [200, "user static route"], + otherDirectory: [404, "no route"], + }, + exitCode: 0, + }); + }); + + // `bun ./index.html` serves its one page as the "/*" route. Read as a + // pattern, an image answered the paths of the page after the first request. + // The name can come from a dependency: a stylesheet of a package refers to + // an image that is large enough to be a file of its own. + test.concurrent("a page that is the /* route keeps its paths", async () => { + using dir = tempDir("bun-serve-html-star-page-pattern-assets", { + "index.html": `

home

`, + ":smile:.png": "colon", + "node_modules/fancy-ui/package.json": JSON.stringify({ name: "fancy-ui", version: "1.0.0" }), + "node_modules/fancy-ui/style.css": `.fancy { background: url("./*.png"); }`, + "node_modules/fancy-ui/*.png": Buffer.alloc(200_000, "D").toString(), + "serve.ts": /*ts*/ ` + import page from "./index.html"; + ${helpers} + using server = Bun.serve({ port: 0, development: false, routes: { "/*": page } }); + // The content type, and the heading of a page or the length of a file. + async function summary(path) { + const response = await fetch(new URL(path, server.url)); + const body = await response.text(); + return [response.headers.get("content-type"), body.match(/

(.*?)<\\/h1>/)?.[1] ?? body.length]; + } + const html = await (await fetch(server.url)).text(); + const colon = html.match(/]* href="([^"]+)"/)[1], server.url))).text(); + const star = css.match(/url\\("?([^")]+)"?\\)/)[1]; + console.log(JSON.stringify({ + names: [withoutHash(colon), withoutHash(star)], + colon: await get(server, colon), + star: await summary(star), + home: await summary("/"), + about: await summary("/about"), + nested: await summary("/a/b"), + })); + `, + }); + const page = ["text/html;charset=utf-8", "home"]; + expect(await resultOf(dir)).toEqual({ + result: { + names: ["/:smile:.png", "/*.png"], + colon: [200, "colon"], + star: ["image/png", 200_000], + home: page, + about: page, + nested: page, + }, + exitCode: 0, + }); + }); + }); +}); + test("serve html with JSX runtime in development mode", async () => { const dir = join(import.meta.dir, "jsx-runtime"); const { default: html } = await import(join(dir, "index.html"));