From f2b1c6d97f535157fe976e64f9ba611629393add Mon Sep 17 00:00:00 2001 From: Jonathan Kelley Date: Fri, 3 Oct 2025 18:26:19 -0700 Subject: [PATCH] codify "platform" into resolution logic, server-fn/fullstack overhaul (#4596) overhaul dioxus fullstack using a new implementation of server_fn This PR is a complete rewrite of dioxus fullstack designed to improve compatibility with axum and to fix a number of compilation errors based on feature flag unification plumbing through macros. --- .github/actions/free-disk-space/action.yml | 3 + .github/workflows/main.yml | 189 +- .github/workflows/merge.yml | 4 +- .github/workflows/promote.yml | 2 +- .github/workflows/publish.yml | 4 +- .github/workflows/typos.yml | 4 +- .gitignore | 1 + Cargo.lock | 781 +++++-- Cargo.toml | 590 +++-- .../bluetooth-scanner/.gitignore | 0 .../bluetooth-scanner/Cargo.toml | 2 +- .../bluetooth-scanner/README.md | 0 .../bluetooth-scanner/assets/tailwind.css | 0 .../bluetooth-scanner/demo_small.png | Bin .../bluetooth-scanner/src/main.rs | 90 + .../bluetooth-scanner/tailwind.css | 0 examples/{ => 01-app-demos}/calculator.rs | 2 +- .../{ => 01-app-demos}/calculator_mutable.rs | 7 +- examples/{ => 01-app-demos}/counters.rs | 2 +- examples/{ => 01-app-demos}/crm.rs | 12 +- examples/01-app-demos/dog_app.rs | 70 + .../ecommerce-site/.gitignore | 0 .../ecommerce-site/Cargo.toml | 2 +- .../ecommerce-site/README.md | 0 .../ecommerce-site/demo.png | Bin .../ecommerce-site/public/loading.css | 0 .../ecommerce-site/public/tailwind.css | 0 .../ecommerce-site/src/api.rs | 100 +- .../ecommerce-site/src/components/error.rs | 0 .../ecommerce-site/src/components/home.rs | 23 + .../ecommerce-site/src/components/loading.rs | 12 + .../ecommerce-site/src/components/nav.rs | 3 +- .../src/components/product_item.rs | 2 +- .../src/components/product_page.rs | 71 +- .../ecommerce-site/src/main.rs | 4 +- .../ecommerce-site/tailwind.css | 0 .../file-explorer/.gitignore | 0 .../file-explorer/Cargo.toml | 0 .../file-explorer/Dioxus.toml | 0 .../file-explorer/README.md | 0 .../file-explorer/assets/fileexplorer.css | 0 .../file-explorer/assets/image.png | Bin .../file-explorer/src/main.rs | 12 +- .../hackernews}/.gitignore | 0 .../hackernews}/Cargo.toml | 0 .../hackernews}/assets/hackernews.css | 0 .../hackernews}/src/main.rs | 163 +- examples/{ => 01-app-demos}/hello_world.rs | 0 examples/{ => 01-app-demos}/hotdog/Cargo.toml | 3 +- .../{ => 01-app-demos}/hotdog/Dioxus.toml | 0 examples/{ => 01-app-demos}/hotdog/Dockerfile | 0 examples/{ => 01-app-demos}/hotdog/README.md | 0 .../hotdog/assets/favicon.ico | Bin .../hotdog/assets/header.svg | 0 .../{ => 01-app-demos}/hotdog/assets/main.css | 0 .../hotdog/assets/screenshot.png | Bin examples/{ => 01-app-demos}/hotdog/fly.toml | 0 examples/01-app-demos/hotdog/src/backend.rs | 41 + examples/01-app-demos/hotdog/src/frontend.rs | 71 + .../{ => 01-app-demos}/hotdog/src/main.rs | 8 +- .../01-app-demos/image_generator_openai.rs | 97 + .../repo_readme.rs} | 0 examples/{ => 01-app-demos}/todomvc.rs | 2 +- examples/{ => 01-app-demos}/todomvc_store.rs | 2 +- examples/{ => 01-app-demos}/weather_app.rs | 42 +- examples/01-app-demos/websocket_chat.rs | 162 ++ examples/{ => 02-building-ui}/disabled.rs | 5 +- .../{ => 02-building-ui}/nested_listeners.rs | 0 examples/{ => 02-building-ui}/svg.rs | 4 +- .../{ => 03-assets-styling}/custom_assets.rs | 0 .../dynamic_assets.rs} | 4 +- examples/{ => 03-assets-styling}/meta.rs | 0 examples/03-assets-styling/meta_elements.rs | 20 + .../{ => 04-managing-state}/context_api.rs | 2 +- .../error_handling.rs} | 35 +- examples/{ => 04-managing-state}/global.rs | 2 +- .../{ => 04-managing-state}/memo_chain.rs | 0 examples/{ => 04-managing-state}/reducer.rs | 2 +- examples/{ => 04-managing-state}/signals.rs | 2 +- .../backgrounded_futures.rs | 0 examples/{ => 05-using-async}/clock.rs | 2 +- examples/{ => 05-using-async}/future.rs | 0 examples/{ => 05-using-async}/streams.rs | 2 +- examples/05-using-async/suspense.rs | 62 + examples/{ => 06-routing}/flat_router.rs | 2 +- .../{ => 06-routing}/hash_fragment_state.rs | 0 examples/{ => 06-routing}/link.rs | 2 +- .../{ => 06-routing}/query_segment_search.rs | 0 examples/{ => 06-routing}/router.rs | 2 +- examples/{ => 06-routing}/router_resource.rs | 0 .../{ => 06-routing}/router_restore_scroll.rs | 0 examples/{ => 06-routing}/simple_router.rs | 0 .../auth-with-fullstack/Cargo.toml | 61 + .../auth-with-fullstack/src/auth.rs | 81 + .../auth-with-fullstack/src/main.rs | 158 ++ .../auth}/Cargo.toml | 3 - .../auth}/src/auth.rs | 117 +- examples/07-fullstack/auth/src/main.rs | 129 ++ examples/07-fullstack/custom_axum_serve.rs | 46 + .../desktop}/Cargo.toml | 0 .../desktop}/src/main.rs | 8 +- examples/07-fullstack/dog_app_self_hosted.rs | 48 + examples/07-fullstack/full_request_access.rs | 37 + .../07-fullstack/fullstack_hello_world.rs | 24 + examples/07-fullstack/handling_errors.rs | 173 ++ examples/07-fullstack/header_map.rs | 36 + .../hello-world}/Cargo.toml | 5 +- .../07-fullstack/hello-world/assets/hello.css | 3 + examples/07-fullstack/hello-world/src/main.rs | 59 + examples/07-fullstack/login_form.rs | 104 + examples/07-fullstack/multipart_form.rs | 64 + .../router}/Cargo.toml | 0 .../router}/src/main.rs | 6 +- examples/07-fullstack/server_sent_events.rs | 95 + examples/07-fullstack/server_state.rs | 119 + examples/07-fullstack/ssr-only/Cargo.toml | 12 + examples/07-fullstack/ssr-only/src/main.rs | 59 + examples/07-fullstack/streaming.rs | 147 ++ .../07-fullstack/streaming_file_upload.rs | 205 ++ examples/07-fullstack/through_reqwest.rs | 47 + examples/07-fullstack/websocket.rs | 90 + examples/{ => 08-apis}/control_focus.rs | 2 +- examples/{ => 08-apis}/custom_html.rs | 0 examples/{ => 08-apis}/custom_menu.rs | 0 examples/{ => 08-apis}/eval.rs | 0 examples/{ => 08-apis}/file_upload.rs | 40 +- examples/{ => 08-apis}/form.rs | 0 examples/{ => 08-apis}/logging.rs | 2 +- examples/{ => 08-apis}/multiwindow.rs | 0 .../multiwindow_with_tray_icon.rs | 3 +- examples/{resize.rs => 08-apis/on_resize.rs} | 5 +- .../{visible.rs => 08-apis/on_visible.rs} | 3 +- examples/{ => 08-apis}/overlay.rs | 7 +- examples/{ => 08-apis}/read_size.rs | 5 +- examples/{ => 08-apis}/scroll_to_offset.rs | 0 examples/{ => 08-apis}/scroll_to_top.rs | 0 examples/{ => 08-apis}/shortcut.rs | 2 +- examples/{ => 08-apis}/ssr.rs | 0 examples/{ => 08-apis}/title.rs | 5 +- examples/{ => 08-apis}/video_stream.rs | 4 +- examples/{ => 08-apis}/wgpu_child_window.rs | 3 +- examples/{ => 08-apis}/window_event.rs | 2 +- examples/{ => 08-apis}/window_focus.rs | 0 .../{popup.rs => 08-apis/window_popup.rs} | 0 examples/{ => 08-apis}/window_zoom.rs | 0 examples/{ => 09-reference}/all_events.rs | 2 +- .../{ => 09-reference}/generic_component.rs | 0 examples/{ => 09-reference}/optional_props.rs | 0 examples/{ => 09-reference}/rsx_usage.rs | 8 +- examples/{ => 09-reference}/shorthand.rs | 0 examples/{ => 09-reference}/simple_list.rs | 0 examples/{ => 09-reference}/spread.rs | 0 examples/{ => 09-reference}/web_component.rs | 0 examples/{ => 09-reference}/xss_safety.rs | 0 .../{ => 10-integrations}/bevy/Cargo.toml | 2 +- .../bevy/src/bevy_renderer.rs | 0 .../bevy/src/bevy_scene_plugin.rs | 0 .../bevy/src/demo_renderer.rs | 0 .../{ => 10-integrations}/bevy/src/main.rs | 2 +- .../{ => 10-integrations}/bevy/src/styles.css | 0 .../native-headless-in-bevy/Cargo.toml | 0 .../native-headless-in-bevy/README.md | 0 .../src/bevy_scene_plugin.rs | 0 .../src/dioxus_in_bevy_plugin.rs | 2 +- .../native-headless-in-bevy/src/main.rs | 0 .../native-headless-in-bevy/src/ui.css | 0 .../native-headless-in-bevy/src/ui.rs | 0 .../native-headless/Cargo.toml | 2 +- .../native-headless/src/main.rs | 0 examples/{ => 10-integrations}/pwa/Cargo.toml | 0 .../{ => 10-integrations}/pwa/Dioxus.toml | 0 examples/{ => 10-integrations}/pwa/LICENSE | 0 examples/{ => 10-integrations}/pwa/README.md | 0 examples/{ => 10-integrations}/pwa/index.html | 0 .../pwa/public/favicon.ico | Bin .../pwa/public/logo_192.png | Bin .../pwa/public/logo_512.png | Bin .../pwa/public/manifest.json | 0 .../{ => 10-integrations}/pwa/public/sw.js | 0 .../{ => 10-integrations}/pwa/src/main.rs | 0 .../{ => 10-integrations}/tailwind/.gitignore | 0 .../{ => 10-integrations}/tailwind/Cargo.toml | 0 .../{ => 10-integrations}/tailwind/README.md | 0 .../tailwind/assets}/tailwind.css | 0 .../tailwind/src/main.rs | 2 +- .../tailwind/tailwind.css | 0 .../wgpu-texture/Cargo.toml | 2 +- .../wgpu-texture/src/demo_renderer.rs | 0 .../wgpu-texture/src/main.rs | 2 +- .../wgpu-texture/src/shader.wgsl | 0 .../wgpu-texture/src/styles.css | 0 examples/assets/file_upload.css | 2 +- examples/bluetooth-scanner/src/main.rs | 94 - examples/dog_app.rs | 88 - .../ecommerce-site/src/components/cart.rs | 1 - .../ecommerce-site/src/components/home.rs | 24 - .../ecommerce-site/src/components/loading.rs | 32 - examples/fullstack-auth/.gitignore | 4 - examples/fullstack-auth/src/main.rs | 150 -- examples/fullstack-desktop/.gitignore | 4 - examples/fullstack-hello-world/.gitignore | 4 - .../fullstack-hello-world/assets/hello.css | 3 - examples/fullstack-hello-world/src/main.rs | 49 - examples/fullstack-router/.gitignore | 4 - examples/fullstack-streaming/.gitignore | 4 - examples/fullstack-streaming/Cargo.toml | 19 - examples/fullstack-streaming/src/main.rs | 45 - examples/fullstack-websockets/.gitignore | 4 - examples/fullstack-websockets/src/main.rs | 69 - examples/hotdog/src/backend.rs | 43 - examples/hotdog/src/components/favorites.rs | 25 - examples/hotdog/src/components/mod.rs | 7 - examples/hotdog/src/components/nav.rs | 19 - examples/hotdog/src/components/view.rs | 42 - examples/hydration.rs | 36 - examples/image_generator_openai.rs | 123 - examples/login_form.rs | 49 - examples/scripts/scrape_examples.rs | 48 + examples/suspense.rs | 94 - examples/tailwind/Dioxus.toml | 27 - packages/asset-resolver/src/native.rs | 2 +- packages/cli-config/src/lib.rs | 1 + packages/cli-harnesses/.gitignore | 2 + packages/cli-harnesses/README.md | 65 + .../harness-default-to-non-default/Cargo.toml | 16 + .../src/main.rs | 4 + .../Cargo.toml | 19 + .../src/main.rs | 4 + .../Cargo.toml | 18 + .../src/main.rs | 4 + .../harness-fullstack-desktop/Cargo.toml | 16 + .../harness-fullstack-desktop/src/main.rs | 4 + .../Cargo.toml | 18 + .../src/main.rs | 4 + .../harness-fullstack-multi-target/Cargo.toml | 19 + .../src/main.rs | 4 + .../Cargo.toml | 13 +- .../src/main.rs | 4 + .../harness-no-dioxus/Cargo.toml | 16 + .../harness-no-dioxus/src/main.rs | 3 + .../harness-renderer-swap/Cargo.toml | 18 + .../harness-renderer-swap/src/main.rs | 4 + .../Cargo.toml | 14 + .../src/main.rs | 3 + .../Cargo.toml | 13 + .../src/main.rs | 3 + .../harness-simple-desktop/Cargo.toml | 14 + .../harness-simple-desktop/src/main.rs | 3 + .../Cargo.toml | 17 + .../src/main.rs | 4 + .../Cargo.toml | 17 + .../src/main.rs | 4 + .../harness-simple-fullstack/Cargo.toml | 16 + .../harness-simple-fullstack/src/main.rs | 4 + .../harness-simple-mobile/Cargo.toml | 14 + .../harness-simple-mobile/src/main.rs | 3 + .../harness-simple-web/Cargo.toml | 14 + .../harness-simple-web/src/main.rs | 3 + packages/cli-opt/src/hash.rs | 23 +- packages/cli/src/build/assets.rs | 5 +- packages/cli/src/build/builder.rs | 5 +- packages/cli/src/build/patch.rs | 16 +- packages/cli/src/build/request.rs | 404 +++- packages/cli/src/build/tools.rs | 6 +- packages/cli/src/cli/build.rs | 15 +- packages/cli/src/cli/link.rs | 8 +- packages/cli/src/cli/run.rs | 4 +- packages/cli/src/cli/serve.rs | 6 +- packages/cli/src/cli/target.rs | 35 +- packages/cli/src/main.rs | 1 + packages/cli/src/platform.rs | 453 +--- packages/cli/src/serve/proxy_ws.rs | 6 +- packages/cli/src/serve/server.rs | 4 - packages/cli/src/test_harnesses.rs | 445 ++++ packages/core-macro/src/props/mod.rs | 12 +- packages/core/Cargo.toml | 1 + packages/core/src/any_props.rs | 2 +- packages/core/src/diff/component.rs | 31 +- packages/core/src/error_boundary.rs | 496 +---- packages/core/src/events.rs | 29 +- packages/core/src/global_context.rs | 5 +- packages/core/src/lib.rs | 40 +- packages/core/src/nodes.rs | 63 +- packages/core/src/reactive_context.rs | 3 +- packages/core/src/render_error.rs | 106 +- packages/core/src/runtime.rs | 9 +- packages/core/src/scope_arena.rs | 18 +- packages/core/src/scope_context.rs | 18 +- packages/core/src/scopes.rs | 45 +- packages/core/src/suspense/component.rs | 83 +- packages/core/src/suspense/mod.rs | 76 +- packages/core/src/tasks.rs | 7 +- packages/core/src/virtual_dom.rs | 9 +- packages/desktop/Cargo.toml | 3 +- packages/desktop/src/app.rs | 7 +- packages/desktop/src/desktop_context.rs | 6 +- packages/desktop/src/file_upload.rs | 151 +- packages/desktop/src/webview.rs | 45 +- packages/dioxus/Cargo.toml | 34 +- packages/dioxus/src/launch.rs | 9 +- packages/dioxus/src/lib.rs | 24 +- packages/document/src/elements/stylesheet.rs | 2 +- .../Cargo.toml | 29 +- .../README.md | 13 +- .../src/document.rs | 2 +- packages/fullstack-core/src/error.rs | 223 ++ .../src/errors.rs | 14 +- .../src/history.rs | 14 +- packages/fullstack-core/src/httperror.rs | 217 ++ packages/fullstack-core/src/lib.rs | 30 + packages/fullstack-core/src/loader.rs | 365 +++ .../src}/server_cached.rs | 47 +- packages/fullstack-core/src/server_future.rs | 429 ++++ .../src/streaming.rs | 8 +- .../src/transport.rs} | 146 +- packages/fullstack-hooks/src/hooks/mod.rs | 4 - .../src/hooks/server_future.rs | 138 -- packages/fullstack-hooks/src/lib.rs | 11 - .../Cargo.toml | 28 +- packages/fullstack-macro/src/lib.rs | 1980 +++++++++++++++++ packages/fullstack-protocol/Cargo.toml | 21 - packages/fullstack-protocol/README.md | 3 - .../.gitignore | 0 packages/fullstack-server/Cargo.toml | 104 + packages/fullstack-server/README.md | 144 ++ .../docs/request_origin.md | 0 .../src/config.rs | 42 +- .../src/document.rs | 3 +- .../src/isrg}/config.rs | 6 +- .../src/isrg}/freshness.rs | 0 .../src/isrg}/fs_cache.rs | 0 .../src/isrg}/memory_cache.rs | 0 .../src/isrg/mod.rs} | 12 +- .../src/launch.rs | 215 +- packages/fullstack-server/src/lib.rs | 46 + packages/fullstack-server/src/redirect.rs | 31 + .../src/server.rs | 492 ++-- packages/fullstack-server/src/serverfn.rs | 178 ++ .../render.rs => fullstack-server/src/ssr.rs} | 639 +++--- .../src/streaming.rs | 2 +- packages/fullstack/.vscode/settings.json | 7 + packages/fullstack/Cargo.toml | 172 +- packages/fullstack/README.md | 158 +- packages/fullstack/src/client.rs | 601 +++++ packages/fullstack/src/encoding.rs | 106 + packages/fullstack/src/error.rs | 222 -- packages/fullstack/src/lazy.rs | 141 ++ packages/fullstack/src/lib.rs | 131 +- packages/fullstack/src/magic.rs | 753 +++++++ packages/fullstack/src/mock_client.rs | 153 -- packages/fullstack/src/payloads/axum_types.rs | 77 + packages/fullstack/src/payloads/cbor.rs | 160 ++ packages/fullstack/src/payloads/files.rs | 371 +++ packages/fullstack/src/payloads/form.rs | 12 + packages/fullstack/src/payloads/header.rs | 66 + packages/fullstack/src/payloads/msgpack.rs | 186 ++ packages/fullstack/src/payloads/multipart.rs | 165 ++ packages/fullstack/src/payloads/postcard.rs | 117 + packages/fullstack/src/payloads/redirect.rs | 0 packages/fullstack/src/payloads/sse.rs | 422 ++++ packages/fullstack/src/payloads/stream.rs | 405 ++++ packages/fullstack/src/payloads/text.rs | 38 + packages/fullstack/src/payloads/websocket.rs | 1459 ++++++++++++ packages/fullstack/src/request.rs | 171 ++ packages/fullstack/src/spawn.rs | 31 + packages/fullstack/tests/compile-test.rs | 344 +++ packages/hooks/Cargo.toml | 2 +- packages/hooks/src/lib.rs | 6 + packages/hooks/src/use_action.rs | 288 +++ packages/hooks/src/use_callback.rs | 8 +- packages/hooks/src/use_resource.rs | 28 +- packages/hooks/src/use_waker.rs | 72 + packages/html/Cargo.toml | 9 +- packages/html/src/events/drag.rs | 14 +- packages/html/src/events/form.rs | 82 +- packages/html/src/events/mounted.rs | 3 + packages/html/src/file_data.rs | 179 +- packages/html/src/lib.rs | 2 + packages/html/src/transit.rs | 2 +- packages/interpreter/src/js/common.js | 2 +- packages/interpreter/src/js/hash.txt | 2 +- packages/interpreter/src/js/native.js | 2 +- packages/interpreter/src/ts/form.ts | 5 + packages/isrg/Cargo.toml | 19 - packages/native-dom/src/events.rs | 42 +- .../playwright-tests/cli-optimization.spec.js | 8 +- .../cli-optimization/Cargo.toml | 8 +- .../cli-optimization/build.rs | 26 +- .../cli-optimization/src/main.rs | 30 +- .../playwright-tests/fullstack-errors.spec.js | 4 + .../fullstack-errors/src/main.rs | 2 +- .../fullstack-routing/src/main.rs | 8 +- packages/playwright-tests/fullstack.spec.js | 2 +- .../fullstack/assets/image.png | Bin 3291 -> 14861 bytes .../fullstack/assets/nested/image.png | Bin 3291 -> 14861 bytes .../playwright-tests/fullstack/src/main.rs | 91 +- .../playwright-tests/playwright.config.js | 58 +- .../suspense-carousel/src/main.rs | 2 +- packages/router-macro/src/route_tree.rs | 2 +- packages/router/Cargo.toml | 10 +- .../router/src/components/default_errors.rs | 1 - .../router/src/components/history_buttons.rs | 1 - .../router/src/components/history_provider.rs | 1 - packages/router/src/components/link.rs | 1 - packages/router/src/components/router.rs | 3 +- packages/router/src/routable.rs | 10 + packages/rsx/src/expr_node.rs | 17 + packages/rsx/src/template_body.rs | 4 +- packages/server-macro/src/lib.rs | 236 -- packages/server/Cargo.toml | 114 - packages/server/docs/request_origin.md | 50 - packages/server/src/context.rs | 576 ----- packages/server/src/lib.rs | 84 - packages/wasm-split/wasm-split-cli/src/lib.rs | 24 +- packages/web/Cargo.toml | 24 +- packages/web/src/cfg.rs | 1 + packages/web/src/document.rs | 20 +- packages/web/src/events/drag.rs | 58 +- packages/web/src/events/file.rs | 59 +- packages/web/src/events/form.rs | 102 +- packages/web/src/file_engine.rs | 133 -- packages/web/src/files.rs | 189 ++ packages/web/src/hydration/hydrate.rs | 2 +- packages/web/src/lib.rs | 15 +- 424 files changed, 17284 insertions(+), 6624 deletions(-) rename examples/{ => 01-app-demos}/bluetooth-scanner/.gitignore (100%) rename examples/{ => 01-app-demos}/bluetooth-scanner/Cargo.toml (95%) rename examples/{ => 01-app-demos}/bluetooth-scanner/README.md (100%) rename examples/{ => 01-app-demos}/bluetooth-scanner/assets/tailwind.css (100%) rename examples/{ => 01-app-demos}/bluetooth-scanner/demo_small.png (100%) create mode 100644 examples/01-app-demos/bluetooth-scanner/src/main.rs rename examples/{ => 01-app-demos}/bluetooth-scanner/tailwind.css (100%) rename examples/{ => 01-app-demos}/calculator.rs (99%) rename examples/{ => 01-app-demos}/calculator_mutable.rs (98%) rename examples/{ => 01-app-demos}/counters.rs (97%) rename examples/{ => 01-app-demos}/crm.rs (93%) create mode 100644 examples/01-app-demos/dog_app.rs rename examples/{ => 01-app-demos}/ecommerce-site/.gitignore (100%) rename examples/{ => 01-app-demos}/ecommerce-site/Cargo.toml (97%) rename examples/{ => 01-app-demos}/ecommerce-site/README.md (100%) rename examples/{ => 01-app-demos}/ecommerce-site/demo.png (100%) rename examples/{ => 01-app-demos}/ecommerce-site/public/loading.css (100%) rename examples/{ => 01-app-demos}/ecommerce-site/public/tailwind.css (100%) rename examples/{ => 01-app-demos}/ecommerce-site/src/api.rs (60%) rename examples/{ => 01-app-demos}/ecommerce-site/src/components/error.rs (100%) create mode 100644 examples/01-app-demos/ecommerce-site/src/components/home.rs create mode 100644 examples/01-app-demos/ecommerce-site/src/components/loading.rs rename examples/{ => 01-app-demos}/ecommerce-site/src/components/nav.rs (99%) rename examples/{ => 01-app-demos}/ecommerce-site/src/components/product_item.rs (94%) rename examples/{ => 01-app-demos}/ecommerce-site/src/components/product_page.rs (98%) rename examples/{ => 01-app-demos}/ecommerce-site/src/main.rs (91%) rename examples/{ => 01-app-demos}/ecommerce-site/tailwind.css (100%) rename examples/{ => 01-app-demos}/file-explorer/.gitignore (100%) rename examples/{ => 01-app-demos}/file-explorer/Cargo.toml (100%) rename examples/{ => 01-app-demos}/file-explorer/Dioxus.toml (100%) rename examples/{ => 01-app-demos}/file-explorer/README.md (100%) rename examples/{ => 01-app-demos}/file-explorer/assets/fileexplorer.css (100%) rename examples/{ => 01-app-demos}/file-explorer/assets/image.png (100%) rename examples/{ => 01-app-demos}/file-explorer/src/main.rs (90%) rename examples/{fullstack-hackernews => 01-app-demos/hackernews}/.gitignore (100%) rename examples/{fullstack-hackernews => 01-app-demos/hackernews}/Cargo.toml (100%) rename examples/{fullstack-hackernews => 01-app-demos/hackernews}/assets/hackernews.css (100%) rename examples/{fullstack-hackernews => 01-app-demos/hackernews}/src/main.rs (56%) rename examples/{ => 01-app-demos}/hello_world.rs (100%) rename examples/{ => 01-app-demos}/hotdog/Cargo.toml (89%) rename examples/{ => 01-app-demos}/hotdog/Dioxus.toml (100%) rename examples/{ => 01-app-demos}/hotdog/Dockerfile (100%) rename examples/{ => 01-app-demos}/hotdog/README.md (100%) rename examples/{ => 01-app-demos}/hotdog/assets/favicon.ico (100%) rename examples/{ => 01-app-demos}/hotdog/assets/header.svg (100%) rename examples/{ => 01-app-demos}/hotdog/assets/main.css (100%) rename examples/{ => 01-app-demos}/hotdog/assets/screenshot.png (100%) rename examples/{ => 01-app-demos}/hotdog/fly.toml (100%) create mode 100644 examples/01-app-demos/hotdog/src/backend.rs create mode 100644 examples/01-app-demos/hotdog/src/frontend.rs rename examples/{ => 01-app-demos}/hotdog/src/main.rs (63%) create mode 100644 examples/01-app-demos/image_generator_openai.rs rename examples/{readme.rs => 01-app-demos/repo_readme.rs} (100%) rename examples/{ => 01-app-demos}/todomvc.rs (99%) rename examples/{ => 01-app-demos}/todomvc_store.rs (99%) rename examples/{ => 01-app-demos}/weather_app.rs (83%) create mode 100644 examples/01-app-demos/websocket_chat.rs rename examples/{ => 02-building-ui}/disabled.rs (74%) rename examples/{ => 02-building-ui}/nested_listeners.rs (100%) rename examples/{ => 02-building-ui}/svg.rs (96%) rename examples/{ => 03-assets-styling}/custom_assets.rs (100%) rename examples/{dynamic_asset.rs => 03-assets-styling/dynamic_assets.rs} (86%) rename examples/{ => 03-assets-styling}/meta.rs (100%) create mode 100644 examples/03-assets-styling/meta_elements.rs rename examples/{ => 04-managing-state}/context_api.rs (97%) rename examples/{errors.rs => 04-managing-state/error_handling.rs} (80%) rename examples/{ => 04-managing-state}/global.rs (96%) rename examples/{ => 04-managing-state}/memo_chain.rs (100%) rename examples/{ => 04-managing-state}/reducer.rs (96%) rename examples/{ => 04-managing-state}/signals.rs (97%) rename examples/{ => 05-using-async}/backgrounded_futures.rs (100%) rename examples/{ => 05-using-async}/clock.rs (96%) rename examples/{ => 05-using-async}/future.rs (100%) rename examples/{ => 05-using-async}/streams.rs (94%) create mode 100644 examples/05-using-async/suspense.rs rename examples/{ => 06-routing}/flat_router.rs (97%) rename examples/{ => 06-routing}/hash_fragment_state.rs (100%) rename examples/{ => 06-routing}/link.rs (97%) rename examples/{ => 06-routing}/query_segment_search.rs (100%) rename examples/{ => 06-routing}/router.rs (98%) rename examples/{ => 06-routing}/router_resource.rs (100%) rename examples/{ => 06-routing}/router_restore_scroll.rs (100%) rename examples/{ => 06-routing}/simple_router.rs (100%) create mode 100644 examples/07-fullstack/auth-with-fullstack/Cargo.toml create mode 100644 examples/07-fullstack/auth-with-fullstack/src/auth.rs create mode 100644 examples/07-fullstack/auth-with-fullstack/src/main.rs rename examples/{fullstack-auth => 07-fullstack/auth}/Cargo.toml (92%) rename examples/{fullstack-auth => 07-fullstack/auth}/src/auth.rs (57%) create mode 100644 examples/07-fullstack/auth/src/main.rs create mode 100644 examples/07-fullstack/custom_axum_serve.rs rename examples/{fullstack-desktop => 07-fullstack/desktop}/Cargo.toml (100%) rename examples/{fullstack-desktop => 07-fullstack/desktop}/src/main.rs (87%) create mode 100644 examples/07-fullstack/dog_app_self_hosted.rs create mode 100644 examples/07-fullstack/full_request_access.rs create mode 100644 examples/07-fullstack/fullstack_hello_world.rs create mode 100644 examples/07-fullstack/handling_errors.rs create mode 100644 examples/07-fullstack/header_map.rs rename examples/{fullstack-hello-world => 07-fullstack/hello-world}/Cargo.toml (71%) create mode 100644 examples/07-fullstack/hello-world/assets/hello.css create mode 100644 examples/07-fullstack/hello-world/src/main.rs create mode 100644 examples/07-fullstack/login_form.rs create mode 100644 examples/07-fullstack/multipart_form.rs rename examples/{fullstack-router => 07-fullstack/router}/Cargo.toml (100%) rename examples/{fullstack-router => 07-fullstack/router}/src/main.rs (94%) create mode 100644 examples/07-fullstack/server_sent_events.rs create mode 100644 examples/07-fullstack/server_state.rs create mode 100644 examples/07-fullstack/ssr-only/Cargo.toml create mode 100644 examples/07-fullstack/ssr-only/src/main.rs create mode 100644 examples/07-fullstack/streaming.rs create mode 100644 examples/07-fullstack/streaming_file_upload.rs create mode 100644 examples/07-fullstack/through_reqwest.rs create mode 100644 examples/07-fullstack/websocket.rs rename examples/{ => 08-apis}/control_focus.rs (96%) rename examples/{ => 08-apis}/custom_html.rs (100%) rename examples/{ => 08-apis}/custom_menu.rs (100%) rename examples/{ => 08-apis}/eval.rs (100%) rename examples/{ => 08-apis}/file_upload.rs (72%) rename examples/{ => 08-apis}/form.rs (100%) rename examples/{ => 08-apis}/logging.rs (96%) rename examples/{ => 08-apis}/multiwindow.rs (100%) rename examples/{ => 08-apis}/multiwindow_with_tray_icon.rs (97%) rename examples/{resize.rs => 08-apis/on_resize.rs} (85%) rename examples/{visible.rs => 08-apis/on_visible.rs} (97%) rename examples/{ => 08-apis}/overlay.rs (88%) rename examples/{ => 08-apis}/read_size.rs (90%) rename examples/{ => 08-apis}/scroll_to_offset.rs (100%) rename examples/{ => 08-apis}/scroll_to_top.rs (100%) rename examples/{ => 08-apis}/shortcut.rs (92%) rename examples/{ => 08-apis}/ssr.rs (100%) rename examples/{ => 08-apis}/title.rs (82%) rename examples/{ => 08-apis}/video_stream.rs (98%) rename examples/{ => 08-apis}/wgpu_child_window.rs (99%) rename examples/{ => 08-apis}/window_event.rs (98%) rename examples/{ => 08-apis}/window_focus.rs (100%) rename examples/{popup.rs => 08-apis/window_popup.rs} (100%) rename examples/{ => 08-apis}/window_zoom.rs (100%) rename examples/{ => 09-reference}/all_events.rs (97%) rename examples/{ => 09-reference}/generic_component.rs (100%) rename examples/{ => 09-reference}/optional_props.rs (100%) rename examples/{ => 09-reference}/rsx_usage.rs (97%) rename examples/{ => 09-reference}/shorthand.rs (100%) rename examples/{ => 09-reference}/simple_list.rs (100%) rename examples/{ => 09-reference}/spread.rs (100%) rename examples/{ => 09-reference}/web_component.rs (100%) rename examples/{ => 09-reference}/xss_safety.rs (100%) rename examples/{ => 10-integrations}/bevy/Cargo.toml (89%) rename examples/{ => 10-integrations}/bevy/src/bevy_renderer.rs (100%) rename examples/{ => 10-integrations}/bevy/src/bevy_scene_plugin.rs (100%) rename examples/{ => 10-integrations}/bevy/src/demo_renderer.rs (100%) rename examples/{ => 10-integrations}/bevy/src/main.rs (97%) rename examples/{ => 10-integrations}/bevy/src/styles.css (100%) rename examples/{ => 10-integrations}/native-headless-in-bevy/Cargo.toml (100%) rename examples/{ => 10-integrations}/native-headless-in-bevy/README.md (100%) rename examples/{ => 10-integrations}/native-headless-in-bevy/src/bevy_scene_plugin.rs (100%) rename examples/{ => 10-integrations}/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs (99%) rename examples/{ => 10-integrations}/native-headless-in-bevy/src/main.rs (100%) rename examples/{ => 10-integrations}/native-headless-in-bevy/src/ui.css (100%) rename examples/{ => 10-integrations}/native-headless-in-bevy/src/ui.rs (100%) rename examples/{ => 10-integrations}/native-headless/Cargo.toml (86%) rename examples/{ => 10-integrations}/native-headless/src/main.rs (100%) rename examples/{ => 10-integrations}/pwa/Cargo.toml (100%) rename examples/{ => 10-integrations}/pwa/Dioxus.toml (100%) rename examples/{ => 10-integrations}/pwa/LICENSE (100%) rename examples/{ => 10-integrations}/pwa/README.md (100%) rename examples/{ => 10-integrations}/pwa/index.html (100%) rename examples/{ => 10-integrations}/pwa/public/favicon.ico (100%) rename examples/{ => 10-integrations}/pwa/public/logo_192.png (100%) rename examples/{ => 10-integrations}/pwa/public/logo_512.png (100%) rename examples/{ => 10-integrations}/pwa/public/manifest.json (100%) rename examples/{ => 10-integrations}/pwa/public/sw.js (100%) rename examples/{ => 10-integrations}/pwa/src/main.rs (100%) rename examples/{ => 10-integrations}/tailwind/.gitignore (100%) rename examples/{ => 10-integrations}/tailwind/Cargo.toml (100%) rename examples/{ => 10-integrations}/tailwind/README.md (100%) rename examples/{tailwind/public => 10-integrations/tailwind/assets}/tailwind.css (100%) rename examples/{ => 10-integrations}/tailwind/src/main.rs (98%) rename examples/{ => 10-integrations}/tailwind/tailwind.css (100%) rename examples/{ => 10-integrations}/wgpu-texture/Cargo.toml (89%) rename examples/{ => 10-integrations}/wgpu-texture/src/demo_renderer.rs (100%) rename examples/{ => 10-integrations}/wgpu-texture/src/main.rs (98%) rename examples/{ => 10-integrations}/wgpu-texture/src/shader.wgsl (100%) rename examples/{ => 10-integrations}/wgpu-texture/src/styles.css (100%) delete mode 100644 examples/bluetooth-scanner/src/main.rs delete mode 100644 examples/dog_app.rs delete mode 100644 examples/ecommerce-site/src/components/cart.rs delete mode 100644 examples/ecommerce-site/src/components/home.rs delete mode 100644 examples/ecommerce-site/src/components/loading.rs delete mode 100644 examples/fullstack-auth/.gitignore delete mode 100644 examples/fullstack-auth/src/main.rs delete mode 100644 examples/fullstack-desktop/.gitignore delete mode 100644 examples/fullstack-hello-world/.gitignore delete mode 100644 examples/fullstack-hello-world/assets/hello.css delete mode 100644 examples/fullstack-hello-world/src/main.rs delete mode 100644 examples/fullstack-router/.gitignore delete mode 100644 examples/fullstack-streaming/.gitignore delete mode 100644 examples/fullstack-streaming/Cargo.toml delete mode 100644 examples/fullstack-streaming/src/main.rs delete mode 100644 examples/fullstack-websockets/.gitignore delete mode 100644 examples/fullstack-websockets/src/main.rs delete mode 100644 examples/hotdog/src/backend.rs delete mode 100644 examples/hotdog/src/components/favorites.rs delete mode 100644 examples/hotdog/src/components/mod.rs delete mode 100644 examples/hotdog/src/components/nav.rs delete mode 100644 examples/hotdog/src/components/view.rs delete mode 100644 examples/hydration.rs delete mode 100644 examples/image_generator_openai.rs delete mode 100644 examples/login_form.rs create mode 100644 examples/scripts/scrape_examples.rs delete mode 100644 examples/suspense.rs delete mode 100644 examples/tailwind/Dioxus.toml create mode 100644 packages/cli-harnesses/.gitignore create mode 100644 packages/cli-harnesses/README.md create mode 100644 packages/cli-harnesses/harness-default-to-non-default/Cargo.toml create mode 100644 packages/cli-harnesses/harness-default-to-non-default/src/main.rs create mode 100644 packages/cli-harnesses/harness-fullstack-desktop-with-default/Cargo.toml create mode 100644 packages/cli-harnesses/harness-fullstack-desktop-with-default/src/main.rs create mode 100644 packages/cli-harnesses/harness-fullstack-desktop-with-features/Cargo.toml create mode 100644 packages/cli-harnesses/harness-fullstack-desktop-with-features/src/main.rs create mode 100644 packages/cli-harnesses/harness-fullstack-desktop/Cargo.toml create mode 100644 packages/cli-harnesses/harness-fullstack-desktop/src/main.rs create mode 100644 packages/cli-harnesses/harness-fullstack-multi-target-no-default/Cargo.toml create mode 100644 packages/cli-harnesses/harness-fullstack-multi-target-no-default/src/main.rs create mode 100644 packages/cli-harnesses/harness-fullstack-multi-target/Cargo.toml create mode 100644 packages/cli-harnesses/harness-fullstack-multi-target/src/main.rs rename {examples/fullstack-websockets => packages/cli-harnesses/harness-fullstack-with-optional-tokio}/Cargo.toml (59%) create mode 100644 packages/cli-harnesses/harness-fullstack-with-optional-tokio/src/main.rs create mode 100644 packages/cli-harnesses/harness-no-dioxus/Cargo.toml create mode 100644 packages/cli-harnesses/harness-no-dioxus/src/main.rs create mode 100644 packages/cli-harnesses/harness-renderer-swap/Cargo.toml create mode 100644 packages/cli-harnesses/harness-renderer-swap/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-dedicated-client/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-dedicated-client/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-dedicated-server/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-dedicated-server/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-desktop/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-desktop/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-fullstack-native-with-default/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-fullstack-native-with-default/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-fullstack-with-default/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-fullstack-with-default/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-fullstack/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-fullstack/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-mobile/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-mobile/src/main.rs create mode 100644 packages/cli-harnesses/harness-simple-web/Cargo.toml create mode 100644 packages/cli-harnesses/harness-simple-web/src/main.rs create mode 100644 packages/cli/src/test_harnesses.rs rename packages/{fullstack-hooks => fullstack-core}/Cargo.toml (58%) rename packages/{fullstack-hooks => fullstack-core}/README.md (71%) rename packages/{fullstack-hooks => fullstack-core}/src/document.rs (95%) create mode 100644 packages/fullstack-core/src/error.rs rename packages/{fullstack-hooks => fullstack-core}/src/errors.rs (60%) rename packages/{fullstack-hooks => fullstack-core}/src/history.rs (91%) create mode 100644 packages/fullstack-core/src/httperror.rs create mode 100644 packages/fullstack-core/src/lib.rs create mode 100644 packages/fullstack-core/src/loader.rs rename packages/{fullstack-hooks/src/hooks => fullstack-core/src}/server_cached.rs (61%) create mode 100644 packages/fullstack-core/src/server_future.rs rename packages/{fullstack-hooks => fullstack-core}/src/streaming.rs (93%) rename packages/{fullstack-protocol/src/lib.rs => fullstack-core/src/transport.rs} (73%) delete mode 100644 packages/fullstack-hooks/src/hooks/mod.rs delete mode 100644 packages/fullstack-hooks/src/hooks/server_future.rs delete mode 100644 packages/fullstack-hooks/src/lib.rs rename packages/{server-macro => fullstack-macro}/Cargo.toml (50%) create mode 100644 packages/fullstack-macro/src/lib.rs delete mode 100644 packages/fullstack-protocol/Cargo.toml delete mode 100644 packages/fullstack-protocol/README.md rename packages/{fullstack => fullstack-server}/.gitignore (100%) create mode 100644 packages/fullstack-server/Cargo.toml create mode 100644 packages/fullstack-server/README.md rename packages/{fullstack => fullstack-server}/docs/request_origin.md (100%) rename packages/{server => fullstack-server}/src/config.rs (92%) rename packages/{server => fullstack-server}/src/document.rs (97%) rename packages/{isrg/src => fullstack-server/src/isrg}/config.rs (95%) rename packages/{isrg/src => fullstack-server/src/isrg}/freshness.rs (100%) rename packages/{isrg/src => fullstack-server/src/isrg}/fs_cache.rs (100%) rename packages/{isrg/src => fullstack-server/src/isrg}/memory_cache.rs (100%) rename packages/{isrg/src/lib.rs => fullstack-server/src/isrg/mod.rs} (95%) rename packages/{server => fullstack-server}/src/launch.rs (66%) create mode 100644 packages/fullstack-server/src/lib.rs create mode 100644 packages/fullstack-server/src/redirect.rs rename packages/{server => fullstack-server}/src/server.rs (55%) create mode 100644 packages/fullstack-server/src/serverfn.rs rename packages/{server/src/render.rs => fullstack-server/src/ssr.rs} (51%) rename packages/{server => fullstack-server}/src/streaming.rs (99%) create mode 100644 packages/fullstack/.vscode/settings.json create mode 100644 packages/fullstack/src/client.rs create mode 100644 packages/fullstack/src/encoding.rs delete mode 100644 packages/fullstack/src/error.rs create mode 100644 packages/fullstack/src/lazy.rs create mode 100644 packages/fullstack/src/magic.rs delete mode 100644 packages/fullstack/src/mock_client.rs create mode 100644 packages/fullstack/src/payloads/axum_types.rs create mode 100644 packages/fullstack/src/payloads/cbor.rs create mode 100644 packages/fullstack/src/payloads/files.rs create mode 100644 packages/fullstack/src/payloads/form.rs create mode 100644 packages/fullstack/src/payloads/header.rs create mode 100644 packages/fullstack/src/payloads/msgpack.rs create mode 100644 packages/fullstack/src/payloads/multipart.rs create mode 100644 packages/fullstack/src/payloads/postcard.rs create mode 100644 packages/fullstack/src/payloads/redirect.rs create mode 100644 packages/fullstack/src/payloads/sse.rs create mode 100644 packages/fullstack/src/payloads/stream.rs create mode 100644 packages/fullstack/src/payloads/text.rs create mode 100644 packages/fullstack/src/payloads/websocket.rs create mode 100644 packages/fullstack/src/request.rs create mode 100644 packages/fullstack/src/spawn.rs create mode 100644 packages/fullstack/tests/compile-test.rs create mode 100644 packages/hooks/src/use_action.rs create mode 100644 packages/hooks/src/use_waker.rs delete mode 100644 packages/isrg/Cargo.toml delete mode 100644 packages/server-macro/src/lib.rs delete mode 100644 packages/server/Cargo.toml delete mode 100644 packages/server/docs/request_origin.md delete mode 100644 packages/server/src/context.rs delete mode 100644 packages/server/src/lib.rs delete mode 100644 packages/web/src/file_engine.rs create mode 100644 packages/web/src/files.rs diff --git a/.github/actions/free-disk-space/action.yml b/.github/actions/free-disk-space/action.yml index 85d4c62e7..4ad49b3bf 100644 --- a/.github/actions/free-disk-space/action.yml +++ b/.github/actions/free-disk-space/action.yml @@ -12,3 +12,6 @@ runs: sudo rm -rf /usr/share/dotnet sudo rm -rf /usr/local/lib/android sudo rm -rf /usr/share/swift + sudo docker image prune --all --force || true + + diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c046c1ed6..4a1b877e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -45,7 +45,7 @@ jobs: check-msrv: if: github.event.pull_request.draft == false name: Check MSRV - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.86.0 @@ -66,7 +66,7 @@ jobs: test: if: github.event.pull_request.draft == false name: Test Suite - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - name: Free Disk Space @@ -87,7 +87,7 @@ jobs: release-test: if: github.event.pull_request.draft == false name: Test Suite with Optimizations - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - name: Free Disk Space @@ -108,7 +108,7 @@ jobs: fmt: if: github.event.pull_request.draft == false name: Rustfmt - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - uses: dtolnay/rust-toolchain@1.86.0 @@ -122,7 +122,7 @@ jobs: docs: if: github.event.pull_request.draft == false name: Docs - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - uses: awalsh128/cache-apt-pkgs-action@latest @@ -145,7 +145,7 @@ jobs: test-docs: if: github.event.pull_request.draft == false name: Test Docs - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - uses: awalsh128/cache-apt-pkgs-action@latest @@ -166,7 +166,7 @@ jobs: check: if: github.event.pull_request.draft == false name: Check - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - uses: awalsh128/cache-apt-pkgs-action@latest @@ -182,7 +182,7 @@ jobs: clippy: if: github.event.pull_request.draft == false name: Clippy - runs-on: ubuntu-24.04 + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - uses: awalsh128/cache-apt-pkgs-action@latest @@ -202,7 +202,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-24.04, macos-latest] + os: [blacksmith-4vcpu-ubuntu-2404, macos-latest] steps: - uses: actions/checkout@v5 - uses: nixbuild/nix-quick-install-action@master @@ -220,7 +220,7 @@ jobs: playwright: if: github.event.pull_request.draft == false name: Playwright Tests - runs-on: macos-latest + runs-on: blacksmith-8vcpu-ubuntu-2404 strategy: matrix: platform: @@ -229,6 +229,9 @@ jobs: steps: # Do our best to cache the toolchain and node install steps - uses: actions/checkout@v5 + - name: Free Disk Space + if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404-arm' }} + uses: ./.github/actions/free-disk-space - uses: actions/setup-node@v4 with: node-version: 24 @@ -297,18 +300,9 @@ jobs: args: "--package dioxus-desktop", platform: "ios", } - - { - target: x86_64-unknown-linux-gnu, - os: ubuntu-24.04, - toolchain: "1.86.0", - cross: false, - command: "build", - args: "--all --tests", - platform: "desktop", - } - { target: aarch64-unknown-linux-gnu, - os: ubuntu-24.04-arm, + os: blacksmith-4vcpu-ubuntu-2404-arm, toolchain: "1.86.0", cross: false, command: "build", @@ -317,16 +311,36 @@ jobs: } - { target: aarch64-linux-android, - os: ubuntu-24.04, + os: blacksmith-4vcpu-ubuntu-2404, toolchain: "1.86.0", cross: true, command: "build", args: "--package dioxus-desktop", platform: "android", } + # commented out because it's having issues with space on the device, but we already test it above + # - { + # target: x86_64-unknown-linux-gnu, + # os: ubuntu-24.04, + # toolchain: "1.86.0", + # cross: false, + # command: "build", + # args: "--all --tests", + # platform: "desktop", + # } steps: - uses: actions/checkout@v5 + - name: Free Disk Space + if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404-arm' }} + uses: ./.github/actions/free-disk-space + + - uses: awalsh128/cache-apt-pkgs-action@latest + if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404' || matrix.platform.os == 'blacksmith-4vcpu-ubuntu-2404-arm' }} + with: + packages: libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev libudev-dev libayatana-appindicator3-dev libxdo-dev libglib2.0-dev + version: ${{ matrix.platform.target }}-${{ matrix.platform.os }} # disambiguate since we're in a matrix and this caching action doesn't factor in these variables + - name: install stable uses: dtolnay/rust-toolchain@master with: @@ -338,16 +352,6 @@ jobs: if: ${{ matrix.platform.target == 'x86_64-pc-windows-msvc' }} uses: ilammy/setup-nasm@v1 - - name: Free Disk Space - if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' }} - uses: ./.github/actions/free-disk-space - - - uses: awalsh128/cache-apt-pkgs-action@latest - if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' }} - with: - packages: libwebkit2gtk-4.1-dev libgtk-3-dev libasound2-dev libudev-dev libayatana-appindicator3-dev libxdo-dev libglib2.0-dev - version: ${{ matrix.platform.target }}-${{ matrix.platform.os }} # disambiguate since we're in a matrix and this caching action doesn't factor in these variables - - name: Install cross if: ${{ matrix.platform.cross == true }} uses: taiki-e/install-action@cross @@ -386,126 +390,3 @@ jobs: working-directory: ${{ env.UV_WORKSPACE }} run: | cargo test --workspace --tests - - # Only run semver checks if the PR is not a draft and does not have the breaking label - # Breaking PRs don't need to follow semver since they are breaking changes - # However, this means we won't attempt to backport them, so you should be careful about using this label, as it will - # likely make future backporting difficult - # - # todo: fix this so even if breaking changes have been merged, the fix can be backported - # - # This will stop working once the first breaking change has been merged, so we should really try to just backport the fix - # and *then* run the semver checks. Basically "would backporting this PR cause a breaking change on stable?" - # - # semver: - # if: github.event.pull_request.draft == false && !contains(github.event.pull_request.labels.*.name, 'breaking') - # name: Semver Check - # runs-on: ubuntu-24.04 - # steps: - # - uses: actions/checkout@v5 - # - uses: dtolnay/rust-toolchain@1.86.0 - # - uses: Swatinem/rust-cache@v2 - # with: - # cache-all-crates: "true" - # cache-on-failure: "true" - # - name: Check semver - # uses: obi1kenobi/cargo-semver-checks-action@v2 - # with: - # manifest-path: ./Cargo.toml - # exclude: "dioxus-cli, dioxus-ext" - - # Releases for the CLI on a per-branch basis - # nightly-cli: - # if: github.event.pull_request.draft == false - # runs-on: ${{ matrix.platform.os }} - # strategy: - # matrix: - # platform: - # - { - # target: x86_64-pc-windows-msvc, - # os: windows-latest - # } - # - { - # target: aarch64-pc-windows-msvc, - # os: windows-latest - # } - # - { - # target: x86_64-apple-darwin, - # os: macos-13 - # } - # - { - # target: aarch64-apple-darwin, - # os: macos-latest - # } - # - { - # target: x86_64-unknown-linux-gnu, - # os: ubuntu-24.04 - # } - # - { - # target: aarch64-unknown-linux-gnu, - # os: ubuntu-24.04-arm - # } - # - { - # target: x86_64-unknown-linux-musl, - # os: ubuntu-24.04 - # } - # - { - # target: aarch64-unknown-linux-musl, - # os: ubuntu-24.04-arm - # } - # steps: - # - name: Checkout - # uses: actions/checkout@v5 - - # - name: Install openssl on macos - # if: matrix.platform.os == 'macos-latest' - # run: brew install openssl - - # - name: Install nasm for windows (tls) - # if: ${{ matrix.platform.target == 'x86_64-pc-windows-msvc' }} - # uses: ilammy/setup-nasm@v1 - - # - name: Free Disk Space - # if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' }} - # uses: ./.github/actions/free-disk-space - - # - uses: awalsh128/cache-apt-pkgs-action@latest - # if: ${{ matrix.platform.os == 'ubuntu-24.04' || matrix.platform.os == 'ubuntu-24.04-arm' }} - # with: - # packages: libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev libxdo-dev libglib2.0-dev musl-tools - # version: 1.0 - - # - name: Install stable - # uses: dtolnay/rust-toolchain@master - # with: - # toolchain: "1.86.0" - # targets: ${{ matrix.platform.target }} - - # - uses: Swatinem/rust-cache@v2 - # with: - # cache-all-crates: "true" - # save-if: ${{ github.ref == 'refs/heads/main' }} - - # - name: Free Disk Space - # uses: jlumbroso/free-disk-space@v1.3.1 - # with: # speed things up a bit - # large-packages: false - # docker-images: false - # swap-storage: false - - # # Todo: we want `cargo install dx` to actually just use a prebuilt binary instead of building it - # - name: Build CLI - # run: | - # cargo build --release --package dioxus-cli --target ${{ matrix.platform.target }} - - # - name: Upload CLI binaries - # uses: actions/upload-artifact@v4 - # if: always() - # with: - # name: dx-${{ matrix.platform.target }}-${{ github.event.number || github.ref_name }} - # path: ./target/${{ matrix.platform.target }}/release/dx - # retention-days: ${{ github.ref == 'refs/heads/main' && 90 || 30 }} - # overwrite: true - - # - name: Output artifact ID - # run: echo 'Artifact ID is ${{ steps.artifact-upload-step.outputs.artifact-id }}' diff --git a/.github/workflows/merge.yml b/.github/workflows/merge.yml index 650b856a4..d89b3c2d9 100644 --- a/.github/workflows/merge.yml +++ b/.github/workflows/merge.yml @@ -23,7 +23,7 @@ jobs: # Attempt to backport a merged pull request to the latest stable release backport: name: Backport pull request - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 # Don't run on closed unmerged pull requests, or pull requests with the "breaking" label if: github.event.pull_request.merged && !contains(github.event.pull_request.labels.*.name, 'breaking') @@ -34,7 +34,7 @@ jobs: # Upload nightly docs to the website docs: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write steps: diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml index e5be84f54..627c11e5a 100644 --- a/.github/workflows/promote.yml +++ b/.github/workflows/promote.yml @@ -22,7 +22,7 @@ permissions: jobs: promote: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - name: Publish the next pre-release diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8dc745da8..b450ea6e7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,9 +58,9 @@ jobs: - target: aarch64-apple-darwin os: macos-latest - target: x86_64-unknown-linux-gnu - os: ubuntu-24.04 + os: blacksmith-4vcpu-ubuntu-2404 - target: aarch64-unknown-linux-gnu - os: ubuntu-24.04-arm + os: blacksmith-4vcpu-ubuntu-2404 # - target: x86_64-unknown-linux-musl # os: ubuntu-24.04 # - target: aarch64-unknown-linux-musl diff --git a/.github/workflows/typos.yml b/.github/workflows/typos.yml index b7aec377b..2ff4ef0ac 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -16,7 +16,7 @@ jobs: typos: if: github.event.pull_request.draft == false name: Check for typos - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - name: Check for typos @@ -26,7 +26,7 @@ jobs: link-check: if: github.event.pull_request.draft == false name: Check For Invalid Links - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - uses: actions/checkout@v5 - name: Restore lychee cache diff --git a/.gitignore b/.gitignore index df4b5192c..2fbbcaf05 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /dist .DS_Store /examples/assets/test_video.mp4 +/examples/_assets/test_video.mp4 static # new recommendation to keep the lockfile in for CI and reproducible builds diff --git a/Cargo.lock b/Cargo.lock index c4dcc2118..e001c70b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,6 +1081,22 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "async-tungstenite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee88b4c88ac8c9ea446ad43498955750a4bbe64c4392f21ccfe5d952865e318f" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.27.0", +] + [[package]] name = "atk" version = "0.18.2" @@ -1122,6 +1138,15 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1256,7 +1281,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fcc8f365936c834db5514fc45aee5b1202d677e6b40e48468aaaa8183ca8c7" dependencies = [ "aws-lc-sys", - "untrusted 0.7.1", "zeroize", ] @@ -1307,6 +1331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core 0.5.2", + "axum-macros", "base64 0.22.1", "bytes", "form_urlencoded", @@ -1401,6 +1426,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "axum-server" version = "0.7.2" @@ -2427,7 +2463,7 @@ dependencies = [ "derive_more 1.0.0", "futures-channel", "futures-lite", - "heapless", + "heapless 0.8.0", "pin-project", "wasm-bindgen-futures", ] @@ -2849,7 +2885,7 @@ checksum = "2434788de88b5b6b10a4b71b08284be3126633caa53c15063a2bf322ef6c6202" dependencies = [ "blitz-traits", "data-url 0.3.1", - "reqwest 0.12.22", + "reqwest 0.12.23", "tokio", ] @@ -3180,8 +3216,20 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" dependencies = [ - "bytecheck_derive", - "ptr_meta", + "bytecheck_derive 0.6.12", + "ptr_meta 0.1.4", + "simdutf8", +] + +[[package]] +name = "bytecheck" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50690fb3370fb9fe3550372746084c46f2ac8c9685c583d2be10eefd89d3d1a3" +dependencies = [ + "bytecheck_derive 0.8.1", + "ptr_meta 0.3.0", + "rancor", "simdutf8", ] @@ -3196,6 +3244,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "bytecheck_derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb7846e0cb180355c2dec69e721edafa36919850f1a9f52ffba4ebc0393cb71" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "bytemuck" version = "1.23.1" @@ -3233,6 +3292,9 @@ name = "bytes" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +dependencies = [ + "serde", +] [[package]] name = "bytes-str" @@ -3559,6 +3621,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "charset" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f927b07c74ba84c7e5fe4db2baeb3e996ab2688992e39ac68ce3220a677c7e" +dependencies = [ + "base64 0.22.1", + "encoding_rs", +] + [[package]] name = "chrono" version = "0.4.41" @@ -3683,6 +3755,15 @@ dependencies = [ "cc", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.12", +] + [[package]] name = "cocoa" version = "0.26.1" @@ -3941,9 +4022,9 @@ dependencies = [ [[package]] name = "const-str" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e991226a70654b49d34de5ed064885f0bef0348a8e70018b8ff1ac80aa984a2" +checksum = "f4d34b8f066904ed7cfa4a6f9ee96c3214aa998cb44b69ca20bd2054f47402ed" [[package]] name = "const-str-proc-macro" @@ -4003,6 +4084,15 @@ dependencies = [ "const_soft_float", ] +[[package]] +name = "content_disposition" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc14a88e1463ddd193906285abe5c360c7e8564e05ccc5d501755f7fbc9ca9c" +dependencies = [ + "charset", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -4060,6 +4150,24 @@ dependencies = [ "futures", ] +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -4933,6 +5041,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.104", + "unicode-xid", ] [[package]] @@ -4997,6 +5106,7 @@ dependencies = [ "dioxus-devtools", "dioxus-document", "dioxus-fullstack", + "dioxus-fullstack-macro", "dioxus-history", "dioxus-hooks", "dioxus-html", @@ -5009,7 +5119,6 @@ dependencies = [ "dioxus-ssr", "dioxus-stores", "dioxus-web", - "dioxus_server_macro", "env_logger 0.11.8", "futures-util", "manganis", @@ -5150,7 +5259,7 @@ dependencies = [ "ratatui", "rayon", "regex", - "reqwest 0.12.22", + "reqwest 0.12.23", "rustls 0.23.29", "self-replace", "self_update", @@ -5251,11 +5360,8 @@ name = "dioxus-cli-optimization-test" version = "0.0.1" dependencies = [ "dioxus", - "flate2", - "reqwest 0.12.22", "serde", "serde_json", - "tar", ] [[package]] @@ -5286,6 +5392,7 @@ version = "0.7.0-rc.0" name = "dioxus-core" version = "0.7.0-rc.0" dependencies = [ + "anyhow", "const_format", "dioxus", "dioxus-core-types", @@ -5297,7 +5404,7 @@ dependencies = [ "longest-increasing-subsequence", "pretty_assertions", "rand 0.9.1", - "reqwest 0.12.22", + "reqwest 0.12.23", "rustc-hash 2.1.1", "rustversion", "serde", @@ -5339,6 +5446,7 @@ version = "0.7.0-rc.0" dependencies = [ "async-trait", "base64 0.22.1", + "bytes", "cocoa", "core-foundation 0.10.1", "dioxus", @@ -5372,7 +5480,7 @@ dependencies = [ "objc_id", "percent-encoding", "rand 0.9.1", - "reqwest 0.12.22", + "reqwest 0.12.23", "rfd", "rustc-hash 2.1.1", "separator", @@ -5453,24 +5561,33 @@ dependencies = [ name = "dioxus-examples" version = "0.7.0-rc.0" dependencies = [ + "anyhow", "async-std", + "axum-core 0.5.2", "base64 0.22.1", + "bytes", "ciborium", "dioxus", + "dioxus-html", "dioxus-ssr", "dioxus-stores", "form_urlencoded", + "futures", "futures-util", "getrandom 0.3.3", "http-range", "ouroboros", "rand 0.9.1", - "reqwest 0.12.22", + "reqwest 0.12.23", "separator", "serde", "serde_json", + "sqlx", + "thiserror 2.0.12", "tokio", + "uuid", "wasm-splitter", + "wasm-streams", "web-time", "wgpu", ] @@ -5490,75 +5607,109 @@ dependencies = [ name = "dioxus-fullstack" version = "0.7.0-rc.0" dependencies = [ - "async-trait", - "aws-lc-rs", + "anyhow", + "async-stream", + "async-tungstenite", "axum 0.8.4", + "axum-core 0.5.2", + "axum-extra", "base64 0.22.1", "bytes", "ciborium", + "const-str 0.7.0", + "const_format", + "content_disposition", + "derive_more 2.0.1", "dioxus", + "dioxus-asset-resolver", "dioxus-cli-config", "dioxus-core", - "dioxus-desktop", - "dioxus-devtools", - "dioxus-document", - "dioxus-fullstack-hooks", - "dioxus-fullstack-protocol", - "dioxus-history", - "dioxus-interpreter-js", - "dioxus-isrg", - "dioxus-router", + "dioxus-fullstack-core", + "dioxus-fullstack-macro", + "dioxus-hooks", + "dioxus-html", "dioxus-server", - "dioxus-web", - "dioxus_server_macro", + "dioxus-signals", + "form_urlencoded", + "futures", "futures-channel", "futures-util", - "generational-box", + "gloo-net", + "headers", "http 1.3.1", - "hyper-rustls 0.27.7", - "parking_lot", + "http-body 1.0.1", + "http-body-util", + "inventory", + "js-sys", + "mime", "pin-project", - "rustls 0.23.29", + "postcard", + "reqwest 0.12.23", + "rmp-serde", + "rustversion", + "send_wrapper", "serde", - "server_fn", + "serde_json", + "serde_qs", + "serde_urlencoded", "thiserror 2.0.12", "tokio", "tokio-stream", + "tokio-tungstenite 0.27.0", "tokio-util", "tower 0.5.2", "tower-http", "tower-layer", "tracing", - "tracing-futures", + "tungstenite 0.27.0", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", "web-sys", + "xxhash-rust", ] [[package]] -name = "dioxus-fullstack-hooks" +name = "dioxus-fullstack-core" version = "0.7.0-rc.0" dependencies = [ + "anyhow", + "axum-core 0.5.2", + "base64 0.22.1", + "ciborium", "dioxus", "dioxus-core", "dioxus-document", "dioxus-fullstack", - "dioxus-fullstack-protocol", "dioxus-history", "dioxus-hooks", "dioxus-signals", "futures-channel", "futures-util", + "generational-box", + "http 1.3.1", + "inventory", "serde", + "serde_json", + "thiserror 2.0.12", + "tracing", ] [[package]] -name = "dioxus-fullstack-protocol" +name = "dioxus-fullstack-macro" version = "0.7.0-rc.0" dependencies = [ - "base64 0.22.1", - "ciborium", - "dioxus-core", + "axum 0.8.4", + "const_format", + "convert_case 0.8.0", + "dioxus", + "proc-macro2", + "quote", "serde", - "tracing", + "syn 2.0.104", + "tower-http", + "xxhash-rust", ] [[package]] @@ -5580,7 +5731,7 @@ dependencies = [ "futures-channel", "futures-util", "generational-box", - "reqwest 0.12.22", + "reqwest 0.12.23", "rustversion", "slab", "tokio", @@ -5594,6 +5745,7 @@ name = "dioxus-html" version = "0.7.0-rc.0" dependencies = [ "async-trait", + "bytes", "dioxus", "dioxus-core", "dioxus-core-macro", @@ -5605,6 +5757,7 @@ dependencies = [ "enumset", "euclid", "futures-channel", + "futures-util", "generational-box", "js-sys", "keyboard-types", @@ -5647,19 +5800,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "dioxus-isrg" -version = "0.7.0-rc.0" -dependencies = [ - "chrono", - "http 1.3.1", - "lru 0.16.0", - "rustc-hash 2.1.1", - "thiserror 2.0.12", - "tracing", - "walkdir", -] - [[package]] name = "dioxus-liveview" version = "0.7.0-rc.0" @@ -5842,7 +5982,7 @@ dependencies = [ "dioxus-cli-config", "dioxus-core", "dioxus-core-macro", - "dioxus-fullstack-hooks", + "dioxus-fullstack-core", "dioxus-history", "dioxus-hooks", "dioxus-html", @@ -5919,11 +6059,12 @@ dependencies = [ name = "dioxus-server" version = "0.7.0-rc.0" dependencies = [ + "anyhow", "async-trait", - "aws-lc-rs", "axum 0.8.4", "base64 0.22.1", "bytes", + "chrono", "ciborium", "dashmap 6.1.0", "dioxus", @@ -5932,40 +6073,48 @@ dependencies = [ "dioxus-core-macro", "dioxus-devtools", "dioxus-document", - "dioxus-fullstack-hooks", - "dioxus-fullstack-protocol", + "dioxus-fullstack-core", "dioxus-history", + "dioxus-hooks", "dioxus-html", "dioxus-interpreter-js", - "dioxus-isrg", + "dioxus-logger", "dioxus-router", "dioxus-signals", "dioxus-ssr", "enumset", + "futures", "futures-channel", "futures-util", "generational-box", "http 1.3.1", + "http-body-util", "hyper 1.6.0", "hyper-rustls 0.27.7", "hyper-util", "inventory", + "lru 0.16.1", + "multer", "parking_lot", "pin-project", + "rkyv 0.8.11", + "rustc-hash 2.1.1", "rustls 0.23.29", "serde", - "server_fn", + "serde_json", + "serde_qs", "subsecond", "thiserror 2.0.12", "tokio", - "tokio-stream", + "tokio-tungstenite 0.27.0", "tokio-util", "tower 0.5.2", "tower-http", "tower-layer", "tracing", "tracing-futures", - "web-sys", + "url", + "walkdir", ] [[package]] @@ -5979,7 +6128,7 @@ dependencies = [ "generational-box", "parking_lot", "rand 0.9.1", - "reqwest 0.12.22", + "reqwest 0.12.23", "rustc-hash 2.1.1", "serde", "tokio", @@ -6033,7 +6182,6 @@ dependencies = [ name = "dioxus-web" version = "0.7.0-rc.0" dependencies = [ - "async-trait", "ciborium", "dioxus", "dioxus-cli-config", @@ -6041,8 +6189,7 @@ dependencies = [ "dioxus-core-types", "dioxus-devtools", "dioxus-document", - "dioxus-fullstack-hooks", - "dioxus-fullstack-protocol", + "dioxus-fullstack-core", "dioxus-history", "dioxus-html", "dioxus-interpreter-js", @@ -6057,6 +6204,7 @@ dependencies = [ "js-sys", "lazy-js-bundle", "rustc-hash 2.1.1", + "send_wrapper", "serde", "serde-wasm-bindgen", "serde_json", @@ -6065,23 +6213,10 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", + "wasm-streams", "web-sys", ] -[[package]] -name = "dioxus_server_macro" -version = "0.7.0-rc.0" -dependencies = [ - "axum 0.8.4", - "dioxus", - "proc-macro2", - "quote", - "serde", - "server_fn_macro", - "syn 2.0.104", - "tower-http", -] - [[package]] name = "dircpy" version = "0.3.19" @@ -6333,7 +6468,7 @@ version = "0.1.1" dependencies = [ "chrono", "dioxus", - "reqwest 0.12.22", + "reqwest 0.12.23", "serde", ] @@ -6403,6 +6538,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encase" version = "0.10.0" @@ -6759,7 +6906,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" dependencies = [ - "heapless", + "heapless 0.8.0", "serde", ] @@ -7106,8 +7253,27 @@ dependencies = [ "axum_session_auth", "axum_session_sqlx", "dioxus", - "dioxus-cli-config", - "dioxus-fullstack", + "dioxus-web", + "execute", + "http 1.3.1", + "serde", + "sqlx", + "tokio", + "tower 0.5.2", + "tower-http", +] + +[[package]] +name = "fullstack-auth-example-with-fullstack" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.4", + "axum_session", + "axum_session_auth", + "axum_session_sqlx", + "dioxus", "dioxus-web", "execute", "http 1.3.1", @@ -7132,7 +7298,7 @@ version = "0.1.0" dependencies = [ "chrono", "dioxus", - "reqwest 0.12.22", + "reqwest 0.12.23", "serde", ] @@ -7140,9 +7306,12 @@ dependencies = [ name = "fullstack-hello-world-example" version = "0.1.0" dependencies = [ + "anyhow", "dioxus", - "reqwest 0.12.22", + "reqwest 0.12.23", "serde", + "serde_json", + "thiserror 2.0.12", ] [[package]] @@ -7155,26 +7324,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "fullstack-streaming-example" -version = "0.1.0" -dependencies = [ - "dioxus", - "futures", - "futures-util", - "serde", - "tokio", -] - -[[package]] -name = "fullstack-websocket-example" -version = "0.1.0" -dependencies = [ - "dioxus", - "futures", - "tokio", -] - [[package]] name = "funty" version = "2.0.0" @@ -8359,6 +8508,135 @@ dependencies = [ "thiserror 2.0.12", ] +[[package]] +name = "harness-default-to-non-default" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-fullstack-desktop" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-fullstack-desktop-with-default" +version = "0.0.1" +dependencies = [ + "anyhow", + "dioxus", +] + +[[package]] +name = "harness-fullstack-desktop-with-features" +version = "0.0.1" +dependencies = [ + "anyhow", + "dioxus", +] + +[[package]] +name = "harness-fullstack-multi-target" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-fullstack-multi-target-no-default" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-fullstack-with-optional-tokio" +version = "0.0.1" +dependencies = [ + "dioxus", + "serde", + "tokio", +] + +[[package]] +name = "harness-no-dioxus" +version = "0.0.1" +dependencies = [ + "anyhow", +] + +[[package]] +name = "harness-renderer-swap" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-dedicated-client" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-dedicated-server" +version = "0.0.1" + +[[package]] +name = "harness-simple-desktop" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-fullstack" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-fullstack-native-with-default" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-fullstack-with-default" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-mobile" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "harness-simple-web" +version = "0.0.1" +dependencies = [ + "dioxus", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hash32" version = "0.3.1" @@ -8464,13 +8742,27 @@ dependencies = [ "http 1.3.1", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" dependencies = [ - "hash32", + "hash32 0.3.1", "portable-atomic", "stable_deref_trait", ] @@ -8547,8 +8839,9 @@ dependencies = [ name = "hotdog" version = "0.1.0" dependencies = [ + "anyhow", "dioxus", - "reqwest 0.12.22", + "reqwest 0.12.23", "rusqlite", "serde", "serde_json", @@ -10256,9 +10549,9 @@ dependencies = [ [[package]] name = "lru" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ea4e65087ff52f3862caff188d489f1fab49a0cb09e01b2e3f1a617b10aaed" +checksum = "bfe949189f46fabb938b3a9a0be30fdd93fd8a09260da863399a8cf3db756ec8" dependencies = [ "hashbrown 0.15.4", ] @@ -10688,6 +10981,26 @@ dependencies = [ "version_check", ] +[[package]] +name = "munge" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7feb0b48aa0a25f9fe0899482c6e1379ee7a11b24a53073eacdecb9adb6dc60" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2e3795a5d2da581a8b252fec6022eee01aea10161a4d1bf237d4cbe47f7e988" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "naga" version = "24.0.0" @@ -11980,7 +12293,7 @@ checksum = "485b74d7218068b2b7c0e3ff12fbc61ae11d57cb5d8224f525bd304c6be05bbb" dependencies = [ "base64-simd 0.7.0", "data-url 0.1.1", - "rkyv", + "rkyv 0.7.45", "serde", "serde_json", "vlq", @@ -12596,6 +12909,19 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + [[package]] name = "posthog-rs" version = "0.3.5" @@ -12836,6 +13162,12 @@ dependencies = [ "prost", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "psm" version = "0.1.26" @@ -12851,7 +13183,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" dependencies = [ - "ptr_meta_derive", + "ptr_meta_derive 0.1.4", +] + +[[package]] +name = "ptr_meta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9e76f66d3f9606f44e45598d155cb13ecf09f4a28199e48daf8c8fc937ea90" +dependencies = [ + "ptr_meta_derive 0.3.0", ] [[package]] @@ -12865,6 +13206,27 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ptr_meta_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca414edb151b4c8d125c12566ab0d74dc9cdba36fb80eb7b848c15f495fd32d1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "qoi" version = "0.4.1" @@ -12996,6 +13358,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" +[[package]] +name = "rancor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf5f7161924b9d1cea0e4cabc97c372cea92b5f927fc13c6bca67157a0ad947" +dependencies = [ + "ptr_meta 0.3.0", +] + [[package]] name = "rand" version = "0.7.3" @@ -13434,7 +13805,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" dependencies = [ - "bytecheck", + "bytecheck 0.6.12", +] + +[[package]] +name = "rend" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a35e8a6bf28cd121053a66aa2e6a2e3eaffad4a60012179f0e864aa5ffeff215" +dependencies = [ + "bytecheck 0.8.1", ] [[package]] @@ -13487,12 +13867,14 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", "encoding_rs", "futures-channel", "futures-core", @@ -13508,7 +13890,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -13615,7 +13996,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.16", "libc", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] @@ -13635,17 +14016,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" dependencies = [ "bitvec", - "bytecheck", + "bytecheck 0.6.12", "bytes", "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", + "ptr_meta 0.1.4", + "rend 0.4.2", + "rkyv_derive 0.7.45", "seahash", "tinyvec", "uuid", ] +[[package]] +name = "rkyv" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f5c3e5da784cd8c69d32cdc84673f3204536ca56e1fa01be31a74b92c932ac" +dependencies = [ + "bytecheck 0.8.1", + "bytes", + "hashbrown 0.15.4", + "indexmap 2.10.0", + "munge", + "ptr_meta 0.3.0", + "rancor", + "rend 0.5.2", + "rkyv_derive 0.8.11", + "tinyvec", + "uuid", +] + [[package]] name = "rkyv_derive" version = "0.7.45" @@ -13657,6 +14057,39 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rkyv_derive" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4270433626cffc9c4c1d3707dd681f2a2718d3d7b09ad754bec137acecda8d22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + +[[package]] +name = "rmp" +version = "0.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "228ed7c16fa39782c3b3468e974aec2795e9089153cd08ee2e9aefb3613334c4" +dependencies = [ + "byteorder", + "num-traits", + "paste", +] + +[[package]] +name = "rmp-serde" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e599a477cf9840e92f2cde9a7189e67b42c57532749bf90aea6ec10facd4db" +dependencies = [ + "byteorder", + "rmp", + "serde", +] + [[package]] name = "rodio" version = "0.20.1" @@ -13760,7 +14193,7 @@ dependencies = [ "bytes", "num-traits", "rand 0.8.5", - "rkyv", + "rkyv 0.7.45", "serde", "serde_json", ] @@ -13941,7 +14374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ "ring", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -13952,7 +14385,7 @@ checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -13964,7 +14397,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -14142,7 +14575,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ "ring", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -14283,7 +14716,7 @@ dependencies = [ "log", "quick-xml 0.37.5", "regex", - "reqwest 0.12.22", + "reqwest 0.12.23", "self-replace", "semver", "serde_json", @@ -14580,73 +15013,6 @@ dependencies = [ "unsafe-libyaml", ] -[[package]] -name = "server_fn" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c27fbd25ecc066481e383e2ed62ab2480e708aa3fe46cba36e95f58e61dfd04" -dependencies = [ - "axum 0.8.4", - "base64 0.22.1", - "bytes", - "const-str 0.6.2", - "const_format", - "dashmap 6.1.0", - "futures", - "gloo-net", - "http 1.3.1", - "http-body-util", - "hyper 1.6.0", - "inventory", - "js-sys", - "pin-project-lite", - "reqwest 0.12.22", - "rustc_version", - "rustversion", - "send_wrapper", - "serde", - "serde_json", - "serde_qs", - "server_fn_macro_default", - "thiserror 2.0.12", - "throw_error", - "tokio", - "tokio-tungstenite 0.27.0", - "tower 0.5.2", - "tower-layer", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "xxhash-rust", -] - -[[package]] -name = "server_fn_macro" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d530c872590473016016679c94e3bddf47372941fc924f687ffd11a1778a71" -dependencies = [ - "const_format", - "convert_case 0.8.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.104", - "xxhash-rust", -] - -[[package]] -name = "server_fn_macro_default" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca7abc92ed696648275ed9ff171131a83d571af11748593dc2e6eb6a4e22a5b9" -dependencies = [ - "server_fn_macro", - "syn 2.0.104", -] - [[package]] name = "servo_arc" version = "0.2.0" @@ -15279,6 +15645,13 @@ dependencies = [ "uuid", ] +[[package]] +name = "ssr-only" +version = "0.7.0-rc.0" +dependencies = [ + "dioxus", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -16643,15 +17016,6 @@ dependencies = [ "ratatui", ] -[[package]] -name = "throw_error" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41e42a6afdde94f3e656fae18f837cb9bbe500a5ac5de325b09f3ec05b9c28e3" -dependencies = [ - "pin-project-lite", -] - [[package]] name = "tiff" version = "0.9.1" @@ -16893,7 +17257,6 @@ checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" dependencies = [ "futures-util", "log", - "rustls 0.23.29", "tokio", "tungstenite 0.27.0", ] @@ -17656,12 +18019,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -18214,7 +18571,7 @@ dependencies = [ "futures", "getrandom 0.3.3", "js-sys", - "reqwest 0.12.22", + "reqwest 0.12.23", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index 8434662d9..3cbf63791 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,9 @@ members = [ "packages/document", "packages/extension", "packages/fullstack", - "packages/fullstack-hooks", - "packages/fullstack-protocol", + "packages/fullstack-core", + "packages/fullstack-macro", + "packages/fullstack-server", "packages/generational-box", "packages/history", "packages/hooks", @@ -50,7 +51,6 @@ members = [ "packages/rsx-hotreload", "packages/rsx-rosetta", "packages/rsx", - "packages/server-macro", "packages/signals", "packages/stores", "packages/stores-macro", @@ -59,7 +59,6 @@ members = [ "packages/cli-config", "packages/devtools", "packages/devtools-types", - "packages/isrg", "packages/rsx-hotreload", "packages/const-serialize", "packages/const-serialize-macro", @@ -70,7 +69,9 @@ members = [ "packages/native-dom", "packages/asset-resolver", "packages/depinfo", - "packages/server", + + # CLI harnesses, all included + "packages/cli-harnesses/*", # Playwright tests "packages/playwright-tests/liveview", @@ -98,25 +99,27 @@ members = [ "packages/subsecond/subsecond-tests/cross-tls-test", # Full project examples - "examples/fullstack-hackernews", - "examples/ecommerce-site", - "examples/bluetooth-scanner", - "examples/file-explorer", - "examples/hotdog", + "examples/01-app-demos/hackernews", + "examples/01-app-demos/ecommerce-site", + "examples/01-app-demos/bluetooth-scanner", + "examples/01-app-demos/file-explorer", + "examples/01-app-demos/hotdog", - # Simple examples that require a crate - "examples/tailwind", - "examples/pwa", - "examples/fullstack-hello-world", - "examples/fullstack-router", - "examples/fullstack-streaming", - "examples/fullstack-desktop", - "examples/fullstack-auth", - "examples/fullstack-websockets", - "examples/wgpu-texture", - "examples/native-headless", - "examples/native-headless-in-bevy", - "examples/bevy", + # Fullstack examples + "examples/07-fullstack/hello-world", + "examples/07-fullstack/router", + "examples/07-fullstack/desktop", + "examples/07-fullstack/auth", + "examples/07-fullstack/auth-with-fullstack", + "examples/07-fullstack/ssr-only", + + # Integrations + "examples/10-integrations/tailwind", + "examples/10-integrations/pwa", + "examples/10-integrations/wgpu-texture", + "examples/10-integrations/native-headless", + "examples/10-integrations/native-headless-in-bevy", + "examples/10-integrations/bevy", # Playwright tests "packages/playwright-tests/liveview", @@ -141,7 +144,6 @@ version = "0.7.0-rc.0" # dependencies that are shared across packages [workspace.dependencies] dioxus = { path = "packages/dioxus", version = "0.7.0-rc.0" } -dioxus-lib = { path = "packages/dioxus-lib", version = "0.7.0-rc.0" } dioxus-core = { path = "packages/core", version = "0.7.0-rc.0" } dioxus-core-types = { path = "packages/core-types", version = "0.7.0-rc.0" } dioxus-core-macro = { path = "packages/core-macro", version = "0.7.0-rc.0" } @@ -154,10 +156,8 @@ dioxus-html = { path = "packages/html", version = "0.7.0-rc.0", default-features dioxus-html-internal-macro = { path = "packages/html-internal-macro", version = "0.7.0-rc.0" } dioxus-hooks = { path = "packages/hooks", version = "0.7.0-rc.0" } dioxus-web = { path = "packages/web", version = "0.7.0-rc.0", default-features = false } -dioxus-isrg = { path = "packages/isrg", version = "0.7.0-rc.0" } dioxus-ssr = { path = "packages/ssr", version = "0.7.0-rc.0", default-features = false } dioxus-desktop = { path = "packages/desktop", version = "0.7.0-rc.0", default-features = false } -dioxus-mobile = { path = "packages/mobile", version = "0.7.0-rc.0" } dioxus-interpreter-js = { path = "packages/interpreter", version = "0.7.0-rc.0" } dioxus-liveview = { path = "packages/liveview", version = "0.7.0-rc.0" } dioxus-autofmt = { path = "packages/autofmt", version = "0.7.0-rc.0" } @@ -170,11 +170,10 @@ dioxus-stores = { path = "packages/stores", version = "0.7.0-rc.0" } dioxus-stores-macro = { path = "packages/stores-macro", version = "0.7.0-rc.0" } dioxus-devtools = { path = "packages/devtools", version = "0.7.0-rc.0" } dioxus-devtools-types = { path = "packages/devtools-types", version = "0.7.0-rc.0" } -dioxus-server = { path = "packages/server", version = "0.7.0-rc.0" } -dioxus-fullstack = { path = "packages/fullstack", version = "0.7.0-rc.0" } -dioxus-fullstack-hooks = { path = "packages/fullstack-hooks", version = "0.7.0-rc.0" } -dioxus-fullstack-protocol = { path = "packages/fullstack-protocol", version = "0.7.0-rc.0" } -dioxus_server_macro = { path = "packages/server-macro", version = "0.7.0-rc.0", default-features = false } +dioxus-fullstack = { path = "packages/fullstack", version = "0.7.0-rc.0", default-features = false} +dioxus-fullstack-core = { path = "packages/fullstack-core", version = "0.7.0-rc.0", default-features = false} +dioxus-fullstack-macro = { path = "packages/fullstack-macro", version = "0.7.0-rc.0", default-features = false } +dioxus-server = { path = "packages/fullstack-server", version = "0.7.0-rc.0" } dioxus-dx-wire-format = { path = "packages/dx-wire-format", version = "0.7.0-rc.0" } dioxus-logger = { path = "packages/logger", version = "0.7.0-rc.0" } dioxus-native = { path = "packages/native", version = "0.7.0-rc.0" } @@ -207,7 +206,6 @@ manganis-macro = { path = "packages/manganis/manganis-macro", version = "0.7.0-r wasm-splitter = { path = "packages/wasm-split/wasm-split", version = "0.7.0-rc.0" } wasm-split-macro = { path = "packages/wasm-split/wasm-split-macro", version = "0.7.0-rc.0" } wasm-split-cli = { path = "packages/wasm-split/wasm-split-cli", version = "0.7.0-rc.0" } -wasm-split-harness = { path = "packages/playwright-tests/wasm-split-harness", version = "0.7.0-rc.0" } wasm-used = { path = "packages/wasm-split/wasm-used", version = "0.7.0-rc.0" } depinfo = { path = "packages/depinfo", version = "0.7.0-rc.0" } @@ -257,8 +255,7 @@ lru = "0.16.0" async-trait = "0.1.88" axum = { version = "0.8.4", default-features = false } axum-server = { version = "0.7.2", default-features = false } -server_fn = { version = "=0.8.3", default-features = false } -server_fn_macro = { version = "=0.8.3" } +http-body = { version = "1.0"} tower = "0.5.2" http = "1.3.1" notify = { version = "8.1.0" } @@ -277,12 +274,13 @@ serde_json = "1.0.140" serde = "1.0.219" syn = "2.0" quote = "1.0" +axum-core = "0.5" proc-macro2 = "1.0.101" axum_session = "0.16.0" axum_session_auth = "0.16.0" axum_session_sqlx = "0.5.0" axum-extra = "0.10.1" -reqwest = "0.12.22" +reqwest = { version = "0.12.23", default-features = false } owo-colors = "4.2.2" ciborium = "0.2.2" base64 = "0.22.1" @@ -291,13 +289,10 @@ convert_case = "0.8.0" tungstenite = { version = "0.27.0" } tokio-tungstenite = { version = "0.27.0" } gloo-timers = "0.3.0" -fluent-uri = { version = "0.3.2", features = ["serde"] } internment = { version = "0.8.6" } proc-macro2-diagnostics = { version = "0.10", default-features = false } env_logger = "0.11.8" chrono = { version = "0.4.39" } -gloo = { version = "0.11.0" } -gloo-utils = { version = "0.2.0" } rustversion = "1.0.21" rand = "0.9" longest-increasing-subsequence = "0.1.0" @@ -320,7 +315,6 @@ rayon = "1.10.0" wasmparser = "0.235.0" itertools = "0.14.0" object = { version = "0.37.1" } -bincode = "1.3.3" inventory = { version = "0.3" } macro-string = "0.1.4" walkdir = "2.5.0" @@ -334,6 +328,16 @@ libloading = "0.8.8" libc = "0.2.174" memmap2 = "0.9.5" memfd = "0.6.4" +xxhash-rust = {version = "0.8.15", default-features = false} +serde_qs = "0.15.0" +multer = "3.1.0" +const-str = "0.7.0" +bytes = "1.10" +send_wrapper = "0.6.0" +pin-project = { version = "1.1.10" } +postcard = { version = "1.1.3", default-features = false } +serde_urlencoded = "0.7" +form_urlencoded = "1.2.1" # desktop wry = { version = "0.52.1", default-features = false } @@ -341,13 +345,7 @@ tao = { version = "0.34.0", features = ["rwh_05"] } infer = "0.19.0" dunce = "1.0.5" percent-encoding = "2.3.1" -global-hotkey = "0.7.0" -rfd = { version = "0.15.3", default-features = false } muda = "0.17.0" -cocoa = "0.26" -core-foundation = "0.10.1" -objc = { version = "0.2.7", features = ["exception"] } -objc_id = "0.1.1" tray-icon = "0.21.0" open = "5.3.2" webbrowser = "1.0" @@ -362,14 +360,11 @@ path-absolutize = "3.1" crossterm = { version = "0.29.0" } ratatui = { version = "0.29.0" } shell-words = "1.1.0" -color-eyre = "0.6.5" # native keyboard-types = { version = "0.7", default-features = false } winit = { version = "0.30.11", features = ["rwh_06"] } -[profile.dev] - # our release profile should be fast to compile and fast to run # when we ship our CI builds, we turn on LTO which improves perf leftover by turning on incremental [profile.release] @@ -419,28 +414,40 @@ inherits = "dev" [package] name = "dioxus-examples" authors = ["Jonathan Kelley"] -edition = "2021" +edition = "2024" description = "Top level crate for the Dioxus repository" license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" documentation = "https://dioxuslabs.com" keywords = ["dom", "ui", "gui", "react", "wasm"] -rust-version = "1.80.0" +rust-version = "1.85.0" publish = false version = "0.7.0-rc.0" [dependencies] -reqwest = { workspace = true, features = ["json"], optional = true } +reqwest = { workspace = true, features = ["json"] } ciborium = { workspace = true, optional = true } base64 = { workspace = true, optional = true } -http-range = { version = "0.1.5", optional = true } +http-range = { version = "0.1.5" } wgpu = { workspace = true, optional = true } ouroboros = { version = "*", optional = true } -wasm-splitter = { workspace = true, package = "wasm-split" } +wasm-splitter = { workspace = true } + +sqlx = { version = "0.8.6", features = [ + "macros", + "migrate", + "postgres", + "sqlite", + "_unstable-all-types", + "tls-native-tls", + "runtime-tokio", +], optional = true } +wasm-streams = "0.4.2" [dev-dependencies] -dioxus = { workspace = true, features = ["router"] } +dioxus = { workspace = true, features = ["router", "fullstack"] } +dioxus-html = { workspace = true, features = ["serialize"] } dioxus-stores = { workspace = true } dioxus-ssr = { workspace = true } futures-util = { workspace = true } @@ -451,10 +458,17 @@ rand = { workspace = true, features = ["small_rng"] } form_urlencoded = "1.2.1" async-std = "1.13.1" web-time = "1.1.0" +anyhow = { workspace = true } +thiserror = { workspace = true } +bytes = { workspace = true } +futures = { workspace = true } +axum-core = { workspace = true } +uuid = { workspace = true, features = ["v4", "serde"] } [target.'cfg(target_arch = "wasm32")'.dev-dependencies] getrandom = { workspace = true, features = ["wasm_js"] } tokio = { version = "1.46", default-features = false, features = ["sync", "macros", "io-util", "rt", "time"] } +uuid = { workspace = true, features = ["v4", "serde", "js"] } [target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] tokio = { version = "1.46", features = ["full"] } @@ -466,161 +480,451 @@ default = ["desktop"] desktop = ["dioxus/desktop"] native = ["dioxus/native"] liveview = ["dioxus/liveview"] -fullstack = ["dioxus/fullstack"] server = ["dioxus/server"] mobile = ["dioxus/mobile"] web = ["dioxus/web"] -http = ["dep:reqwest", "dep:http-range"] gpu = ["dep:ouroboros", "dep:wgpu"] [[example]] -name = "login_form" -required-features = ["http"] -doc-scrape-examples = true - -[[example]] -name = "dog_app" -required-features = ["http"] -doc-scrape-examples = true - -[[example]] -name = "video_stream" -required-features = ["http", "desktop"] -doc-scrape-examples = true - -[[example]] -name = "suspense" -required-features = ["http", "desktop"] +name = "websocket_chat" +path = "examples/01-app-demos/websocket_chat.rs" doc-scrape-examples = true [[example]] name = "weather_app" -required-features = ["http"] +path = "examples/01-app-demos/weather_app.rs" +doc-scrape-examples = true + +[[example]] +name = "crm" +path = "examples/01-app-demos/crm.rs" doc-scrape-examples = true [[example]] name = "image_generator_openai" -required-features = ["http"] +path = "examples/01-app-demos/image_generator_openai.rs" +doc-scrape-examples = true + +[[example]] +name = "todomvc" +path = "examples/01-app-demos/todomvc.rs" +doc-scrape-examples = true + +[[example]] +name = "calculator_mutable" +path = "examples/01-app-demos/calculator_mutable.rs" +doc-scrape-examples = true + +[[example]] +name = "hello_world" +path = "examples/01-app-demos/hello_world.rs" +doc-scrape-examples = true + +[[example]] +name = "counters" +path = "examples/01-app-demos/counters.rs" +doc-scrape-examples = true + +[[example]] +name = "todomvc_store" +path = "examples/01-app-demos/todomvc_store.rs" +doc-scrape-examples = true + +[[example]] +name = "dog_app" +path = "examples/01-app-demos/dog_app.rs" +doc-scrape-examples = true + +[[example]] +name = "calculator" +path = "examples/01-app-demos/calculator.rs" +doc-scrape-examples = true + +[[example]] +name = "repo_readme" +path = "examples/01-app-demos/repo_readme.rs" +doc-scrape-examples = true + +[[example]] +name = "nested_listeners" +path = "examples/02-building-ui/nested_listeners.rs" +doc-scrape-examples = true + +[[example]] +name = "disabled" +path = "examples/02-building-ui/disabled.rs" +doc-scrape-examples = true + +[[example]] +name = "svg" +path = "examples/02-building-ui/svg.rs" +doc-scrape-examples = true + +[[example]] +name = "custom_assets" +path = "examples/03-assets-styling/custom_assets.rs" +doc-scrape-examples = true + +[[example]] +name = "dynamic_assets" +path = "examples/03-assets-styling/dynamic_assets.rs" +doc-scrape-examples = true + +[[example]] +name = "meta" +path = "examples/03-assets-styling/meta.rs" +doc-scrape-examples = true + +[[example]] +name = "meta_elements" +path = "examples/03-assets-styling/meta_elements.rs" +doc-scrape-examples = true + +[[example]] +name = "reducer" +path = "examples/04-managing-state/reducer.rs" +doc-scrape-examples = true + +[[example]] +name = "memo_chain" +path = "examples/04-managing-state/memo_chain.rs" +doc-scrape-examples = true + +[[example]] +name = "global" +path = "examples/04-managing-state/global.rs" +doc-scrape-examples = true + +[[example]] +name = "context_api" +path = "examples/04-managing-state/context_api.rs" +doc-scrape-examples = true + +[[example]] +name = "signals" +path = "examples/04-managing-state/signals.rs" +doc-scrape-examples = true + +[[example]] +name = "errors" +path = "examples/04-managing-state/error_handling.rs" +doc-scrape-examples = true + +[[example]] +name = "backgrounded_futures" +path = "examples/05-using-async/backgrounded_futures.rs" +doc-scrape-examples = true + +[[example]] +name = "clock" +path = "examples/05-using-async/clock.rs" +doc-scrape-examples = true + +[[example]] +name = "streams" +path = "examples/05-using-async/streams.rs" +doc-scrape-examples = true + +[[example]] +name = "suspense" +path = "examples/05-using-async/suspense.rs" +doc-scrape-examples = true + +[[example]] +name = "future" +path = "examples/05-using-async/future.rs" +doc-scrape-examples = true + + +[[example]] +name = "simple_router" +path = "examples/06-routing/simple_router.rs" +doc-scrape-examples = true + +[[example]] +name = "router_restore_scroll" +path = "examples/06-routing/router_restore_scroll.rs" +doc-scrape-examples = true + +[[example]] +name = "link" +path = "examples/06-routing/link.rs" doc-scrape-examples = true [[example]] name = "hash_fragment_state" required-features = ["ciborium", "base64"] +path = "examples/06-routing/hash_fragment_state.rs" doc-scrape-examples = true [[example]] -name = "backgrounded_futures" -required-features = ["desktop"] +name = "router" +path = "examples/06-routing/router.rs" doc-scrape-examples = true [[example]] -name = "calculator_mutable" -required-features = ["desktop"] +name = "router_resource" +path = "examples/06-routing/router_resource.rs" doc-scrape-examples = true [[example]] -name = "calculator" -required-features = ["desktop"] +name = "query_segment_search" +path = "examples/06-routing/query_segment_search.rs" doc-scrape-examples = true [[example]] -name = "clock" +name = "flat_router" +path = "examples/06-routing/flat_router.rs" doc-scrape-examples = true [[example]] -name = "crm" +name = "header_map" +path = "examples/07-fullstack/header_map.rs" +doc-scrape-examples = true + +[[example]] +name = "login_form" +path = "examples/07-fullstack/login_form.rs" +doc-scrape-examples = true + +[[example]] +name = "handling_errors" +path = "examples/07-fullstack/handling_errors.rs" +doc-scrape-examples = true + +[[example]] +name = "dog_app_self_hosted" +path = "examples/07-fullstack/dog_app_self_hosted.rs" +doc-scrape-examples = true + +[[example]] +name = "through_reqwest" +path = "examples/07-fullstack/through_reqwest.rs" +doc-scrape-examples = true + +[[example]] +name = "streaming_file_upload" +path = "examples/07-fullstack/streaming_file_upload.rs" +doc-scrape-examples = true + +[[example]] +name = "multipart_form" +path = "examples/07-fullstack/multipart_form.rs" +doc-scrape-examples = true + +[[example]] +name = "full_request_access" +path = "examples/07-fullstack/full_request_access.rs" +doc-scrape-examples = true + +[[example]] +name = "fullstack_hello_world" +path = "examples/07-fullstack/fullstack_hello_world.rs" +doc-scrape-examples = true + +[[example]] +name = "server_sent_events" +path = "examples/07-fullstack/server_sent_events.rs" +doc-scrape-examples = true + +[[example]] +name = "server_state" +path = "examples/07-fullstack/server_state.rs" +doc-scrape-examples = true +required-features = ["sqlx"] + +[[example]] +name = "streaming" +path = "examples/07-fullstack/streaming.rs" +doc-scrape-examples = true + +[[example]] +name = "custom_axum_serve" +path = "examples/07-fullstack/custom_axum_serve.rs" +doc-scrape-examples = true + +[[example]] +name = "websocket" +path = "examples/07-fullstack/websocket.rs" +doc-scrape-examples = true + +[[example]] +name = "control_focus" +path = "examples/08-apis/control_focus.rs" +doc-scrape-examples = true + +[[example]] +name = "window_popup" required-features = ["desktop"] +path = "examples/08-apis/window_popup.rs" doc-scrape-examples = true [[example]] name = "custom_html" required-features = ["desktop"] +path = "examples/08-apis/custom_html.rs" doc-scrape-examples = true [[example]] -name = "custom_menu" +name = "multiwindow_with_tray_icon" required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "dynamic_asset" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "errors" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "future" -doc-scrape-examples = true - -[[example]] -name = "hydration" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "multiwindow" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "overlay" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "popup" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "read_size" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "shortcut" -required-features = ["desktop"] -doc-scrape-examples = true - -[[example]] -name = "streams" -doc-scrape-examples = true - -[[example]] -name = "visible" +path = "examples/08-apis/multiwindow_with_tray_icon.rs" doc-scrape-examples = true [[example]] name = "window_event" required-features = ["desktop"] +path = "examples/08-apis/window_event.rs" +doc-scrape-examples = true + +[[example]] +name = "read_size" +path = "examples/08-apis/read_size.rs" +doc-scrape-examples = true + +[[example]] +name = "logging" +path = "examples/08-apis/logging.rs" +doc-scrape-examples = true + +[[example]] +name = "overlay" +required-features = ["desktop"] +path = "examples/08-apis/overlay.rs" +doc-scrape-examples = true + +[[example]] +name = "ssr" +path = "examples/08-apis/ssr.rs" +doc-scrape-examples = true + +[[example]] +name = "video_stream" +required-features = ["desktop"] +path = "examples/08-apis/video_stream.rs" +doc-scrape-examples = true + +[[example]] +name = "title" +path = "examples/08-apis/title.rs" +doc-scrape-examples = true + +[[example]] +name = "file_upload" +path = "examples/08-apis/file_upload.rs" doc-scrape-examples = true [[example]] name = "window_focus" required-features = ["desktop"] +path = "examples/08-apis/window_focus.rs" +doc-scrape-examples = true + +[[example]] +name = "eval" +path = "examples/08-apis/eval.rs" +doc-scrape-examples = true + +[[example]] +name = "shortcut" +required-features = ["desktop"] +path = "examples/08-apis/shortcut.rs" +doc-scrape-examples = true + +[[example]] +name = "scroll_to_offset" +path = "examples/08-apis/scroll_to_offset.rs" +doc-scrape-examples = true + +[[example]] +name = "scroll_to_top" +path = "examples/08-apis/scroll_to_top.rs" +doc-scrape-examples = true + +[[example]] +name = "wgpu_child_window" +path = "examples/08-apis/wgpu_child_window.rs" +required-features = ["gpu", "desktop"] +doc-scrape-examples = true + +[[example]] +name = "multiwindow" +required-features = ["desktop"] +path = "examples/08-apis/multiwindow.rs" doc-scrape-examples = true [[example]] name = "window_zoom" required-features = ["desktop"] +path = "examples/08-apis/window_zoom.rs" doc-scrape-examples = true [[example]] -name = "wgpu_child_window" -required-features = ["desktop", "gpu"] - -[[example]] -name = "control_focus" +name = "custom_menu" +required-features = ["desktop"] +path = "examples/08-apis/custom_menu.rs" doc-scrape-examples = true [[example]] -name = "eval" +name = "on_resize" +path = "examples/08-apis/on_resize.rs" doc-scrape-examples = true [[example]] -name = "logging" +name = "form" +path = "examples/08-apis/form.rs" doc-scrape-examples = true + +[[example]] +name = "on_visible" +path = "examples/08-apis/on_visible.rs" +doc-scrape-examples = true + +[[example]] +name = "all_events" +path = "examples/09-reference/all_events.rs" +doc-scrape-examples = true + +[[example]] +name = "xss_safety" +path = "examples/09-reference/xss_safety.rs" +doc-scrape-examples = true + +[[example]] +name = "web_component" +path = "examples/09-reference/web_component.rs" +doc-scrape-examples = true + +[[example]] +name = "generic_component" +path = "examples/09-reference/generic_component.rs" +doc-scrape-examples = true + +[[example]] +name = "shorthand" +path = "examples/09-reference/shorthand.rs" +doc-scrape-examples = true + +[[example]] +name = "simple_list" +path = "examples/09-reference/simple_list.rs" +doc-scrape-examples = true + +[[example]] +name = "optional_props" +path = "examples/09-reference/optional_props.rs" +doc-scrape-examples = true + +[[example]] +name = "rsx_usage" +path = "examples/09-reference/rsx_usage.rs" +doc-scrape-examples = true + +[[example]] +name = "spread" +path = "examples/09-reference/spread.rs" +doc-scrape-examples = true + +[[example]] +name = "__scrape_example_list" +path = "examples/scripts/scrape_examples.rs" diff --git a/examples/bluetooth-scanner/.gitignore b/examples/01-app-demos/bluetooth-scanner/.gitignore similarity index 100% rename from examples/bluetooth-scanner/.gitignore rename to examples/01-app-demos/bluetooth-scanner/.gitignore diff --git a/examples/bluetooth-scanner/Cargo.toml b/examples/01-app-demos/bluetooth-scanner/Cargo.toml similarity index 95% rename from examples/bluetooth-scanner/Cargo.toml rename to examples/01-app-demos/bluetooth-scanner/Cargo.toml index c9ed65c9c..5e72efa52 100644 --- a/examples/bluetooth-scanner/Cargo.toml +++ b/examples/01-app-demos/bluetooth-scanner/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "bluetooth-scanner" version = "0.1.1" -edition = "2018" +edition = "2021" publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/bluetooth-scanner/README.md b/examples/01-app-demos/bluetooth-scanner/README.md similarity index 100% rename from examples/bluetooth-scanner/README.md rename to examples/01-app-demos/bluetooth-scanner/README.md diff --git a/examples/bluetooth-scanner/assets/tailwind.css b/examples/01-app-demos/bluetooth-scanner/assets/tailwind.css similarity index 100% rename from examples/bluetooth-scanner/assets/tailwind.css rename to examples/01-app-demos/bluetooth-scanner/assets/tailwind.css diff --git a/examples/bluetooth-scanner/demo_small.png b/examples/01-app-demos/bluetooth-scanner/demo_small.png similarity index 100% rename from examples/bluetooth-scanner/demo_small.png rename to examples/01-app-demos/bluetooth-scanner/demo_small.png diff --git a/examples/01-app-demos/bluetooth-scanner/src/main.rs b/examples/01-app-demos/bluetooth-scanner/src/main.rs new file mode 100644 index 000000000..662449f42 --- /dev/null +++ b/examples/01-app-demos/bluetooth-scanner/src/main.rs @@ -0,0 +1,90 @@ +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app) +} + +fn app() -> Element { + let mut scan = use_action(|| async { + use btleplug::api::{Central, Manager as _, Peripheral, ScanFilter}; + + let manager = btleplug::platform::Manager::new().await?; + + // get the first bluetooth adapter + let adapters = manager.adapters().await?; + let central = adapters + .into_iter() + .next() + .context("No Bluetooth adapter found")?; + + // start scanning for devices + central.start_scan(ScanFilter::default()).await?; + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + + // Return the list of peripherals after scanning + let mut devices = vec![]; + for p in central.peripherals().await? { + if let Some(p) = p.properties().await? { + devices.push(p); + } + } + + // Sort them by RSSI (signal strength) + devices.sort_by_key(|p| p.rssi.unwrap_or(-100)); + + dioxus::Ok(devices) + }); + + rsx! { + Stylesheet { href: asset!("/assets/tailwind.css") } + div { + div { class: "py-8 px-6", + div { class: "container px-4 mx-auto", + h2 { class: "text-2xl font-bold", "Scan for Bluetooth Devices" } + button { + class: "inline-block w-full md:w-auto px-6 py-3 font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded transition duration-200", + disabled: scan.is_pending(), + onclick: move |_| { + scan.call(); + }, + if scan.is_pending() { "Scanning" } else { "Scan" } + } + } + } + + section { class: "py-8", + div { class: "container px-4 mx-auto", + div { class: "p-4 mb-6 bg-white shadow rounded overflow-x-auto", + table { class: "table-auto w-full", + thead { + tr { class: "text-xs text-gray-500 text-left", + th { class: "pl-6 pb-3 font-medium", "Strength" } + th { class: "pb-3 font-medium", "Network" } + th { class: "pb-3 font-medium", "Channel" } + th { class: "pb-3 px-2 font-medium", "Security" } + } + } + match scan.result() { + None if scan.is_pending() => rsx! { "Scanning..." }, + None => rsx! { "Press Scan to start scanning" }, + Some(Err(_err)) => rsx! { "Failed to scan" }, + Some(Ok(peripherals)) => rsx! { + tbody { + for peripheral in peripherals.read().iter().rev() { + tr { class: "text-xs bg-gray-50", + td { class: "py-5 px-6 font-medium", "{peripheral.rssi.unwrap_or(-100)}" } + td { class: "flex py-3 font-medium", "{peripheral.local_name.clone().unwrap_or_default()}" } + td { span { class: "inline-block py-1 px-2 text-white bg-green-500 rounded-full", "{peripheral.address}" } } + td { span { class: "inline-block py-1 px-2 text-purple-500 bg-purple-50 rounded-full", "{peripheral.tx_power_level.unwrap_or_default()}" } } + } + } + } + } + } + } + } + } + } + } + } +} diff --git a/examples/bluetooth-scanner/tailwind.css b/examples/01-app-demos/bluetooth-scanner/tailwind.css similarity index 100% rename from examples/bluetooth-scanner/tailwind.css rename to examples/01-app-demos/bluetooth-scanner/tailwind.css diff --git a/examples/calculator.rs b/examples/01-app-demos/calculator.rs similarity index 99% rename from examples/calculator.rs rename to examples/01-app-demos/calculator.rs index c82517d57..200c756e2 100644 --- a/examples/calculator.rs +++ b/examples/01-app-demos/calculator.rs @@ -54,7 +54,7 @@ fn app() -> Element { }; rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } div { id: "wrapper", div { class: "app", div { class: "calculator", tabindex: "0", onkeydown: handle_key_down_event, diff --git a/examples/calculator_mutable.rs b/examples/01-app-demos/calculator_mutable.rs similarity index 98% rename from examples/calculator_mutable.rs rename to examples/01-app-demos/calculator_mutable.rs index 825851d37..4102e6ff2 100644 --- a/examples/calculator_mutable.rs +++ b/examples/01-app-demos/calculator_mutable.rs @@ -8,8 +8,8 @@ use dioxus::desktop::tao::dpi::LogicalSize; use dioxus::desktop::{Config, WindowBuilder}; -use dioxus::html::input_data::keyboard_types::Key; use dioxus::html::MouseEvent; +use dioxus::html::input_data::keyboard_types::Key; use dioxus::prelude::*; fn main() { @@ -29,10 +29,7 @@ fn app() -> Element { let mut state = use_signal(Calculator::new); rsx! { - document::Link { - rel: "stylesheet", - href: asset!("/examples/assets/calculator.css"), - } + Stylesheet { href: asset!("/examples/assets/calculator.css") } div { id: "wrapper", div { class: "app", div { diff --git a/examples/counters.rs b/examples/01-app-demos/counters.rs similarity index 97% rename from examples/counters.rs rename to examples/01-app-demos/counters.rs index bc9ce327d..8487a2b97 100644 --- a/examples/counters.rs +++ b/examples/01-app-demos/counters.rs @@ -16,7 +16,7 @@ fn app() -> Element { let sum = use_memo(move || counters.read().iter().copied().sum::()); rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } div { id: "controls", button { onclick: move |_| counters.write().push(0), "Add counter" } diff --git a/examples/crm.rs b/examples/01-app-demos/crm.rs similarity index 93% rename from examples/crm.rs rename to examples/01-app-demos/crm.rs index 8469b2e16..e8f93b252 100644 --- a/examples/crm.rs +++ b/examples/01-app-demos/crm.rs @@ -20,20 +20,12 @@ fn main() { })) .launch(|| { rsx! { - document::Link { - rel: "stylesheet", + Stylesheet { href: "https://unpkg.com/purecss@2.0.6/build/pure-min.css", integrity: "sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5", crossorigin: "anonymous", } - document::Link { - rel: "stylesheet", - href: asset!("/examples/assets/crm.css"), - } - document::Link { - rel: "stylesheet", - href: asset!("/examples/assets/crm.css"), - } + Stylesheet { href: asset!("/examples/assets/crm.css") } h1 { "Dioxus CRM Example" } Router:: {} } diff --git a/examples/01-app-demos/dog_app.rs b/examples/01-app-demos/dog_app.rs new file mode 100644 index 000000000..020a17842 --- /dev/null +++ b/examples/01-app-demos/dog_app.rs @@ -0,0 +1,70 @@ +//! This example demonstrates a simple app that fetches a list of dog breeds and displays a random dog. +//! +//! This app combines `use_loader` and `use_action` to fetch data from the Dog API. +//! - `use_loader` automatically fetches the list of dog breeds when the component mounts. +//! - `use_action` fetches a random dog image whenever the `.dispatch` method is called. + +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + // Fetch the list of breeds from the Dog API, using the `?` syntax to suspend or throw errors + let breed_list = use_loader(move || async move { + #[derive(Deserialize, Serialize, Debug, PartialEq, Clone)] + struct ListBreeds { + message: HashMap>, + } + + reqwest::get("https://dog.ceo/api/breeds/list/all") + .await? + .json::() + .await + })?; + + // Whenever this action is called, it will re-run the future and return the result. + let mut breed = use_action(move |breed| async move { + #[derive(Deserialize, Serialize, Debug, PartialEq)] + struct DogApi { + message: String, + } + + reqwest::get(format!("https://dog.ceo/api/breed/{breed}/images/random")) + .await + .unwrap() + .json::() + .await + }); + + rsx! { + h1 { "Doggo selector" } + div { width: "400px", + for cur_breed in breed_list.read().message.keys().take(20).cloned() { + button { + onclick: move |_| { + breed.call(cur_breed.clone()); + }, + "{cur_breed}" + } + } + } + div { + match breed.result() { + None => rsx! { div { "Click the button to fetch a dog!" } }, + Some(Err(_e)) => rsx! { div { "Failed to fetch a dog, please try again." } }, + Some(Ok(res)) => rsx! { + img { + max_width: "500px", + max_height: "500px", + src: "{res.read().message}" + } + }, + } + } + + } +} diff --git a/examples/ecommerce-site/.gitignore b/examples/01-app-demos/ecommerce-site/.gitignore similarity index 100% rename from examples/ecommerce-site/.gitignore rename to examples/01-app-demos/ecommerce-site/.gitignore diff --git a/examples/ecommerce-site/Cargo.toml b/examples/01-app-demos/ecommerce-site/Cargo.toml similarity index 97% rename from examples/ecommerce-site/Cargo.toml rename to examples/01-app-demos/ecommerce-site/Cargo.toml index dbc32e0ce..ad89072a6 100644 --- a/examples/ecommerce-site/Cargo.toml +++ b/examples/01-app-demos/ecommerce-site/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ecommerce-site" version = "0.1.1" -edition = "2018" +edition = "2021" publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/examples/ecommerce-site/README.md b/examples/01-app-demos/ecommerce-site/README.md similarity index 100% rename from examples/ecommerce-site/README.md rename to examples/01-app-demos/ecommerce-site/README.md diff --git a/examples/ecommerce-site/demo.png b/examples/01-app-demos/ecommerce-site/demo.png similarity index 100% rename from examples/ecommerce-site/demo.png rename to examples/01-app-demos/ecommerce-site/demo.png diff --git a/examples/ecommerce-site/public/loading.css b/examples/01-app-demos/ecommerce-site/public/loading.css similarity index 100% rename from examples/ecommerce-site/public/loading.css rename to examples/01-app-demos/ecommerce-site/public/loading.css diff --git a/examples/ecommerce-site/public/tailwind.css b/examples/01-app-demos/ecommerce-site/public/tailwind.css similarity index 100% rename from examples/ecommerce-site/public/tailwind.css rename to examples/01-app-demos/ecommerce-site/public/tailwind.css diff --git a/examples/ecommerce-site/src/api.rs b/examples/01-app-demos/ecommerce-site/src/api.rs similarity index 60% rename from examples/ecommerce-site/src/api.rs rename to examples/01-app-demos/ecommerce-site/src/api.rs index 30d506e3f..22b8201f5 100644 --- a/examples/ecommerce-site/src/api.rs +++ b/examples/01-app-demos/ecommerce-site/src/api.rs @@ -1,9 +1,27 @@ -#![allow(unused)] - +use chrono::{DateTime, Utc}; +use dioxus::prelude::Result; +use serde::{Deserialize, Serialize}; use std::fmt::Display; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +// Cache up to 100 requests, invalidating them after 60 seconds +pub(crate) async fn fetch_product(product_id: usize) -> Result { + Ok( + reqwest::get(format!("https://fakestoreapi.com/products/{product_id}")) + .await? + .json() + .await?, + ) +} + +// Cache up to 100 requests, invalidating them after 60 seconds +pub(crate) async fn fetch_products(count: usize, sort: Sort) -> Result> { + Ok(reqwest::get(format!( + "https://fakestoreapi.com/products/?sort={sort}&limit={count}" + )) + .await? + .json() + .await?) +} #[derive(Serialize, Deserialize, PartialEq, Clone, Debug, Default)] pub(crate) struct Product { @@ -54,48 +72,8 @@ impl Display for Sort { } } -// Cache up to 100 requests, invalidating them after 60 seconds -pub(crate) async fn fetch_user_carts(user_id: usize) -> Result, reqwest::Error> { - reqwest::get(format!( - "https://fakestoreapi.com/carts/user/{user_id}?startdate=2019-12-10&enddate=2023-01-01" - )) - .await? - .json() - .await -} - -// Cache up to 100 requests, invalidating them after 60 seconds -pub(crate) async fn fetch_user(user_id: usize) -> dioxus::Result { - Ok( - reqwest::get(format!("https://fakestoreapi.com/users/{user_id}")) - .await? - .json() - .await?, - ) -} - -// Cache up to 100 requests, invalidating them after 60 seconds -pub(crate) async fn fetch_product(product_id: usize) -> dioxus::Result { - Ok( - reqwest::get(format!("https://fakestoreapi.com/products/{product_id}")) - .await? - .json() - .await?, - ) -} - -// Cache up to 100 requests, invalidating them after 60 seconds -pub(crate) async fn fetch_products(count: usize, sort: Sort) -> dioxus::Result> { - Ok(reqwest::get(format!( - "https://fakestoreapi.com/products/?sort={sort}&limit={count}" - )) - .await? - .json() - .await?) -} - #[derive(Serialize, Deserialize)] -pub(crate) struct User { +struct User { id: usize, email: String, username: String, @@ -104,14 +82,6 @@ pub(crate) struct User { phone: String, } -impl User { - async fn fetch_most_recent_cart(&self) -> Result, reqwest::Error> { - let all_carts = fetch_user_carts(self.id).await?; - - Ok(all_carts.into_iter().max_by_key(|cart| cart.date)) - } -} - #[derive(Serialize, Deserialize)] struct FullName { firstname: String, @@ -119,7 +89,7 @@ struct FullName { } #[derive(Serialize, Deserialize, Clone)] -pub(crate) struct Cart { +struct Cart { id: usize, #[serde(rename = "userId")] user_id: usize, @@ -128,29 +98,9 @@ pub(crate) struct Cart { date: DateTime, } -impl Cart { - async fn update_database(&mut self) -> Result<(), reqwest::Error> { - let id = self.id; - let client = reqwest::Client::new(); - *self = client - .put(format!("https://fakestoreapi.com/carts/{id}")) - .send() - .await? - .json() - .await?; - Ok(()) - } -} - #[derive(Serialize, Deserialize, Clone)] -pub(crate) struct ProductInCart { +struct ProductInCart { #[serde(rename = "productId")] product_id: usize, quantity: usize, } - -impl ProductInCart { - pub async fn fetch_product(&self) -> Result { - fetch_product(self.product_id).await - } -} diff --git a/examples/ecommerce-site/src/components/error.rs b/examples/01-app-demos/ecommerce-site/src/components/error.rs similarity index 100% rename from examples/ecommerce-site/src/components/error.rs rename to examples/01-app-demos/ecommerce-site/src/components/error.rs diff --git a/examples/01-app-demos/ecommerce-site/src/components/home.rs b/examples/01-app-demos/ecommerce-site/src/components/home.rs new file mode 100644 index 000000000..65b3a4c64 --- /dev/null +++ b/examples/01-app-demos/ecommerce-site/src/components/home.rs @@ -0,0 +1,23 @@ +// The homepage is statically rendered, so we don't need to a persistent websocket connection. + +use crate::{ + api::{fetch_products, Sort}, + components::nav::Nav, + components::product_item::ProductItem, +}; +use dioxus::prelude::*; + +pub(crate) fn Home() -> Element { + let products = use_loader(|| fetch_products(10, Sort::Ascending))?; + + rsx! { + Nav {} + section { class: "p-10", + for product in products.iter() { + ProductItem { + product: product.clone() + } + } + } + } +} diff --git a/examples/01-app-demos/ecommerce-site/src/components/loading.rs b/examples/01-app-demos/ecommerce-site/src/components/loading.rs new file mode 100644 index 000000000..ff684559e --- /dev/null +++ b/examples/01-app-demos/ecommerce-site/src/components/loading.rs @@ -0,0 +1,12 @@ +use dioxus::prelude::*; + +#[component] +pub(crate) fn ChildrenOrLoading(children: Element) -> Element { + rsx! { + Stylesheet { href: asset!("/public/loading.css") } + SuspenseBoundary { + fallback: |_| rsx! { div { class: "spinner", } }, + {children} + } + } +} diff --git a/examples/ecommerce-site/src/components/nav.rs b/examples/01-app-demos/ecommerce-site/src/components/nav.rs similarity index 99% rename from examples/ecommerce-site/src/components/nav.rs rename to examples/01-app-demos/ecommerce-site/src/components/nav.rs index 7f6c6830b..c512345e2 100644 --- a/examples/ecommerce-site/src/components/nav.rs +++ b/examples/01-app-demos/ecommerce-site/src/components/nav.rs @@ -1,6 +1,7 @@ use dioxus::prelude::*; -pub fn nav() -> Element { +#[component] +pub fn Nav() -> Element { rsx! { section { class: "relative", nav { class: "flex justify-between border-b", diff --git a/examples/ecommerce-site/src/components/product_item.rs b/examples/01-app-demos/ecommerce-site/src/components/product_item.rs similarity index 94% rename from examples/ecommerce-site/src/components/product_item.rs rename to examples/01-app-demos/ecommerce-site/src/components/product_item.rs index 0e99e8222..63cd531a3 100644 --- a/examples/ecommerce-site/src/components/product_item.rs +++ b/examples/01-app-demos/ecommerce-site/src/components/product_item.rs @@ -3,7 +3,7 @@ use dioxus::prelude::*; use crate::api::Product; #[component] -pub(crate) fn product_item(product: Product) -> Element { +pub(crate) fn ProductItem(product: Product) -> Element { let Product { id, title, diff --git a/examples/ecommerce-site/src/components/product_page.rs b/examples/01-app-demos/ecommerce-site/src/components/product_page.rs similarity index 98% rename from examples/ecommerce-site/src/components/product_page.rs rename to examples/01-app-demos/ecommerce-site/src/components/product_page.rs index 18f8f91ed..0bec354de 100644 --- a/examples/ecommerce-site/src/components/product_page.rs +++ b/examples/01-app-demos/ecommerce-site/src/components/product_page.rs @@ -3,44 +3,11 @@ use std::{fmt::Display, str::FromStr}; use crate::api::{fetch_product, Product}; use dioxus::prelude::*; -#[derive(Default)] -enum Size { - Small, - #[default] - Medium, - Large, -} - -impl Display for Size { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Size::Small => "small".fmt(f), - Size::Medium => "medium".fmt(f), - Size::Large => "large".fmt(f), - } - } -} - -impl FromStr for Size { - type Err = (); - - fn from_str(s: &str) -> Result { - use Size::*; - match s.to_lowercase().as_str() { - "small" => Ok(Small), - "medium" => Ok(Medium), - "large" => Ok(Large), - _ => Err(()), - } - } -} - #[component] -pub fn product_page(product_id: ReadSignal) -> Element { +pub fn ProductPage(product_id: ReadSignal) -> Element { let mut quantity = use_signal(|| 1); let mut size = use_signal(Size::default); - - let product = use_server_future(move || fetch_product(product_id()))?; + let product = use_loader(move || fetch_product(product_id()))?; let Product { title, @@ -50,7 +17,7 @@ pub fn product_page(product_id: ReadSignal) -> Element { image, rating, .. - } = product().unwrap()?; + } = product(); rsx! { section { class: "py-20", @@ -216,6 +183,38 @@ pub fn product_page(product_id: ReadSignal) -> Element { } } +#[derive(Default)] +enum Size { + Small, + #[default] + Medium, + Large, +} + +impl Display for Size { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Size::Small => "small".fmt(f), + Size::Medium => "medium".fmt(f), + Size::Large => "large".fmt(f), + } + } +} + +impl FromStr for Size { + type Err = (); + + fn from_str(s: &str) -> Result { + use Size::*; + match s.to_lowercase().as_str() { + "small" => Ok(Small), + "medium" => Ok(Medium), + "large" => Ok(Large), + _ => Err(()), + } + } +} + mod icons { use super::*; diff --git a/examples/ecommerce-site/src/main.rs b/examples/01-app-demos/ecommerce-site/src/main.rs similarity index 91% rename from examples/ecommerce-site/src/main.rs rename to examples/01-app-demos/ecommerce-site/src/main.rs index 1ff12b67c..224630cf1 100644 --- a/examples/ecommerce-site/src/main.rs +++ b/examples/01-app-demos/ecommerce-site/src/main.rs @@ -43,8 +43,8 @@ enum Route { fn Details(product_id: usize) -> Element { rsx! { div { - components::nav::nav {} - components::product_page::product_page { + components::nav::Nav {} + components::product_page::ProductPage { product_id } } diff --git a/examples/ecommerce-site/tailwind.css b/examples/01-app-demos/ecommerce-site/tailwind.css similarity index 100% rename from examples/ecommerce-site/tailwind.css rename to examples/01-app-demos/ecommerce-site/tailwind.css diff --git a/examples/file-explorer/.gitignore b/examples/01-app-demos/file-explorer/.gitignore similarity index 100% rename from examples/file-explorer/.gitignore rename to examples/01-app-demos/file-explorer/.gitignore diff --git a/examples/file-explorer/Cargo.toml b/examples/01-app-demos/file-explorer/Cargo.toml similarity index 100% rename from examples/file-explorer/Cargo.toml rename to examples/01-app-demos/file-explorer/Cargo.toml diff --git a/examples/file-explorer/Dioxus.toml b/examples/01-app-demos/file-explorer/Dioxus.toml similarity index 100% rename from examples/file-explorer/Dioxus.toml rename to examples/01-app-demos/file-explorer/Dioxus.toml diff --git a/examples/file-explorer/README.md b/examples/01-app-demos/file-explorer/README.md similarity index 100% rename from examples/file-explorer/README.md rename to examples/01-app-demos/file-explorer/README.md diff --git a/examples/file-explorer/assets/fileexplorer.css b/examples/01-app-demos/file-explorer/assets/fileexplorer.css similarity index 100% rename from examples/file-explorer/assets/fileexplorer.css rename to examples/01-app-demos/file-explorer/assets/fileexplorer.css diff --git a/examples/file-explorer/assets/image.png b/examples/01-app-demos/file-explorer/assets/image.png similarity index 100% rename from examples/file-explorer/assets/image.png rename to examples/01-app-demos/file-explorer/assets/image.png diff --git a/examples/file-explorer/src/main.rs b/examples/01-app-demos/file-explorer/src/main.rs similarity index 90% rename from examples/file-explorer/src/main.rs rename to examples/01-app-demos/file-explorer/src/main.rs index 768d49482..b482edef5 100644 --- a/examples/file-explorer/src/main.rs +++ b/examples/01-app-demos/file-explorer/src/main.rs @@ -8,25 +8,19 @@ use std::env::current_dir; use std::path::PathBuf; -use dioxus::desktop::{Config, WindowBuilder}; use dioxus::prelude::*; fn main() { - dioxus::LaunchBuilder::desktop() - .with_cfg(Config::new().with_window(WindowBuilder::new().with_resizable(true))) - .launch(app) + dioxus::launch(app); } fn app() -> Element { let mut files = use_signal(Files::new); rsx! { - document::Link { - rel: "stylesheet", - href: asset!("/assets/fileexplorer.css") - } + Stylesheet { href: asset!("/assets/fileexplorer.css") } + Stylesheet { href: "https://fonts.googleapis.com/icon?family=Material+Icons" } div { - document::Link { href: "https://fonts.googleapis.com/icon?family=Material+Icons", rel: "stylesheet" } header { i { class: "material-icons icon-menu", "menu" } h1 { "Files: " {files.read().current()} } diff --git a/examples/fullstack-hackernews/.gitignore b/examples/01-app-demos/hackernews/.gitignore similarity index 100% rename from examples/fullstack-hackernews/.gitignore rename to examples/01-app-demos/hackernews/.gitignore diff --git a/examples/fullstack-hackernews/Cargo.toml b/examples/01-app-demos/hackernews/Cargo.toml similarity index 100% rename from examples/fullstack-hackernews/Cargo.toml rename to examples/01-app-demos/hackernews/Cargo.toml diff --git a/examples/fullstack-hackernews/assets/hackernews.css b/examples/01-app-demos/hackernews/assets/hackernews.css similarity index 100% rename from examples/fullstack-hackernews/assets/hackernews.css rename to examples/01-app-demos/hackernews/assets/hackernews.css diff --git a/examples/fullstack-hackernews/src/main.rs b/examples/01-app-demos/hackernews/src/main.rs similarity index 56% rename from examples/fullstack-hackernews/src/main.rs rename to examples/01-app-demos/hackernews/src/main.rs index ec389d371..bd31fa4c0 100644 --- a/examples/fullstack-hackernews/src/main.rs +++ b/examples/01-app-demos/hackernews/src/main.rs @@ -13,45 +13,37 @@ use svg_attributes::to; fn main() { LaunchBuilder::new() .with_cfg(server_only! { - dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + dioxus::server::ServeConfig::builder().enable_out_of_order_streaming() }) - .launch(|| rsx! { Router:: {} }); + .launch(|| { + rsx! { + Stylesheet { href: asset!("/assets/hackernews.css") } + Router:: {} + } + }); } #[derive(Clone, Routable)] enum Route { - #[redirect("/", || Route::Homepage { story: PreviewState { active_story: None } })] - #[route("/:story")] - Homepage { story: PreviewState }, -} - -pub fn App() -> Element { - rsx! { - Router:: {} - } + #[route("/story&:story")] + StoryPreview { story: Option }, } #[component] -fn Homepage(story: ReadSignal) -> Element { +fn StoryPreview(story: ReadSignal>) -> Element { rsx! { - document::Link { rel: "stylesheet", href: asset!("/assets/hackernews.css") } div { display: "flex", flex_direction: "row", width: "100%", - div { - width: "50%", - SuspenseBoundary { - fallback: |context: SuspenseContext| rsx! { - "Loading..." - }, + div { width: "50%", + SuspenseBoundary { fallback: |context| rsx! { "Loading..." }, Stories {} } } div { width: "50%", - SuspenseBoundary { - fallback: |context: SuspenseContext| rsx! { - "Loading preview..." - }, - Preview { - story + SuspenseBoundary { fallback: |context| rsx! { "Loading preview..." }, + if let Some(story) = story() { + Preview { story_id: story } + } else { + div { padding: "0.5rem", "Select a story to preview" } } } } @@ -61,32 +53,31 @@ fn Homepage(story: ReadSignal) -> Element { #[component] fn Stories() -> Element { - let stories: Resource>> = use_server_future(move || async move { - let url = format!("{}topstories.json", BASE_API_URL); - let mut stories_ids = reqwest::get(&url).await?.json::>().await?; - stories_ids.truncate(30); - Ok(stories_ids) + let stories = use_loader(move || async move { + let stories_ids = reqwest::get(&format!("{}topstories.json", BASE_API_URL)) + .await? + .json::>() + .await? + .into_iter() + .take(30) + .collect::>(); + dioxus::Ok(stories_ids) })?; - match stories().unwrap() { - Ok(list) => rsx! { - div { - for story in list { - ChildrenOrLoading { - key: "{story}", - StoryListing { story } - } + rsx! { + div { + for story in stories() { + ChildrenOrLoading { key: "{story}", + StoryListing { story } } } - }, - Err(err) => rsx! {"An error occurred while fetching stories {err}"}, + } } } #[component] fn StoryListing(story: ReadSignal) -> Element { - let story = use_server_future(move || get_story(story()))?; - + let story = use_loader(move || get_story(story()))?; let StoryItem { title, url, @@ -96,7 +87,7 @@ fn StoryListing(story: ReadSignal) -> Element { kids, id, .. - } = story().unwrap()?.item; + } = story().item; let url = url.as_deref().unwrap_or_default(); let hostname = url @@ -121,7 +112,7 @@ fn StoryListing(story: ReadSignal) -> Element { position: "relative", div { font_size: "1.5rem", Link { - to: Route::Homepage { story: PreviewState { active_story: Some(id) } }, + to: Route::StoryPreview { story: Some(id) }, "{title}" } a { @@ -141,44 +132,9 @@ fn StoryListing(story: ReadSignal) -> Element { } } -#[derive(Clone, Debug, Default)] -struct PreviewState { - active_story: Option, -} - -impl FromStr for PreviewState { - type Err = ParseIntError; - - fn from_str(s: &str) -> Result { - let state = i64::from_str(s)?; - Ok(PreviewState { - active_story: Some(state), - }) - } -} - -impl Display for PreviewState { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if let Some(id) = &self.active_story { - write!(f, "{id}")?; - } - Ok(()) - } -} - #[component] -fn Preview(story: ReadSignal) -> Element { - let PreviewState { - active_story: Some(id), - } = story() - else { - return rsx! {"Click on a story to preview it here"}; - }; - - let story = use_server_future(use_reactive!(|id| get_story(id)))?; - - let story = story().unwrap()?; - +fn Preview(story_id: ReadSignal) -> Element { + let story = use_loader(move || get_story(story_id()))?.cloned(); rsx! { div { padding: "0.5rem", div { font_size: "1.5rem", a { href: story.item.url, "{story.item.title}" } } @@ -194,13 +150,14 @@ fn Preview(story: ReadSignal) -> Element { } #[component] -fn Comment(comment: i64) -> Element { - let comment: Resource> = - use_server_future(use_reactive!(|comment| async move { - let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, comment); - let mut comment = reqwest::get(&url).await?.json::().await?; - Ok(comment) - }))?; +fn Comment(comment: ReadSignal) -> Element { + let comment = use_loader(move || async move { + let mut comment = reqwest::get(&format!("{}{}{}.json", BASE_API_URL, ITEM_API, comment)) + .await? + .json::() + .await?; + dioxus::Ok(comment) + })?; let CommentData { by, @@ -209,7 +166,7 @@ fn Comment(comment: i64) -> Element { id, kids, .. - } = comment().unwrap()?; + } = comment(); rsx! { div { padding: "0.5rem", @@ -272,33 +229,21 @@ pub struct StoryItem { pub r#type: String, } -pub async fn get_story(id: i64) -> dioxus::Result { - let url = format!("{}{}{}.json", BASE_API_URL, ITEM_API, id); - Ok(reqwest::get(&url).await?.json::().await?) +pub async fn get_story(id: i64) -> Result { + Ok( + reqwest::get(&format!("{}{}{}.json", BASE_API_URL, ITEM_API, id)) + .await? + .json::() + .await?, + ) } #[component] fn ChildrenOrLoading(children: Element) -> Element { rsx! { SuspenseBoundary { - fallback: |context: SuspenseContext| { - rsx! { - if let Some(placeholder) = context.suspense_placeholder() { - {placeholder} - } else { - LoadingIndicator {} - } - } - }, + fallback: |_| rsx! { div { class: "spinner", } }, children } } } - -fn LoadingIndicator() -> Element { - rsx! { - div { - class: "spinner", - } - } -} diff --git a/examples/hello_world.rs b/examples/01-app-demos/hello_world.rs similarity index 100% rename from examples/hello_world.rs rename to examples/01-app-demos/hello_world.rs diff --git a/examples/hotdog/Cargo.toml b/examples/01-app-demos/hotdog/Cargo.toml similarity index 89% rename from examples/hotdog/Cargo.toml rename to examples/01-app-demos/hotdog/Cargo.toml index e97df95cc..7b56b2e0c 100644 --- a/examples/hotdog/Cargo.toml +++ b/examples/01-app-demos/hotdog/Cargo.toml @@ -11,9 +11,10 @@ reqwest = { workspace = true, features = ["json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } rusqlite = { version = "0.32.0", optional = true } +anyhow = { workspace = true } [features] -default = ["web"] +default = ["web", "server"] web = ["dioxus/web"] desktop = ["dioxus/desktop"] mobile = ["dioxus/mobile"] diff --git a/examples/hotdog/Dioxus.toml b/examples/01-app-demos/hotdog/Dioxus.toml similarity index 100% rename from examples/hotdog/Dioxus.toml rename to examples/01-app-demos/hotdog/Dioxus.toml diff --git a/examples/hotdog/Dockerfile b/examples/01-app-demos/hotdog/Dockerfile similarity index 100% rename from examples/hotdog/Dockerfile rename to examples/01-app-demos/hotdog/Dockerfile diff --git a/examples/hotdog/README.md b/examples/01-app-demos/hotdog/README.md similarity index 100% rename from examples/hotdog/README.md rename to examples/01-app-demos/hotdog/README.md diff --git a/examples/hotdog/assets/favicon.ico b/examples/01-app-demos/hotdog/assets/favicon.ico similarity index 100% rename from examples/hotdog/assets/favicon.ico rename to examples/01-app-demos/hotdog/assets/favicon.ico diff --git a/examples/hotdog/assets/header.svg b/examples/01-app-demos/hotdog/assets/header.svg similarity index 100% rename from examples/hotdog/assets/header.svg rename to examples/01-app-demos/hotdog/assets/header.svg diff --git a/examples/hotdog/assets/main.css b/examples/01-app-demos/hotdog/assets/main.css similarity index 100% rename from examples/hotdog/assets/main.css rename to examples/01-app-demos/hotdog/assets/main.css diff --git a/examples/hotdog/assets/screenshot.png b/examples/01-app-demos/hotdog/assets/screenshot.png similarity index 100% rename from examples/hotdog/assets/screenshot.png rename to examples/01-app-demos/hotdog/assets/screenshot.png diff --git a/examples/hotdog/fly.toml b/examples/01-app-demos/hotdog/fly.toml similarity index 100% rename from examples/hotdog/fly.toml rename to examples/01-app-demos/hotdog/fly.toml diff --git a/examples/01-app-demos/hotdog/src/backend.rs b/examples/01-app-demos/hotdog/src/backend.rs new file mode 100644 index 000000000..468b244b7 --- /dev/null +++ b/examples/01-app-demos/hotdog/src/backend.rs @@ -0,0 +1,41 @@ +use anyhow::Result; +use dioxus::prelude::*; + +#[cfg(feature = "server")] +thread_local! { + static DB: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let conn = rusqlite::Connection::open("hotdogdb/hotdog.db").expect("Failed to open database"); + + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS dogs ( + id INTEGER PRIMARY KEY, + url TEXT NOT NULL + );", + ) + .unwrap(); + + conn + }); +} + +#[get("/api/dogs")] +pub async fn list_dogs() -> Result> { + DB.with(|db| { + Ok(db + .prepare("SELECT id, url FROM dogs ORDER BY id DESC LIMIT 10")? + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, rusqlite::Error>>()?) + }) +} + +#[delete("/api/dogs/{id}")] +pub async fn remove_dog(id: usize) -> Result<()> { + DB.with(|db| db.execute("DELETE FROM dogs WHERE id = ?1", [id]))?; + Ok(()) +} + +#[post("/api/dogs")] +pub async fn save_dog(image: String) -> Result<()> { + DB.with(|db| db.execute("INSERT INTO dogs (url) VALUES (?1)", [&image]))?; + Ok(()) +} diff --git a/examples/01-app-demos/hotdog/src/frontend.rs b/examples/01-app-demos/hotdog/src/frontend.rs new file mode 100644 index 000000000..0e3e0d2cb --- /dev/null +++ b/examples/01-app-demos/hotdog/src/frontend.rs @@ -0,0 +1,71 @@ +use dioxus::prelude::*; + +use crate::{ + backend::{list_dogs, remove_dog, save_dog}, + Route, +}; + +#[component] +pub fn Favorites() -> Element { + let mut favorites = use_loader(list_dogs)?; + + rsx! { + div { id: "favorites", + for (id , url) in favorites.cloned() { + div { class: "favorite-dog", key: "{id}", + img { src: "{url}" } + button { + onclick: move |_| async move { + _ = remove_dog(id).await; + favorites.restart(); + }, + "❌" + } + } + } + } + } +} + +#[component] +pub fn NavBar() -> Element { + rsx! { + div { id: "title", + span {} + Link { to: Route::DogView, h1 { "🌭 HotDog! " } } + Link { to: Route::Favorites, id: "heart", "♥️" } + } + Outlet:: {} + } +} + +#[component] +pub fn DogView() -> Element { + let mut img_src = use_loader(|| async move { + anyhow::Ok( + reqwest::get("https://dog.ceo/api/breeds/image/random") + .await? + .json::() + .await?["message"] + .to_string(), + ) + })?; + + rsx! { + div { id: "dogview", + img { id: "dogimg", src: "{img_src}" } + } + div { id: "buttons", + button { + id: "skip", + onclick: move |_| img_src.restart(), + "skip" + } + button { + id: "save", + onclick: move |_| async move { _ = save_dog(img_src()).await }, + "save!" + } + } + } +} diff --git a/examples/hotdog/src/main.rs b/examples/01-app-demos/hotdog/src/main.rs similarity index 63% rename from examples/hotdog/src/main.rs rename to examples/01-app-demos/hotdog/src/main.rs index 4164416f5..ad0791660 100644 --- a/examples/hotdog/src/main.rs +++ b/examples/01-app-demos/hotdog/src/main.rs @@ -1,8 +1,8 @@ mod backend; -mod components; +mod frontend; -use components::{DogView, Favorites, NavBar}; use dioxus::prelude::*; +use frontend::*; #[derive(Routable, PartialEq, Clone)] enum Route { @@ -16,14 +16,14 @@ enum Route { fn main() { #[cfg(not(feature = "server"))] - server_fn::client::set_server_url("https://hot-dog.fly.dev"); + dioxus::fullstack::set_server_url("https://hot-dog.fly.dev"); dioxus::launch(app); } fn app() -> Element { rsx! { - document::Stylesheet { href: asset!("/assets/main.css") } + Stylesheet { href: asset!("/assets/main.css") } Router:: {} } } diff --git a/examples/01-app-demos/image_generator_openai.rs b/examples/01-app-demos/image_generator_openai.rs new file mode 100644 index 000000000..99dc7bc3d --- /dev/null +++ b/examples/01-app-demos/image_generator_openai.rs @@ -0,0 +1,97 @@ +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app) +} + +fn app() -> Element { + let mut api_key = use_signal(|| "".to_string()); + let mut prompt = use_signal(|| "".to_string()); + let mut num_images = use_signal(|| 1.to_string()); + + let mut image = use_action(move || async move { + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Props, Clone, Default)] + struct ImageResponse { + created: i32, + data: Vec, + } + + #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Props, Clone)] + struct UrlImage { + url: String, + } + + if api_key.peek().is_empty() || prompt.peek().is_empty() || num_images.peek().is_empty() { + return dioxus::Ok(ImageResponse::default()); + } + + let res = reqwest::Client::new() + .post("https://api.openai.com/v1/images/generations") + .json(&serde_json::json!({ + "prompt": prompt.cloned(), + "n": num_images.cloned().parse::().unwrap_or(1), + "size":"1024x1024", + })) + .bearer_auth(api_key) + .send() + .await? + .json::() + .await?; + + Ok(res) + }); + + rsx! { + Stylesheet { href: "https://unpkg.com/bulma@0.9.0/css/bulma.min.css" } + div { class: "container", + div { class: "columns", + div { class: "column", + input { class: "input is-primary mt-4", + value: "{api_key}", + r#type: "text", + placeholder: "Your OpenAI API Key", + oninput: move |evt| api_key.set(evt.value()), + } + input { class: "input is-primary mt-4", + placeholder: "MAX 1000 Dgts", + r#type: "text", + value:"{prompt}", + oninput: move |evt| prompt.set(evt.value()) + } + input { class: "input is-primary mt-4", + r#type: "number", + min:"1", + max:"10", + value:"{num_images}", + oninput: move |evt| num_images.set(evt.value()), + } + } + } + button { + class: "button is-primary", + class: if image.is_pending() { "is-loading" }, + onclick: move |_| { + image.call(); + }, + "Generate image" + } + if let Some(image) = image.value() { + for image in image.read().data.as_slice() { + section { class: "is-flex", + div { class: "container is-fluid", + div { class: "container has-text-centered", + div { class: "is-justify-content-center", + div { class: "level", + div { class: "level-item", + figure { class: "image", img { alt: "", src: "{image.url}", } } + } + } + } + } + } + } + } + } + } + } +} diff --git a/examples/readme.rs b/examples/01-app-demos/repo_readme.rs similarity index 100% rename from examples/readme.rs rename to examples/01-app-demos/repo_readme.rs diff --git a/examples/todomvc.rs b/examples/01-app-demos/todomvc.rs similarity index 99% rename from examples/todomvc.rs rename to examples/01-app-demos/todomvc.rs index fd85d550e..1c33b71c4 100644 --- a/examples/todomvc.rs +++ b/examples/01-app-demos/todomvc.rs @@ -63,7 +63,7 @@ fn app() -> Element { }; rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } section { class: "todoapp", TodoHeader { todos } section { class: "main", diff --git a/examples/todomvc_store.rs b/examples/01-app-demos/todomvc_store.rs similarity index 99% rename from examples/todomvc_store.rs rename to examples/01-app-demos/todomvc_store.rs index a35e94150..0fd02249b 100644 --- a/examples/todomvc_store.rs +++ b/examples/01-app-demos/todomvc_store.rs @@ -124,7 +124,7 @@ fn app() -> Element { }; rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } section { class: "todoapp", TodoHeader { todos } section { class: "main", diff --git a/examples/weather_app.rs b/examples/01-app-demos/weather_app.rs similarity index 83% rename from examples/weather_app.rs rename to examples/01-app-demos/weather_app.rs index 687188f70..70b09b8b6 100644 --- a/examples/weather_app.rs +++ b/examples/01-app-demos/weather_app.rs @@ -1,7 +1,8 @@ #![allow(non_snake_case)] -use dioxus::prelude::*; +use dioxus::{fullstack::Loading, prelude::*}; use serde::{Deserialize, Serialize}; +use std::fmt::Display; fn main() { dioxus::launch(app); @@ -16,10 +17,10 @@ fn app() -> Element { id: 2950159, }); - let current_weather = use_resource(move || async move { get_weather(&country()).await }); + let current_weather = use_loader(move || get_weather(country())); rsx! { - document::Link { rel: "stylesheet", href: "https://unpkg.com/tailwindcss@^2.0/dist/tailwind.min.css" } + Stylesheet { href: "https://unpkg.com/tailwindcss@^2.0/dist/tailwind.min.css" } div { class: "mx-auto p-4 bg-gray-100 h-screen flex justify-center", div { class: "flex items-center justify-center flex-row", div { class: "flex items-start justify-center flex-row", @@ -27,14 +28,20 @@ fn app() -> Element { div { class: "flex flex-wrap w-full px-2", div { class: "bg-gray-900 text-white relative min-w-0 break-words rounded-lg overflow-hidden shadow-sm mb-4 w-full dark:bg-gray-600", div { class: "px-6 py-6 relative", - if let Some(Ok(weather)) = current_weather.read().as_ref() { - CountryData { - country: country.read().clone(), - weather: weather.clone(), + match current_weather { + Ok(weather) => rsx! { + CountryData { + country: country.read().clone(), + weather: weather.cloned(), + } + Forecast { weather: weather.cloned() } + }, + Err(Loading::Pending(_)) => rsx! { + div { "Loading weather data..." } + }, + Err(Loading::Failed(_)) => rsx! { + div { "Failed to load weather data." } } - Forecast { weather: weather.clone() } - } else { - p { "Loading.." } } } } @@ -110,8 +117,7 @@ fn Forecast(weather: WeatherResponse) -> Element { #[component] fn SearchBox(mut country: WriteSignal) -> Element { let mut input = use_signal(|| "".to_string()); - - let locations = use_resource(move || async move { get_locations(&input()).await }); + let locations = use_resource(move || get_locations(input())); rsx! { div { @@ -197,7 +203,7 @@ struct SearchResponse { results: WeatherLocations, } -async fn get_locations(input: &str) -> reqwest::Result { +async fn get_locations(input: impl Display) -> reqwest::Result { let res = reqwest::get(&format!( "https://geocoding-api.open-meteo.com/v1/search?name={input}" )) @@ -226,13 +232,9 @@ struct DailyWeather { temperature_2m_max: Vec, } -async fn get_weather(location: &WeatherLocation) -> reqwest::Result { - let res = reqwest::get(&format!("https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min&timezone=GMT", location.latitude, location.longitude)) - .await - ? +async fn get_weather(location: WeatherLocation) -> reqwest::Result { + reqwest::get(&format!("https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min&timezone=GMT", location.latitude, location.longitude)) + .await? .json::() .await - ?; - - Ok(res) } diff --git a/examples/01-app-demos/websocket_chat.rs b/examples/01-app-demos/websocket_chat.rs new file mode 100644 index 000000000..d86a22668 --- /dev/null +++ b/examples/01-app-demos/websocket_chat.rs @@ -0,0 +1,162 @@ +//! A websocket chat demo using Dioxus' built-in websocket support. +//! +//! We setup an endpoint at `/api/chat` that accepts a `name` and `user_id` query parameter. +//! Each client connects to that endpoint, and we use a `tokio::broadcast` channel +//! to send messages to all connected clients. +//! +//! In practice, you'd use a distributed messaging system (Redis PubSub / Kafka / etc) to coordinate +//! between multiple server instances and an additional database to persist chat history. + +use dioxus::fullstack::{WebSocketOptions, Websocket, use_websocket}; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +fn main() { + println!("IP and PORT: {:#?}", std::env::var("IP")); + println!("IP and PORT: {:#?}", std::env::var("PORT")); + dioxus::launch(app); +} + +fn app() -> Element { + // store the user's current input + let mut input = use_signal(|| "".to_string()); + + // Select a unique id for the user, and then use that entropy to pick a random name + let user_id = use_signal(uuid::Uuid::new_v4); + let user_name = use_signal(|| { + match user_id.read().as_bytes()[0] % 7 { + 0 => "Alice", + 1 => "Bob", + 2 => "Eve", + 3 => "Mallory", + 4 => "Trent", + 5 => "Peggy", + 6 => "Victor", + _ => "Charlie", + } + .to_string() + }); + + // Store the messages we've received from the server + let mut message_list = use_signal(Vec::::new); + + // Connect to the websocket endpoint + let mut socket = + use_websocket(move || uppercase_ws(user_name(), user_id(), Default::default())); + + use_future(move || async move { + while let Ok(msg) = socket.recv().await { + match msg { + ServerEvent::ReceiveMessage(message) => message_list.write().push(message), + ServerEvent::Connected { messages } => message_list.set(messages), + } + } + }); + + rsx! { + h1 { "WebSocket Chat" } + p { "Connection status: {socket.status():?} as {user_name}" } + input { + placeholder: "Type a message", + value: "{input}", + oninput: move |e| async move { input.set(e.value()) }, + onkeydown: move |e| async move { + if e.key() == Key::Enter { + _ = socket.send(ClientEvent::SendMessage(input.read().clone())).await; + input.set("".to_string()); + } + } + } + + div { + for message in message_list.read().iter().rev() { + pre { "{message.name}: {message.message}" } + } + } + } +} + +/// The events that the client can send to the server +#[derive(Serialize, Deserialize, Debug)] +enum ClientEvent { + SendMessage(String), +} + +/// The events that the server can send to the client +#[derive(Serialize, Deserialize, Debug)] +enum ServerEvent { + Connected { messages: Vec }, + ReceiveMessage(ChatMessage), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +struct ChatMessage { + user_id: Uuid, + name: String, + message: String, +} + +#[get("/api/chat?name&user_id")] +async fn uppercase_ws( + name: String, + user_id: Uuid, + options: WebSocketOptions, +) -> Result> { + use std::sync::LazyLock; + use tokio::sync::{ + Mutex, + broadcast::{self, Sender}, + }; + + // Every chat app needs a chat room! For this demo, we just use a tokio broadcast channel and a mutex-protected + // list of messages to store chat history. + // + // We place these types in the body of this serverfn since they're not used on the client, only the server. + static MESSAGES: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + static BROADCAST: LazyLock> = LazyLock::new(|| broadcast::channel(100).0); + + Ok(options.on_upgrade(move |mut socket| async move { + // Send back all the messages from the room to the new client + let messages = MESSAGES.lock().await.clone(); + _ = socket.send(ServerEvent::Connected { messages }).await; + + // Subscriber to the broadcast channel + let sender = BROADCAST.clone(); + let mut broadcast = sender.subscribe(); + + // Announce that we've joined + let _ = sender.send(ChatMessage { + message: format!("{name} has connected."), + user_id, + name: "[CONSOLE]".to_string(), + }); + + // Loop poll the broadcast receiver and the websocket for new messages + // If we receive a message from the broadcast channel, send it to the client + // If we receive a message from the client, broadcast it to all other clients and save it to the message list + loop { + tokio::select! { + Ok(msg) = broadcast.recv() => { + let _ = socket.send(ServerEvent::ReceiveMessage(msg)).await; + } + Ok(ClientEvent::SendMessage(message)) = socket.recv() => { + let chat_message = ChatMessage { + user_id, + name: name.clone(), + message, + }; + let _ = sender.send(chat_message.clone()); + MESSAGES.lock().await.push(chat_message.clone()); + }, + else => break, + } + } + + _ = sender.send(ChatMessage { + name: "[CONSOLE]".to_string(), + message: format!("{name} has disconnected."), + user_id, + }); + })) +} diff --git a/examples/disabled.rs b/examples/02-building-ui/disabled.rs similarity index 74% rename from examples/disabled.rs rename to examples/02-building-ui/disabled.rs index b48b1a912..9e6fe908b 100644 --- a/examples/disabled.rs +++ b/examples/02-building-ui/disabled.rs @@ -12,8 +12,9 @@ fn app() -> Element { let mut disabled = use_signal(|| false); rsx! { - div { style: "text-align: center; margin: 20px; display: flex; flex-direction: column; align-items: center;", - button { onclick: move |_| disabled.toggle(), + div { text_align: "center", margin: "20px", display: "flex", flex_direction: "column", align_items: "center", + button { + onclick: move |_| disabled.toggle(), "click to " if disabled() { "enable" } else { "disable" } " the lower button" diff --git a/examples/nested_listeners.rs b/examples/02-building-ui/nested_listeners.rs similarity index 100% rename from examples/nested_listeners.rs rename to examples/02-building-ui/nested_listeners.rs diff --git a/examples/svg.rs b/examples/02-building-ui/svg.rs similarity index 96% rename from examples/svg.rs rename to examples/02-building-ui/svg.rs index 06bc0fb87..b8084ae56 100644 --- a/examples/svg.rs +++ b/examples/02-building-ui/svg.rs @@ -7,7 +7,7 @@ //! If you `go-to-definition` on the `svg` element, you'll see its custom namespace. use dioxus::prelude::*; -use rand::{rng, Rng}; +use rand::{Rng, rng}; fn main() { dioxus::launch(|| { @@ -45,7 +45,7 @@ fn Dice() -> Element { value.set(rng().random_range(1..=6)) }, rect { x: -1000, y: -1000, width: 2000, height: 2000, rx: 200, fill: "#aaa" } - for ((x, y), _) in DOTS.iter().zip(active_dots.read().iter()).filter(|(_, &active)| active) { + for ((x, y), _) in DOTS.iter().zip(active_dots.read().iter()).filter(|(_, active)| **active) { circle { cx: *x * 600, cy: *y * 600, diff --git a/examples/custom_assets.rs b/examples/03-assets-styling/custom_assets.rs similarity index 100% rename from examples/custom_assets.rs rename to examples/03-assets-styling/custom_assets.rs diff --git a/examples/dynamic_asset.rs b/examples/03-assets-styling/dynamic_assets.rs similarity index 86% rename from examples/dynamic_asset.rs rename to examples/03-assets-styling/dynamic_assets.rs index 51efce6bc..1d7c12c41 100644 --- a/examples/dynamic_asset.rs +++ b/examples/03-assets-styling/dynamic_assets.rs @@ -20,11 +20,11 @@ fn app() -> Element { return; } - response.respond(Response::new(include_bytes!("./assets/logo.png").to_vec())); + response.respond(Response::new(include_bytes!("../assets/logo.png").to_vec())); }); rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } h1 { "Dynamic Assets" } img { src: "/logos/logo.png" } } diff --git a/examples/meta.rs b/examples/03-assets-styling/meta.rs similarity index 100% rename from examples/meta.rs rename to examples/03-assets-styling/meta.rs diff --git a/examples/03-assets-styling/meta_elements.rs b/examples/03-assets-styling/meta_elements.rs new file mode 100644 index 000000000..2d240a6af --- /dev/null +++ b/examples/03-assets-styling/meta_elements.rs @@ -0,0 +1,20 @@ +//! This example shows how to add metadata to the page with the Meta component + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + rsx! { + // You can use the Meta component to render a meta tag into the head of the page + // Meta tags are useful to provide information about the page to search engines and social media sites + // This example sets up meta tags for the open graph protocol for social media previews + Meta { property: "og:title", content: "My Site" } + Meta { property: "og:type", content: "website" } + Meta { property: "og:url", content: "https://www.example.com" } + Meta { property: "og:image", content: "https://example.com/image.jpg" } + Meta { name: "description", content: "My Site is a site" } + } +} diff --git a/examples/context_api.rs b/examples/04-managing-state/context_api.rs similarity index 97% rename from examples/context_api.rs rename to examples/04-managing-state/context_api.rs index e4c2103d9..70ed46dc2 100644 --- a/examples/context_api.rs +++ b/examples/04-managing-state/context_api.rs @@ -21,7 +21,7 @@ fn app() -> Element { use_context_provider(|| Signal::new(Theme::Light)); rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } main { class: "main-container", diff --git a/examples/errors.rs b/examples/04-managing-state/error_handling.rs similarity index 80% rename from examples/errors.rs rename to examples/04-managing-state/error_handling.rs index a7319e017..1888a5d10 100644 --- a/examples/errors.rs +++ b/examples/04-managing-state/error_handling.rs @@ -54,9 +54,20 @@ fn Show() -> Element { ErrorBoundary { handle_error: |errors: ErrorContext| { rsx! { - for error in errors.errors() { - if let Some(error) = error.show() { - {error} + for error in errors.error() { + // You can downcast the error to see if it's a specific type and render something specific for it + if let Some(_error) = error.downcast_ref::() { + div { + background_color: "red", + border: "black", + border_width: "2px", + border_radius: "5px", + p { "Failed to parse data" } + Link { + to: Route::Home {}, + "Go back to the homepage" + } + } } else { pre { color: "red", @@ -79,24 +90,8 @@ fn ParseNumberWithShow() -> Element { button { onclick: move |_| { let request_data = "0.5"; - let data: i32 = request_data.parse() - // You can attach rsx to results that can be displayed in the Error Boundary - .show(|_| rsx! { - div { - background_color: "red", - border: "black", - border_width: "2px", - border_radius: "5px", - p { "Failed to parse data" } - Link { - to: Route::Home {}, - "Go back to the homepage" - } - } - })?; - + let data: i32 = request_data.parse()?; println!("parsed {data}"); - Ok(()) }, "Click to throw an error" diff --git a/examples/global.rs b/examples/04-managing-state/global.rs similarity index 96% rename from examples/global.rs rename to examples/04-managing-state/global.rs index 3b7785a6e..dee5a42ad 100644 --- a/examples/global.rs +++ b/examples/04-managing-state/global.rs @@ -18,7 +18,7 @@ fn main() { fn app() -> Element { rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } Increment {} Decrement {} Reset {} diff --git a/examples/memo_chain.rs b/examples/04-managing-state/memo_chain.rs similarity index 100% rename from examples/memo_chain.rs rename to examples/04-managing-state/memo_chain.rs diff --git a/examples/reducer.rs b/examples/04-managing-state/reducer.rs similarity index 96% rename from examples/reducer.rs rename to examples/04-managing-state/reducer.rs index c1138e748..05fa5a55e 100644 --- a/examples/reducer.rs +++ b/examples/04-managing-state/reducer.rs @@ -17,7 +17,7 @@ fn app() -> Element { let mut state = use_signal(|| PlayerState { is_playing: false }); rsx!( - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } h1 {"Select an option"} // Add some cute animations if the radio is playing! diff --git a/examples/signals.rs b/examples/04-managing-state/signals.rs similarity index 97% rename from examples/signals.rs rename to examples/04-managing-state/signals.rs index 5d0e3aae4..f45b0d75e 100644 --- a/examples/signals.rs +++ b/examples/04-managing-state/signals.rs @@ -68,7 +68,7 @@ fn app() -> Element { } // We can also use the signal value as a slice - if let [ref first, .., ref last] = saved_values.read().as_slice() { + if let [first, .., last] = saved_values.read().as_slice() { li { "First and last: {first}, {last}" } } else { "No saved values" diff --git a/examples/backgrounded_futures.rs b/examples/05-using-async/backgrounded_futures.rs similarity index 100% rename from examples/backgrounded_futures.rs rename to examples/05-using-async/backgrounded_futures.rs diff --git a/examples/clock.rs b/examples/05-using-async/clock.rs similarity index 96% rename from examples/clock.rs rename to examples/05-using-async/clock.rs index 8ff058286..9418e4f52 100644 --- a/examples/clock.rs +++ b/examples/05-using-async/clock.rs @@ -12,7 +12,7 @@ fn app() -> Element { let mut millis = use_signal(|| 0); use_future(move || async move { - // Save our initial timea + // Save our initial time let start = Instant::now(); loop { diff --git a/examples/future.rs b/examples/05-using-async/future.rs similarity index 100% rename from examples/future.rs rename to examples/05-using-async/future.rs diff --git a/examples/streams.rs b/examples/05-using-async/streams.rs similarity index 94% rename from examples/streams.rs rename to examples/05-using-async/streams.rs index a71b5b9c1..2f74c6199 100644 --- a/examples/streams.rs +++ b/examples/05-using-async/streams.rs @@ -2,7 +2,7 @@ use async_std::task::sleep; use dioxus::prelude::*; -use futures_util::{future, stream, Stream, StreamExt}; +use futures_util::{Stream, StreamExt, future, stream}; fn main() { dioxus::launch(app); diff --git a/examples/05-using-async/suspense.rs b/examples/05-using-async/suspense.rs new file mode 100644 index 000000000..dfa8aee32 --- /dev/null +++ b/examples/05-using-async/suspense.rs @@ -0,0 +1,62 @@ +//! Suspense in Dioxus +//! +//! Suspense allows components to bubble up loading states to parent components, simplifying data fetching. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app) +} + +fn app() -> Element { + rsx! { + div { + h1 { "Dogs are very important" } + p { + "The dog or domestic dog (Canis familiaris[4][5] or Canis lupus familiaris[5])" + "is a domesticated descendant of the wolf which is characterized by an upturning tail." + "The dog derived from an ancient, extinct wolf,[6][7] and the modern grey wolf is the" + "dog's nearest living relative.[8] The dog was the first species to be domesticated,[9][8]" + "by hunter–gatherers over 15,000 years ago,[7] before the development of agriculture.[1]" + } + h3 { "Illustrious Dog Photo" } + ErrorBoundary { handle_error: |_| rsx! { p { "Error loading doggos" } }, + SuspenseBoundary { fallback: move |_| rsx! { "Loading doggos..." }, + Doggo {} + } + } + } + } +} + +#[component] +fn Doggo() -> Element { + // `use_loader` returns a Result, Loading>. Loading can either be "Pending" or "Failed". + // When we use the `?` operator, the pending/error state will be thrown to the nearest Suspense or Error boundary. + // + // During SSR, `use_loader` will serialize the contents of the fetch, and during hydration, the client will + // use the pre-fetched data instead of re-fetching to render. + let mut dog = use_loader(move || async move { + #[derive(serde::Deserialize, serde::Serialize, PartialEq)] + struct DogApi { + message: String, + } + + reqwest::get("https://dog.ceo/api/breeds/image/random/") + .await + .unwrap() + .json::() + .await + })?; + + rsx! { + button { onclick: move |_| dog.restart(), "Click to fetch another doggo" } + div { + img { + max_width: "500px", + max_height: "500px", + src: "{dog.read().message}" + } + } + } +} diff --git a/examples/flat_router.rs b/examples/06-routing/flat_router.rs similarity index 97% rename from examples/flat_router.rs rename to examples/06-routing/flat_router.rs index 0da8bc87e..e745294af 100644 --- a/examples/flat_router.rs +++ b/examples/06-routing/flat_router.rs @@ -14,7 +14,7 @@ const STYLE: Asset = asset!("/examples/assets/flat_router.css"); fn main() { dioxus::launch(|| { rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } Router:: {} } }) diff --git a/examples/hash_fragment_state.rs b/examples/06-routing/hash_fragment_state.rs similarity index 100% rename from examples/hash_fragment_state.rs rename to examples/06-routing/hash_fragment_state.rs diff --git a/examples/link.rs b/examples/06-routing/link.rs similarity index 97% rename from examples/link.rs rename to examples/06-routing/link.rs index 0ce3378df..2c072acc7 100644 --- a/examples/link.rs +++ b/examples/06-routing/link.rs @@ -16,7 +16,7 @@ fn main() { fn app() -> Element { rsx! ( - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } Router:: {} ) } diff --git a/examples/query_segment_search.rs b/examples/06-routing/query_segment_search.rs similarity index 100% rename from examples/query_segment_search.rs rename to examples/06-routing/query_segment_search.rs diff --git a/examples/router.rs b/examples/06-routing/router.rs similarity index 98% rename from examples/router.rs rename to examples/06-routing/router.rs index a533fcce1..fcf6a2bf7 100644 --- a/examples/router.rs +++ b/examples/06-routing/router.rs @@ -13,7 +13,7 @@ const STYLE: Asset = asset!("/examples/assets/router.css"); fn main() { dioxus::launch(|| { rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } Router:: {} } }); diff --git a/examples/router_resource.rs b/examples/06-routing/router_resource.rs similarity index 100% rename from examples/router_resource.rs rename to examples/06-routing/router_resource.rs diff --git a/examples/router_restore_scroll.rs b/examples/06-routing/router_restore_scroll.rs similarity index 100% rename from examples/router_restore_scroll.rs rename to examples/06-routing/router_restore_scroll.rs diff --git a/examples/simple_router.rs b/examples/06-routing/simple_router.rs similarity index 100% rename from examples/simple_router.rs rename to examples/06-routing/simple_router.rs diff --git a/examples/07-fullstack/auth-with-fullstack/Cargo.toml b/examples/07-fullstack/auth-with-fullstack/Cargo.toml new file mode 100644 index 000000000..c4fc5b75c --- /dev/null +++ b/examples/07-fullstack/auth-with-fullstack/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "fullstack-auth-example-with-fullstack" +version = "0.1.0" +edition = "2021" +publish = false + + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dioxus-web = { workspace = true, features = ["hydrate"], optional = true } +dioxus = { features = ["fullstack"], workspace = true } +axum = { workspace = true, optional = true, features = ["macros"]} +tokio = { workspace = true, features = ["full"], optional = true } +tower-http = { workspace = true, features = ["auth"], optional = true } +async-trait = { version = "0.1.88", optional = true } +sqlx = { version = "0.8.6", features = [ + "macros", + "migrate", + "postgres", + "sqlite", + "_unstable-all-types", + "tls-native-tls", + "runtime-tokio", +], optional = true } +http = { workspace = true, optional = true } +tower = { workspace = true, optional = true } + +execute = "0.2.13" +serde = { workspace = true } +anyhow = { workspace = true } + +[dependencies.axum_session] +workspace = true +optional = true + +[dependencies.axum_session_auth] +workspace = true +optional = true + +[dependencies.axum_session_sqlx] +workspace = true +features = ["sqlite"] +optional = true + +[features] +default = ["web", "server"] +server = [ + "dioxus/server", + "dep:axum", + "dep:tokio", + "dep:tower-http", + "dep:async-trait", + "dep:sqlx", + "dep:axum_session", + "dep:axum_session_auth", + "dep:axum_session_sqlx", + "dep:http", + "dep:tower", +] +web = ["dioxus/web", "dep:dioxus-web"] diff --git a/examples/07-fullstack/auth-with-fullstack/src/auth.rs b/examples/07-fullstack/auth-with-fullstack/src/auth.rs new file mode 100644 index 000000000..5b31733d5 --- /dev/null +++ b/examples/07-fullstack/auth-with-fullstack/src/auth.rs @@ -0,0 +1,81 @@ +//! The code here is pulled from the `axum-session-auth` crate examples, requiring little to no +//! modification to work with dioxus fullstack. + +use async_trait::async_trait; +use axum_session_auth::*; +use axum_session_sqlx::SessionSqlitePool; +use serde::{Deserialize, Serialize}; +use sqlx::sqlite::SqlitePool; +use std::collections::HashSet; + +pub(crate) type Session = axum_session_auth::AuthSession; +pub(crate) type AuthLayer = + axum_session_auth::AuthSessionLayer; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct User { + pub id: i32, + pub anonymous: bool, + pub username: String, + pub permissions: HashSet, +} + +#[derive(sqlx::FromRow, Clone)] +pub(crate) struct SqlPermissionTokens { + pub token: String, +} + +#[async_trait] +impl Authentication for User { + async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result { + let db = pool.unwrap(); + + #[derive(sqlx::FromRow, Clone)] + struct SqlUser { + id: i32, + anonymous: bool, + username: String, + } + + let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = $1") + .bind(userid) + .fetch_one(db) + .await + .unwrap(); + + //lets just get all the tokens the user can use, we will only use the full permissions if modifying them. + let sql_user_perms = sqlx::query_as::<_, SqlPermissionTokens>( + "SELECT token FROM user_permissions WHERE user_id = $1;", + ) + .bind(userid) + .fetch_all(db) + .await + .unwrap(); + + Ok(User { + id: sqluser.id, + anonymous: sqluser.anonymous, + username: sqluser.username, + permissions: sql_user_perms.into_iter().map(|x| x.token).collect(), + }) + } + + fn is_authenticated(&self) -> bool { + !self.anonymous + } + + fn is_active(&self) -> bool { + !self.anonymous + } + + fn is_anonymous(&self) -> bool { + self.anonymous + } +} + +#[async_trait] +impl HasPermission for User { + async fn has(&self, perm: &str, _pool: &Option<&SqlitePool>) -> bool { + self.permissions.contains(perm) + } +} diff --git a/examples/07-fullstack/auth-with-fullstack/src/main.rs b/examples/07-fullstack/auth-with-fullstack/src/main.rs new file mode 100644 index 000000000..30d473dcb --- /dev/null +++ b/examples/07-fullstack/auth-with-fullstack/src/main.rs @@ -0,0 +1,158 @@ +//! This example showcases how to use the `axum-session-auth` crate with Dioxus fullstack. +//! We add the `auth::Session` extractor to our server functions to get access to the current user session. +//! +//! To initialize the axum router, we use `dioxus::serve` to spawn a custom axum server that creates +//! our database, session store, and authentication layer. +//! +//! The `.serve_dioxus_application` method is used to mount our Dioxus app as a fallback service to +//! handle HTML rendering and static assets. +//! +//! We easily share the "permissions" between the server and client by using a `HashSet` +//! which is serialized to/from JSON automatically by the server function system. + +use std::collections::HashSet; + +use dioxus::prelude::*; + +#[cfg(feature = "server")] +mod auth; + +fn main() { + // On the client, we simply launch the app as normal, taking over the main thread + #[cfg(not(feature = "server"))] + dioxus::launch(app); + + // On the server, we can use `dioxus::serve` to create a server that serves our app. + // + // The `serve` function takes a closure that returns a `Future` which resolves to an `axum::Router`. + // + // We return a `Router` such that dioxus sets up logging, hot-reloading, devtools, and wires up the + // IP and PORT environment variables to our server. + #[cfg(feature = "server")] + dioxus::serve(|| async { + use crate::auth::*; + use axum_session::{SessionConfig, SessionLayer, SessionStore}; + use axum_session_auth::AuthConfig; + use axum_session_sqlx::SessionSqlitePool; + use sqlx::{sqlite::SqlitePoolOptions, Executor}; + + // Create an in-memory SQLite database and set up our tables + let db = SqlitePoolOptions::new() + .max_connections(20) + .connect_with("sqlite::memory:".parse()?) + .await?; + + // Create the tables (sessions, users) + db.execute(r#"CREATE TABLE IF NOT EXISTS users ( "id" INTEGER PRIMARY KEY, "anonymous" BOOLEAN NOT NULL, "username" VARCHAR(256) NOT NULL )"#,) + .await?; + db.execute(r#"CREATE TABLE IF NOT EXISTS user_permissions ( "user_id" INTEGER NOT NULL, "token" VARCHAR(256) NOT NULL)"#,) + .await?; + + // Insert in some test data for two users (one anonymous, one normal) + db.execute(r#"INSERT INTO users (id, anonymous, username) SELECT 1, true, 'Guest' ON CONFLICT(id) DO UPDATE SET anonymous = EXCLUDED.anonymous, username = EXCLUDED.username"#,) + .await?; + db.execute(r#"INSERT INTO users (id, anonymous, username) SELECT 2, false, 'Test' ON CONFLICT(id) DO UPDATE SET anonymous = EXCLUDED.anonymous, username = EXCLUDED.username"#,) + .await?; + + // Make sure our test user has the ability to view categories + db.execute(r#"INSERT INTO user_permissions (user_id, token) SELECT 2, 'Category::View'"#) + .await?; + + // Create an axum router that dioxus will attach the app to + Ok(dioxus::server::router(app) + .layer( + AuthLayer::new(Some(db.clone())) + .with_config(AuthConfig::::default().with_anonymous_user_id(Some(1))), + ) + .layer(SessionLayer::new( + SessionStore::::new( + Some(db.into()), + SessionConfig::default().with_table_name("test_table"), + ) + .await?, + ))) + }); +} + +/// The UI for our app - is just a few buttons to call our server functions and display the results. +fn app() -> Element { + let mut login = use_action(login); + let mut user_name = use_action(get_user_name); + let mut permissions = use_action(get_permissions); + let mut logout = use_action(logout); + + let fetch_new = move |_| async move { + user_name.call().await; + permissions.call().await; + }; + + rsx! { + div { + button { + onclick: move |_| async move { + login.call().await; + }, + "Login Test User" + } + button { + onclick: move |_| async move { + logout.call().await; + }, + "Logout" + } + button { + onclick: fetch_new, + "Fetch User Info" + } + + pre { "Logged in: {login.result():?}" } + pre { "User name: {user_name.result():?}" } + pre { "Permissions: {permissions.result():?}" } + } + } +} + +/// We use the `auth::Session` extractor to get access to the current user session. +/// This lets us modify the user session, log in/out, and access the current user. +#[post("/api/user/login", auth: auth::Session)] +pub async fn login() -> Result<()> { + auth.login_user(2); + Ok(()) +} + +/// Just like `login`, but this time we log out the user. +#[post("/api/user/logout", auth: auth::Session)] +pub async fn logout() -> Result<()> { + auth.logout_user(); + Ok(()) +} + +/// We can access the current user via `auth.current_user`. +/// We can have both anonymous user (id 1) and a logged in user (id 2). +/// +/// Logged-in users will have more permissions which we can modify. +#[post("/api/user/name", auth: auth::Session)] +pub async fn get_user_name() -> Result { + Ok(auth.current_user.unwrap().username) +} + +/// Get the current user's permissions, guarding the endpoint with the `Auth` validator. +/// If this returns false, we use the `or_unauthorized` extension to return a 401 error. +#[get("/api/user/permissions", auth: auth::Session)] +pub async fn get_permissions() -> Result> { + use crate::auth::User; + use axum_session_auth::{Auth, Rights}; + + let user = auth.current_user.unwrap(); + + Auth::::build([axum::http::Method::GET], false) + .requires(Rights::any([ + Rights::permission("Category::View"), + Rights::permission("Admin::View"), + ])) + .validate(&user, &axum::http::Method::GET, None) + .await + .or_unauthorized("You do not have permission to view categories")?; + + Ok(user.permissions) +} diff --git a/examples/fullstack-auth/Cargo.toml b/examples/07-fullstack/auth/Cargo.toml similarity index 92% rename from examples/fullstack-auth/Cargo.toml rename to examples/07-fullstack/auth/Cargo.toml index 6add0c9b8..fea47b1d6 100644 --- a/examples/fullstack-auth/Cargo.toml +++ b/examples/07-fullstack/auth/Cargo.toml @@ -10,8 +10,6 @@ publish = false [dependencies] dioxus-web = { workspace = true, features = ["hydrate"], optional = true } dioxus = { features = ["fullstack"], workspace = true } -dioxus-fullstack = { workspace = true } -dioxus-cli-config = { workspace = true, optional = true } axum = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"], optional = true } tower-http = { workspace = true, features = ["auth"], optional = true } @@ -49,7 +47,6 @@ optional = true default = [] server = [ "dioxus/server", - "dep:dioxus-cli-config", "dep:axum", "dep:tokio", "dep:tower-http", diff --git a/examples/fullstack-auth/src/auth.rs b/examples/07-fullstack/auth/src/auth.rs similarity index 57% rename from examples/fullstack-auth/src/auth.rs rename to examples/07-fullstack/auth/src/auth.rs index 7d67ee13c..68b6bd1c1 100644 --- a/examples/fullstack-auth/src/auth.rs +++ b/examples/07-fullstack/auth/src/auth.rs @@ -1,23 +1,15 @@ use async_trait::async_trait; -use axum::{ - http::Method, - response::{IntoResponse, Response}, - routing::get, - Router, -}; -use axum_session::{SessionConfig, SessionLayer, SessionStore}; use axum_session_auth::*; use axum_session_sqlx::SessionSqlitePool; -use core::pin::Pin; -use dioxus::prelude::*; use serde::{Deserialize, Serialize}; -use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; -use std::error::Error; -use std::future::Future; -use std::{collections::HashSet, net::SocketAddr, str::FromStr}; +use sqlx::sqlite::SqlitePool; +use std::collections::HashSet; + +pub(crate) type Session = + axum_session_auth::AuthSession; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { +pub(crate) struct User { pub id: i32, pub anonymous: bool, pub username: String, @@ -25,25 +17,10 @@ pub struct User { } #[derive(sqlx::FromRow, Clone)] -pub struct SqlPermissionTokens { +pub(crate) struct SqlPermissionTokens { pub token: String, } -impl Default for User { - fn default() -> Self { - let mut permissions = HashSet::new(); - - permissions.insert("Category::View".to_owned()); - - Self { - id: 1, - anonymous: true, - username: "Guest".into(), - permissions, - } - } -} - #[async_trait] impl Authentication for User { async fn load_user(userid: i64, pool: Option<&SqlitePool>) -> Result { @@ -76,6 +53,13 @@ impl HasPermission for User { impl User { pub async fn get_user(id: i64, pool: &SqlitePool) -> Option { + #[derive(sqlx::FromRow, Clone)] + struct SqlUser { + id: i32, + anonymous: bool, + username: String, + } + let sqluser = sqlx::query_as::<_, SqlUser>("SELECT * FROM users WHERE id = $1") .bind(id) .fetch_one(pool) @@ -91,10 +75,15 @@ impl User { .await .ok()?; - Some(sqluser.into_user(Some(sql_user_perms))) + Some(User { + id: sqluser.id, + anonymous: sqluser.anonymous, + username: sqluser.username, + permissions: sql_user_perms.into_iter().map(|x| x.token).collect(), + }) } - pub async fn create_user_tables(pool: &SqlitePool) { + pub async fn create_user_tables(pool: &SqlitePool) -> Result<(), sqlx::Error> { sqlx::query( r#" CREATE TABLE IF NOT EXISTS users ( @@ -105,20 +94,17 @@ impl User { "#, ) .execute(pool) - .await - .unwrap(); + .await?; sqlx::query( r#" CREATE TABLE IF NOT EXISTS user_permissions ( "user_id" INTEGER NOT NULL, "token" VARCHAR(256) NOT NULL - ) - "#, + )"#, ) .execute(pool) - .await - .unwrap(); + .await?; sqlx::query( r#" @@ -130,8 +116,7 @@ impl User { "#, ) .execute(pool) - .await - .unwrap(); + .await?; sqlx::query( r#" @@ -143,8 +128,7 @@ impl User { "#, ) .execute(pool) - .await - .unwrap(); + .await?; sqlx::query( r#" @@ -153,51 +137,8 @@ impl User { "#, ) .execute(pool) - .await - .unwrap(); + .await?; + + Ok(()) } } - -#[derive(sqlx::FromRow, Clone)] -pub struct SqlUser { - pub id: i32, - pub anonymous: bool, - pub username: String, -} - -impl SqlUser { - pub fn into_user(self, sql_user_perms: Option>) -> User { - User { - id: self.id, - anonymous: self.anonymous, - username: self.username, - permissions: if let Some(user_perms) = sql_user_perms { - user_perms - .into_iter() - .map(|x| x.token) - .collect::>() - } else { - HashSet::::new() - }, - } - } -} - -pub async fn connect_to_database() -> SqlitePool { - let connect_opts = SqliteConnectOptions::from_str("sqlite::memory:").unwrap(); - - SqlitePoolOptions::new() - .max_connections(5) - .connect_with(connect_opts) - .await - .unwrap() -} - -pub type Session = - axum_session_auth::AuthSession; - -pub async fn get_session() -> ServerFnResult { - extract::() - .await - .map_err(|_| ServerFnError::new("AuthSessionLayer was not found")) -} diff --git a/examples/07-fullstack/auth/src/main.rs b/examples/07-fullstack/auth/src/main.rs new file mode 100644 index 000000000..0ca4ff8f3 --- /dev/null +++ b/examples/07-fullstack/auth/src/main.rs @@ -0,0 +1,129 @@ +#[cfg(feature = "server")] +mod auth; + +use dioxus::prelude::*; + +fn main() { + #[cfg(feature = "server")] + dioxus::serve(|| async { + use crate::auth::*; + use axum_session::{SessionConfig, SessionLayer, SessionStore}; + use axum_session_auth::AuthConfig; + use axum_session_sqlx::SessionSqlitePool; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(SqliteConnectOptions::from_str("sqlite::memory:").unwrap()) + .await?; + + // This Defaults as normal Cookies. + // To enable Private cookies for integrity, and authenticity please check the next Example. + let session_config = SessionConfig::default().with_table_name("test_table"); + let auth_config = AuthConfig::::default().with_anonymous_user_id(Some(1)); + let session_store = + SessionStore::::new(Some(pool.clone().into()), session_config) + .await + .unwrap(); + + User::create_user_tables(&pool).await?; + + // build our application with some routes + Ok( + dioxus::server::router(app) + .layer( + axum_session_auth::AuthSessionLayer::< + User, + i64, + SessionSqlitePool, + sqlx::SqlitePool, + >::new(Some(pool)) + .with_config(auth_config), + ) + .layer(SessionLayer::new(session_store)), + ) + }); + + dioxus::launch(app); +} + +fn app() -> Element { + let mut user_name = use_action(get_user_name); + let mut permissions = use_action(get_permissions); + let mut login = use_action(login); + + rsx! { + div { + button { + onclick: move |_| { + login.call(); + }, + "Login Test User" + } + } + div { + button { + onclick: move |_| { + user_name.call(); + }, + "Get User Name" + } + "User name: {user_name.value().unwrap_or_default()}" + } + div { + button { + onclick: move |_| { + permissions.call(); + }, + "Get Permissions" + } + "Permissions: {permissions.value().unwrap_or_default()}" + } + } +} + +#[get("/api/user/name", auth: auth::Session)] +pub async fn get_user_name() -> Result { + Ok(auth + .current_user + .context("UNAUTHORIZED")? + .username + .to_string()) +} + +#[post("/api/user/login", auth: auth::Session)] +pub async fn login() -> Result<()> { + auth.login_user(2); + Ok(()) +} + +#[get("/api/user/permissions", auth: auth::Session)] +pub async fn get_permissions() -> Result { + use crate::auth::User; + use axum_session_auth::{Auth, Rights}; + + let user = auth.current_user.as_ref().context("UNAUTHORIZED")?; + + // lets check permissions only and not worry about if they are anon or not + let has_permissions = + Auth::::build([axum::http::Method::POST], false) + .requires(Rights::any([ + Rights::permission("Category::View"), + Rights::permission("Admin::View"), + ])) + .validate(user, &axum::http::Method::GET, None) + .await; + + if !has_permissions { + dioxus::core::bail!( + "User {}, Does not have permissions needed to view this page please login", + user.username + ); + } + + Ok(format!( + "User has Permissions needed. {:?}", + user.permissions + )) +} diff --git a/examples/07-fullstack/custom_axum_serve.rs b/examples/07-fullstack/custom_axum_serve.rs new file mode 100644 index 000000000..896340f2b --- /dev/null +++ b/examples/07-fullstack/custom_axum_serve.rs @@ -0,0 +1,46 @@ +//! This example demonstrates how to use `dioxus::serve` with a custom Axum router. +//! +//! By default, `dioxus::launch` takes over the main thread and runs the Dioxus application. +//! However, if you want to integrate Dioxus into an existing web server or use a custom router, +//! you can use `dioxus::serve` to create a server that serves your Dioxus application alongside +//! other routes. +//! +//! `dioxus::serve` sets up an async runtime, logging, hot-reloading, crash handling, and more. +//! You can then use the `.serve_dioxus_application` method on your router to serve the Dioxus app. +//! +//! `dioxus::serve` is most useful for customizing the server setup, such as adding middleware, +//! custom routes, or integrating with existing axum backend code. +//! +//! Note that `dioxus::serve` is accepts a Router from `axum`. Dioxus will use the IP and PORT +//! environment variables to determine where to bind the server. To customize the port, use environment +//! variables or a `.env` file. +//! +//! On other platforms (like desktop or mobile), you'll want to use `dioxus::launch` instead and then +//! handle async loading of data through hooks like `use_future` or `use_resource` and give the user +//! a loading state while data is being fetched. + +use dioxus::prelude::*; + +fn main() { + // On the client we just launch the app as normal. + #[cfg(not(feature = "server"))] + dioxus::launch(app); + + // On the server, we can use `dioxus::serve` and `.serve_dioxus_application` to serve our app with routing. + // The `dioxus::server::router` function creates a new axum Router with the necessary routes to serve the Dioxus app. + #[cfg(feature = "server")] + dioxus::serve(|| async move { + use dioxus::server::axum::routing::{get, post}; + + Ok(dioxus::server::router(app) + .route("/submit", post(|| async { "Form submitted!" })) + .route("/about", get(|| async { "About us" })) + .route("/contact", get(|| async { "Contact us" }))) + }); +} + +fn app() -> Element { + rsx! { + div { "Hello from Dioxus!" } + } +} diff --git a/examples/fullstack-desktop/Cargo.toml b/examples/07-fullstack/desktop/Cargo.toml similarity index 100% rename from examples/fullstack-desktop/Cargo.toml rename to examples/07-fullstack/desktop/Cargo.toml diff --git a/examples/fullstack-desktop/src/main.rs b/examples/07-fullstack/desktop/src/main.rs similarity index 87% rename from examples/fullstack-desktop/src/main.rs rename to examples/07-fullstack/desktop/src/main.rs index 7d6284097..04f924ad5 100644 --- a/examples/fullstack-desktop/src/main.rs +++ b/examples/07-fullstack/desktop/src/main.rs @@ -2,9 +2,10 @@ use dioxus::prelude::*; fn main() { - // Set the url of the server where server functions are hosted. + // Make sure to set the url of the server where server functions are hosted - they aren't always at localhost #[cfg(not(feature = "server"))] dioxus::fullstack::set_server_url("http://127.0.0.1:8080"); + dioxus::launch(app); } @@ -30,14 +31,13 @@ pub fn app() -> Element { } } -#[server] +#[post("/api/data")] async fn post_server_data(data: String) -> ServerFnResult { println!("Server received: {}", data); - Ok(()) } -#[server] +#[get("/api/data")] async fn get_server_data() -> ServerFnResult { Ok("Hello from the server!".to_string()) } diff --git a/examples/07-fullstack/dog_app_self_hosted.rs b/examples/07-fullstack/dog_app_self_hosted.rs new file mode 100644 index 000000000..31062ae46 --- /dev/null +++ b/examples/07-fullstack/dog_app_self_hosted.rs @@ -0,0 +1,48 @@ +//! This example showcases a fullstack variant of the "dog app" demo, but with the loader and actions +//! self-hosted instead of using the Dog API. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + // Fetch the list of breeds from the Dog API, using the `?` syntax to suspend or throw errors + let breed_list = use_loader(list_breeds)?; + + // Whenever this action is called, it will re-run the future and return the result. + let mut breed = use_action(get_random_breed_image); + + rsx! { + h1 { "Doggo selector" } + div { width: "400px", + for cur_breed in breed_list.read().iter().take(20).cloned() { + button { onclick: move |_| { breed.call(cur_breed.clone()); }, "{cur_breed}" } + } + } + div { + match breed.result() { + None => rsx! { div { "Click the button to fetch a dog!" } }, + Some(Err(_e)) => rsx! { div { "Failed to fetch a dog, please try again." } }, + Some(Ok(res)) => rsx! { img { max_width: "500px", max_height: "500px", src: "{res}" } }, + } + } + + } +} + +#[get("/api/breeds/list/all")] +async fn list_breeds() -> Result> { + Ok(vec!["bulldog".into(), "labrador".into(), "poodle".into()]) +} + +#[get("/api/breed/{breed}/images/random")] +async fn get_random_breed_image(breed: String) -> Result { + match breed.as_str() { + "bulldog" => Ok("https://images.dog.ceo/breeds/buhund-norwegian/hakon3.jpg".into()), + "labrador" => Ok("https://images.dog.ceo/breeds/labrador/n02099712_2501.jpg".into()), + "poodle" => Ok("https://images.dog.ceo/breeds/poodle-standard/n02113799_5973.jpg".into()), + _ => HttpError::not_found("Breed not found")?, + } +} diff --git a/examples/07-fullstack/full_request_access.rs b/examples/07-fullstack/full_request_access.rs new file mode 100644 index 000000000..96af0321e --- /dev/null +++ b/examples/07-fullstack/full_request_access.rs @@ -0,0 +1,37 @@ +//! This example shows how to get access to the full axum request in a handler. +//! +//! The extra arguments in the `post` macro are passed to the handler function, but not exposed +//! to the client. This means we can still call the endpoint from the client, but have full access +//! to the request on the server. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut file_id = use_action(full_request); + + rsx! { + div { "Access to full axum request" } + button { onclick: move |_| file_id.call(), "Upload file" } + } +} + +/// Example of accessing the full axum request in a handler +/// +/// The `request: axum_core::extract::Request` argument is placed in the handler function, but not +/// exposed to the client. +#[post("/api/full_request_access", request: axum_core::extract::Request)] +async fn full_request() -> Result<()> { + let headers = request.headers(); + + if headers.contains_key("x-api-key") { + println!("API key found"); + } else { + println!("No API key found"); + } + + Ok(()) +} diff --git a/examples/07-fullstack/fullstack_hello_world.rs b/examples/07-fullstack/fullstack_hello_world.rs new file mode 100644 index 000000000..db0ecfdb0 --- /dev/null +++ b/examples/07-fullstack/fullstack_hello_world.rs @@ -0,0 +1,24 @@ +//! A simple example using Dioxus Fullstack to call a server action. +//! +//! the `get`, `post`, `put`, `delete`, etc macros are used to define server actions that can be +//! called from the client. The action can take arguments and return a value, and the client +//! will automatically serialize and deserialize the data. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(|| { + let mut message = use_action(get_message); + + rsx! { + h1 { "Server says: "} + pre { "{message:?}"} + button { onclick: move |_| message.call("world".into(), 30), "Click me!" } + } + }); +} + +#[get("/api/{name}/?age")] +async fn get_message(name: String, age: i32) -> Result { + Ok(format!("Hello {}, you are {} years old!", name, age)) +} diff --git a/examples/07-fullstack/handling_errors.rs b/examples/07-fullstack/handling_errors.rs new file mode 100644 index 000000000..3b06d8095 --- /dev/null +++ b/examples/07-fullstack/handling_errors.rs @@ -0,0 +1,173 @@ +//! An example of handling errors from server functions. +//! +//! This example showcases a few important error handling patterns when using Dioxus Fullstack. +//! +//! Run with: +//! +//! ```sh +//! dx serve --web +//! ``` +//! +//! What this example shows: +//! - You can return `anyhow::Result` (i.e. `Result` without an `E`) for +//! untyped errors with rich context (converted to HTTP 500 responses by default). +//! - You can return `Result` where `E` is one of: +//! - `HttpError` (convenience for returning HTTP status codes) +//! - `StatusCode` (return raw status codes) +//! - a custom error type that implements `From` or +//! is `Serialize`/`Deserialize` so it can be sent to the client. +//! - This file demonstrates external API errors, custom typed errors, explicit +//! HTTP errors, and basic success cases. The UI uses `use_action` to call +//! server functions and shows loading/result states simply. +//! +//! Try running requests against the endpoints directly with `curl` or `postman` to see the actual HTTP responses! + +use dioxus::fullstack::{Json, StatusCode}; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +fn main() { + dioxus::launch(|| { + let mut dog_data = use_action(get_dog_data); + let mut dog_data_err = use_action(get_dog_data_err); + let mut ip_data = use_action(get_ip_data); + let mut custom_data = use_action(move || { + get_custom_encoding(Json(serde_json::json!({ + "example": "data", + "number": 123, + "array": [1, 2, 3], + }))) + }); + let mut error_data = use_action(get_throws_error); + let mut typed_error_data = use_action(get_throws_typed_error); + let mut throws_ok_data = use_action(get_throws_ok); + let mut http_error_data = use_action(throws_http_error); + let mut http_error_context_data = use_action(throws_http_error_context); + + rsx! { + button { onclick: move |_| { dog_data.call(); }, "Fetch dog data" } + button { onclick: move |_| { ip_data.call(); }, "Fetch IP data" } + button { onclick: move |_| { custom_data.call(); }, "Fetch custom encoded data" } + button { onclick: move |_| { error_data.call(); }, "Fetch error data" } + button { onclick: move |_| { typed_error_data.call(); }, "Fetch typed error data" } + button { onclick: move |_| { dog_data_err.call(); }, "Fetch dog error data" } + button { onclick: move |_| { throws_ok_data.call(); }, "Fetch throws ok data" } + button { onclick: move |_| { http_error_data.call(); }, "Fetch HTTP 400" } + button { onclick: move |_| { http_error_context_data.call(); }, "Fetch HTTP 400 (context)" } + button { + onclick: move |_| { + ip_data.reset(); + dog_data.reset(); + custom_data.reset(); + error_data.reset(); + typed_error_data.reset(); + dog_data_err.reset(); + throws_ok_data.reset(); + http_error_data.reset(); + http_error_context_data.reset(); + }, + "Clear data" + } + div { display: "flex", flex_direction: "column", gap: "8px", + pre { "Dog data: {dog_data.value():#?}" } + pre { "IP data: {ip_data.value():#?}" } + pre { "Custom encoded data: {custom_data.value():#?}" } + pre { "Error data: {error_data.result():#?}" } + pre { "Typed error data: {typed_error_data.result():#?}" } + pre { "HTTP 400 data: {http_error_data.result():#?}" } + pre { "HTTP 400 (context) data: {http_error_context_data.result():#?}" } + pre { "Dog error data: {dog_data_err.result():#?}" } + pre { "Throws ok data: {throws_ok_data.result():#?}" } + } + } + }); +} + +/// Simple POST endpoint used to show a successful server function that returns `StatusCode`. +#[post("/api/data")] +async fn post_server_data(data: String) -> Result<(), StatusCode> { + println!("Server received: {}", data); + Ok(()) +} + +/// Fetches IP info from an external service. Demonstrates propagation of external errors. +#[get("/api/ip-data")] +async fn get_ip_data() -> Result { + Ok(reqwest::get("https://httpbin.org/ip").await?.json().await?) +} + +/// Fetches a random dog image (successful external API example). +#[get("/api/dog-data")] +async fn get_dog_data() -> Result { + Ok(reqwest::get("https://dog.ceo/api/breeds/image/random") + .await? + .json() + .await?) +} + +/// Calls the Dog API with an invalid breed to trigger an external API error (e.g. 404). +#[get("/api/dog-data-err")] +async fn get_dog_data_err() -> Result { + Ok( + reqwest::get("https://dog.ceo/api/breed/NOT_A_REAL_DOG/images") + .await? + .json() + .await?, + ) +} + +/// Accepts JSON and returns a custom-encoded JSON response. +#[post("/api/custom-encoding")] +async fn get_custom_encoding(takes: Json) -> Result { + Ok(serde_json::json!({ + "message": "This response was encoded with a custom encoder!", + "success": true, + "you sent": takes.0, + })) +} + +/// Returns an untyped `anyhow` error with context (results in HTTP 500). +#[get("/api/untyped-error")] +async fn get_throws_error() -> Result<()> { + Err(None.context("This is an example error using anyhow::Error")?) +} + +/// Demonstrates returning an explicit HTTP error (400 Bad Request) using `HttpError`. +#[get("/api/throws-http-error")] +async fn throws_http_error() -> Result<()> { + HttpError::bad_request("Bad request example")?; + Ok(()) +} + +/// Convenience example: handles an Option and returns HTTP 400 with a message if None. +#[get("/api/throws-http-error-context")] +async fn throws_http_error_context() -> Result { + let res = None.or_bad_request("Value was None")?; + Ok(res) +} + +/// A simple server function that always succeeds. +#[get("/api/throws-ok")] +async fn get_throws_ok() -> Result<()> { + Ok(()) +} + +#[derive(thiserror::Error, Debug, Serialize, Deserialize)] +enum MyCustomError { + #[error("bad request")] + BadRequest { custom_name: String }, + + #[error("not found")] + NotFound, + + #[error("internal server error: {0}")] + ServerFnError(#[from] ServerFnError), +} + +/// Returns a custom typed error (serializable) so clients can handle specific cases. +#[get("/api/typed-error")] +async fn get_throws_typed_error() -> Result<(), MyCustomError> { + Err(MyCustomError::BadRequest { + custom_name: "Invalid input".into(), + }) +} diff --git a/examples/07-fullstack/header_map.rs b/examples/07-fullstack/header_map.rs new file mode 100644 index 000000000..cba415b7f --- /dev/null +++ b/examples/07-fullstack/header_map.rs @@ -0,0 +1,36 @@ +//! This example shows how you can extract a HeaderMap from requests to read custom headers. +//! +//! The extra arguments in the `#[get(...)]` macro are passed to the underlying axum handler, +//! and only visible on the server. This lets you run normal axum extractors like `HeaderMap`, +//! `TypedHeader`, `Query`, etc. +//! +//! Note that headers returned by the server are not always visible to the client due to CORS. +//! Headers like `Set-Cookie` are hidden by default, and need to be explicitly allowed +//! by the server using the `Access-Control-Expose-Headers` header (which dioxus-fullstack does not +//! currently expose directly). + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut headers = use_action(get_headers); + + rsx! { + h1 { "Header Map Example" } + button { onclick: move |_| headers.call(), "Get Headers" } + if let Some(Ok(headers)) = headers.result() { + p { "Response from server:" } + pre { "{headers}" } + } else { + p { "No headers yet" } + } + } +} + +#[get("/api/example", headers: dioxus::fullstack::HeaderMap)] +async fn get_headers() -> Result { + Ok(format!("{:#?}", headers)) +} diff --git a/examples/fullstack-hello-world/Cargo.toml b/examples/07-fullstack/hello-world/Cargo.toml similarity index 71% rename from examples/fullstack-hello-world/Cargo.toml rename to examples/07-fullstack/hello-world/Cargo.toml index e70222287..bd632a5c5 100644 --- a/examples/fullstack-hello-world/Cargo.toml +++ b/examples/07-fullstack/hello-world/Cargo.toml @@ -9,7 +9,10 @@ publish = false [dependencies] dioxus = { workspace = true, features = ["fullstack"]} serde = { workspace = true } -reqwest = { workspace = true } +reqwest = { workspace = true, features = ["json"] } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } [features] default = [] diff --git a/examples/07-fullstack/hello-world/assets/hello.css b/examples/07-fullstack/hello-world/assets/hello.css new file mode 100644 index 000000000..e00fc18a5 --- /dev/null +++ b/examples/07-fullstack/hello-world/assets/hello.css @@ -0,0 +1,3 @@ +h1 { + font-family: monospace; +} diff --git a/examples/07-fullstack/hello-world/src/main.rs b/examples/07-fullstack/hello-world/src/main.rs new file mode 100644 index 000000000..97eeb9182 --- /dev/null +++ b/examples/07-fullstack/hello-world/src/main.rs @@ -0,0 +1,59 @@ +//! A simple hello world example for Dioxus fullstack +//! +//! Run with: +//! +//! ```sh +//! dx serve --web +//! ``` +//! +//! This example demonstrates a simple Dioxus fullstack application with a client-side counter +//! and a server function that returns a greeting message. +//! +//! The `use_action` hook makes it easy to call async work (like server functions) from the client side +//! and handle loading and error states. + +use dioxus::prelude::*; +use dioxus_fullstack::get; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut count = use_signal(|| 0); + let mut message = use_action(get_greeting); + + rsx! { + div { style: "padding: 2rem; font-family: Arial, sans-serif;", + h1 { "Hello, Dioxus Fullstack!" } + + // Client-side counter - you can use any client functionality in your app! + div { style: "margin: 1rem 0;", + h2 { "Client Counter: {count}" } + button { onclick: move |_| count += 1, "Increment" } + button { onclick: move |_| count -= 1, "Decrement" } + } + + // We can handle the action result and display loading state + div { style: "margin: 1rem 0;", + h2 { "Server Greeting" } + button { onclick: move |_| message.call("World".to_string(), 30), "Get Server Greeting" } + if message.is_pending() { + p { "Loading..." } + } + p { "{message:#?}" } + } + } + } +} + +/// A simple server function that returns a greeting +/// +/// Our server function takes a name as a path and query parameters as inputs and returns a greeting message. +#[get("/api/greeting/{name}/{age}")] +async fn get_greeting(name: String, age: i32) -> Result { + Ok(format!( + "Hello from the server, {}! You are {} years old. 🚀", + name, age + )) +} diff --git a/examples/07-fullstack/login_form.rs b/examples/07-fullstack/login_form.rs new file mode 100644 index 000000000..d553d7c18 --- /dev/null +++ b/examples/07-fullstack/login_form.rs @@ -0,0 +1,104 @@ +//! This example demonstrates how to use types like `Form`, `SetHeader`, and `TypedHeader` +//! to create a simple login form that sets a cookie in the browser and uses it for authentication +//! on a protected endpoint. +//! +//! For more information on handling forms in general, see the multipart_form example. +//! +//! The intent with this example is to show how to use the building blocks like `Form` and `SetHeader` +//! to roll a simple authentication system. + +use dioxus::fullstack::{Form, SetCookie, SetHeader}; +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "server")] +use { + dioxus::fullstack::{Cookie, TypedHeader}, + std::sync::LazyLock, + uuid::Uuid, +}; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut fetch_login = use_action(login); + let mut fetch_sensitive = use_action(sensitive); + + rsx! { + h1 { "Login Form Demo" } + button { + onclick: move |_| async move { + fetch_sensitive.call(); + }, + "Get Sensitive Data", + } + pre { "Response from locked API: {fetch_sensitive.result():?}"} + form { + onsubmit: move |evt: FormEvent| async move { + // Prevent the browser from navigating away. + evt.prevent_default(); + + // Extract the form values into our `LoginForm` struct. The `.parsed_values` method + // is provided by Dioxus and works with any form element that has `name` attributes. + let values: LoginForm = evt.parsed_values().unwrap(); + + // Call our server function with the form values wrapped in `Form`. The `SetHeader` + // response will set a cookie in the browser if the login is successful. + fetch_login.call(Form(values)).await; + + // Now that we're logged in, we can call our sensitive endpoint. + fetch_sensitive.call().await; + }, + input { r#type: "text", id: "username", name: "username" } + label { "Username" } + input { r#type: "password", id: "password", name: "password" } + label { "Password" } + button { "Login" } + } + + } +} + +#[derive(Deserialize, Serialize)] +pub struct LoginForm { + username: String, + password: String, +} + +/// A static session ID for demonstration purposes. This forces all previous logins to be invalidated +/// when the server restarts. +#[cfg(feature = "server")] +static THIS_SESSION_ID: LazyLock = LazyLock::new(Uuid::new_v4); + +/// In our `login` form, we'll return a `SetCookie` header if the login is successful. +/// +/// This will set a cookie in the user's browser that can be used for subsequent authenticated requests. +/// The `SetHeader::new()` method takes anything that can be converted into a `HeaderValue`. +/// +/// We can set multiple headers by returning a tuple of `SetHeader` types, or passing in a tuple +/// of headers to `SetHeader::new()`. +#[post("/api/login")] +async fn login(form: Form) -> Result> { + // Verify the username and password. In a real application, you'd check these against a database. + if form.0.username == "admin" && form.0.password == "password" { + return Ok(SetHeader::new(format!("auth-demo={};", &*THIS_SESSION_ID))?); + } + + HttpError::unauthorized("Invalid username or password")? +} + +/// We'll use the `TypedHeader` extractor on the server to get the cookie from the request. +#[get("/api/sensitive", header: TypedHeader)] +async fn sensitive() -> Result { + // Extract the cookie from the request headers and use `.eq` to verify its value. + // The `or_unauthorized` works on boolean values, returning a 401 if the condition is false. + header + .get("auth-demo") + .or_unauthorized("Missing auth-demo cookie")? + .eq(THIS_SESSION_ID.to_string().as_str()) + .or_unauthorized("Invalid auth-demo cookie")?; + + Ok("Sensitive data".to_string()) +} diff --git a/examples/07-fullstack/multipart_form.rs b/examples/07-fullstack/multipart_form.rs new file mode 100644 index 000000000..a48c63b79 --- /dev/null +++ b/examples/07-fullstack/multipart_form.rs @@ -0,0 +1,64 @@ +//! This example showcases how to handle multipart form data uploads in Dioxus. +//! +//! Dioxus provides the `MultipartFormData` type to allow converting from the websys `FormData` +//! type directly into a streaming multipart form data handler. + +use dioxus::{fullstack::MultipartFormData, prelude::*}; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + // The `MultipartFormData` type can be used to handle multipart form data uploads. + // We can convert into it by using `.into()` on the `FormEvent`'s data, or by crafting + // a `MultipartFormData` instance manually. + let mut upload_as_multipart = use_action(move |event: FormEvent| upload(event.into())); + + rsx! { + Stylesheet { href: asset!("/examples/assets/file_upload.css") } + img { src: asset!("/examples/assets/logo.png"), width: "200px" } + div { + h3 { "Upload as Multipart" } + p { "Use the built-in multipart form handling" } + form { + display: "flex", + flex_direction: "column", + gap: "8px", + onsubmit: move |evt| async move { + evt.prevent_default(); + upload_as_multipart.call(evt).await; + }, + label { r#for: "headshot", "Photos" } + input { r#type: "file", name: "headshot", multiple: true, accept: ".png,.jpg,.jpeg" } + label { r#for: "resume", "Resume" } + input { r#type: "file", name: "resume", multiple: false, accept: ".pdf" } + label { r#for: "name", "Name" } + input { r#type: "text", name: "name", placeholder: "Name" } + label { r#for: "age", "Age" } + input { r#type: "number", name: "age", placeholder: "Age" } + input { r#type: "submit", name: "submit", value: "Submit your resume" } + } + } + } +} + +/// Upload a form as multipart form data. +/// +/// MultipartFormData is typed over the form data structure, allowing us to extract +/// both files and other form fields in a type-safe manner. +/// +/// On the server, we have access to axum's `Multipart` extractor +#[post("/api/upload-multipart")] +async fn upload(mut form: MultipartFormData) -> Result<()> { + while let Ok(Some(field)) = form.next_field().await { + info!( + "Field name: {:?}, filename: {:?}, content_type: {:?}", + field.name().to_owned(), + field.file_name(), + field.content_type(), + ); + } + + Ok(()) +} diff --git a/examples/fullstack-router/Cargo.toml b/examples/07-fullstack/router/Cargo.toml similarity index 100% rename from examples/fullstack-router/Cargo.toml rename to examples/07-fullstack/router/Cargo.toml diff --git a/examples/fullstack-router/src/main.rs b/examples/07-fullstack/router/src/main.rs similarity index 94% rename from examples/fullstack-router/src/main.rs rename to examples/07-fullstack/router/src/main.rs index be68dafc1..6ea092be2 100644 --- a/examples/fullstack-router/src/main.rs +++ b/examples/07-fullstack/router/src/main.rs @@ -9,7 +9,7 @@ use dioxus::prelude::*; fn main() { dioxus::LaunchBuilder::new() .with_cfg(server_only!(ServeConfig::builder().incremental( - dioxus::fullstack::IncrementalRendererConfig::default() + dioxus::server::IncrementalRendererConfig::default() .invalidate_after(std::time::Duration::from_secs(120)), ))) .launch(app); @@ -72,14 +72,14 @@ fn Home() -> Element { } } -#[server(PostServerData)] +#[post("/api/data")] async fn post_server_data(data: String) -> ServerFnResult { println!("Server received: {}", data); Ok(()) } -#[server(GetServerData)] +#[get("/api/data")] async fn get_server_data() -> ServerFnResult { Ok("Hello from the server!".to_string()) } diff --git a/examples/07-fullstack/server_sent_events.rs b/examples/07-fullstack/server_sent_events.rs new file mode 100644 index 000000000..f08226254 --- /dev/null +++ b/examples/07-fullstack/server_sent_events.rs @@ -0,0 +1,95 @@ +//! This example demonstrates server-sent events (SSE) using Dioxus Fullstack. +//! +//! Server-sent events allow the server to push updates to the client over a single HTTP connection. +//! This is useful for real-time updates, notifications, or any scenario where the server needs to +//! send data to the client without the client explicitly requesting it. +//! +//! SSE is a simpler alternative to WebSockets, not requiring a full-duplex, stateful connection with +//! the server. Instead, it uses a single long-lived HTTP connection to stream events from the server to the client. +//! +//! This means that SSE messages are stringly encoded, and thus binary data must be base64 encoded. +//! If you need to send binary data, consider using the `Streaming` type instead, which lets +//! you send raw bytes over a streaming HTTP response with a custom encoding. You'd reach for SSE +//! when dealing with clients that might not support custom streaming protocols. +//! +//! Calling an SSE endpoint is as simple as calling any other server function. The return type of an +//! SSE endpoint is a `ServerEvents` where `T` is the type of event you want to send to the client. +//! +//! On the client, the `ServerEvents` type implements `Stream>` +//! so you can use it with async streams to get new events as they arrive. +//! +//! `T` must be serializable and deserializable, so anything that implements `Serialize` and `Deserialize` +//! can be used as an event type. Calls to `.recv()` will wait for the next event to arrive and +//! deserialize it into the correct type. + +use dioxus::prelude::*; +use dioxus_fullstack::ServerEvents; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut events = use_signal(Vec::new); + + use_future(move || async move { + // Call the SSE endpoint to get a stream of events + let mut stream = listen_for_changes().await?; + + // And then poll it for new events, adding them to our signal + while let Some(Ok(event)) = stream.recv().await { + events.write().push(event); + } + + anyhow::Ok(()) + }); + + rsx! { + h1 { "Events from server: " } + for msg in events.read().iter().rev() { + pre { "{msg:?}" } + } + } +} + +/// We can send anything that's serializable as a server event - strings, numbers, structs, enums, etc. +#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)] +enum MyServerEvent { + Yay { message: String }, + Nay { error: String }, +} + +/// Our SSE endpoint, when called, will return the ServerEvents handle which streams events to the client. +/// On the client, we can interact with this stream object to get new events as they arrive. +#[get("/api/sse")] +async fn listen_for_changes() -> Result> { + use std::time::Duration; + + Ok(ServerEvents::new(|mut tx| async move { + let mut count = 1; + + loop { + // Create our serializable message + let msg = if count % 5 == 0 { + MyServerEvent::Nay { + error: "An error occurred".into(), + } + } else { + MyServerEvent::Yay { + message: format!("Hello number {count}"), + } + }; + + // Send the message to the client. If it errors, the client has disconnected + if tx.send(msg).await.is_err() { + // client disconnected, do some cleanup + break; + } + + count += 1; + + // Poll some data source here, subscribe to changes, maybe call an LLM? + tokio::time::sleep(Duration::from_secs(1)).await; + } + })) +} diff --git a/examples/07-fullstack/server_state.rs b/examples/07-fullstack/server_state.rs new file mode 100644 index 000000000..ba94a598e --- /dev/null +++ b/examples/07-fullstack/server_state.rs @@ -0,0 +1,119 @@ +//! This example shows how to use global state to maintain state between server functions. + +use dioxus::prelude::*; + +#[cfg(feature = "server")] +use { + dioxus::fullstack::Lazy, + dioxus::fullstack::axum, + futures::lock::Mutex, + sqlx::{Executor, Row}, + std::sync::LazyLock, +}; + +/* +Option 1: + +For simple, synchronous, thread-safe data, we can use statics with atomic types or mutexes. +The `LazyLock` type from the standard library is a great choice for simple, synchronous data +*/ +static MESSAGES: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); + +#[post("/api/messages")] +async fn add_message() -> Result<()> { + MESSAGES.lock().await.push("New message".to_string()); + Ok(()) +} + +#[get("/api/messages")] +async fn read_messages() -> Result> { + Ok(MESSAGES.lock().await.clone()) +} + +/* +Option 2: + +For complex async data, we can use the `Lazy` type from Dioxus Fullstack. The `Lazy` type provides +an interface like `once_cell::Lazy` but supports async initialization. When reading the value from +a `Lazy`, the value will be initialized synchronously, blocking the current task until the value is ready. + +Alternatively, you can create a `Lazy` with `Lazy::lazy` and then initialize it later with +`Lazy::initialize`. +*/ +#[cfg(feature = "server")] +static DATABASE: Lazy = Lazy::new(|| async move { + use sqlx::sqlite::SqlitePoolOptions; + dioxus::Ok( + SqlitePoolOptions::new() + .max_connections(5) + .connect_with("sqlite::memory:".parse().unwrap()) + .await?, + ) +}); + +/// When using the `Lazy` type, it implements `Deref`, so you can use it like a normal reference. +#[get("/api/users")] +async fn get_users() -> Result> { + let users = DATABASE + .fetch_all(sqlx::query("SELECT name FROM users")) + .await? + .iter() + .map(|row| row.get::("name")) + .collect::>(); + + Ok(users) +} + +/* +Option 3: + +For data that needs to be provided per-request, we can use axum's `Extension` type to provide +data to our app. This is useful for things like request-scoped data or data that needs to be +initialized per-requestz +*/ +#[cfg(feature = "server")] +type BroadcastExtension = axum::Extension>; + +#[post("/api/broadcast", ext: BroadcastExtension)] +async fn broadcast_message() -> Result<()> { + ext.send("New broadcast message".to_string())?; + Ok(()) +} + +fn main() { + #[cfg(not(feature = "server"))] + dioxus::launch(app); + + // When using `Lazy` items, or axum `Extension`s, we need to initialize them in `dioxus::serve` + // before launching our app. + #[cfg(feature = "server")] + dioxus::serve(|| async move { + use dioxus::server::axum::Extension; + + // For axum `Extension`s, we can use the `layer` method to add them to our router. + let router = dioxus::server::router(app) + .layer(Extension(tokio::sync::broadcast::channel::(16).0)); + + Ok(router) + }); +} + +fn app() -> Element { + let mut users = use_action(get_users); + let mut messages = use_action(read_messages); + let mut broadcast = use_action(broadcast_message); + let mut add = use_action(add_message); + + rsx! { + div { + button { onclick: move |_| users.call(), "Get Users" } + pre { "{users.result():?}" } + button { onclick: move |_| messages.call(), "Get Messages" } + pre { "{messages.result():?}" } + button { onclick: move |_| broadcast.call(), "Broadcast Message" } + pre { "{broadcast.result():?}" } + button { onclick: move |_| add.call(), "Add Message" } + pre { "{add.result():?}" } + } + } +} diff --git a/examples/07-fullstack/ssr-only/Cargo.toml b/examples/07-fullstack/ssr-only/Cargo.toml new file mode 100644 index 000000000..e6c60de66 --- /dev/null +++ b/examples/07-fullstack/ssr-only/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ssr-only" +edition = "2024" +version.workspace = true +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack", "router"] } + +[features] +default = ["server"] +server = ["dioxus/server"] diff --git a/examples/07-fullstack/ssr-only/src/main.rs b/examples/07-fullstack/ssr-only/src/main.rs new file mode 100644 index 000000000..b04100612 --- /dev/null +++ b/examples/07-fullstack/ssr-only/src/main.rs @@ -0,0 +1,59 @@ +//! This example showcases how to use Fullstack in a server-side rendering only context. +//! +//! This means we have no client-side bundle at all, and *everything* is rendered on the server. +//! You can still use signals, resources, etc, but they won't be reactive on the client. +//! +//! This is useful for static site generation, or if you want to use Dioxus Fullstack as a server-side +//! framework without the `rsx! {}` markup. +//! +//! To run this example, simply run `cargo run --package ssr-only` and navigate to `http://localhost:8080`. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(|| rsx! { Router:: { } }); +} + +#[derive(Routable, Clone, Debug, PartialEq)] +enum Route { + #[route("/")] + Home, + + #[route("/post/:id")] + Post { id: u32 }, +} + +#[component] +fn Home() -> Element { + rsx! { + h1 { "home" } + ul { + li { a { href: "/post/1", "Post 1" } } + li { a { href: "/post/2", "Post 2" } } + li { a { href: "/post/3", "Post 3 (404)" } } + } + } +} + +#[component] +fn Post(id: u32) -> Element { + // You can return `HttpError` to return a specific HTTP status code and message. + // `404 Not Found` will cause the server to return a 404 status code. + // + // `use_loader` will suspend the server-side rendering until the future resolves. + let post_data = use_loader(move || get_post(id))?; + + rsx! { + h1 { "Post {id}" } + p { "{post_data}" } + } +} + +#[get("/api/post/{id}")] +async fn get_post(id: u32) -> Result { + match id { + 1 => Ok("first post".to_string()), + 2 => Ok("second post".to_string()), + _ => HttpError::not_found("Post not found")?, + } +} diff --git a/examples/07-fullstack/streaming.rs b/examples/07-fullstack/streaming.rs new file mode 100644 index 000000000..f8d4deaa9 --- /dev/null +++ b/examples/07-fullstack/streaming.rs @@ -0,0 +1,147 @@ +//! This example shows how to use the `Streaming` type to send streaming responses from the +//! server to the client (and the client to the server!). +//! +//! The `Streaming` type automatically coordinates sending and receiving streaming data over HTTP. +//! The `T` type parameter is the type of data being sent, and the `E` type parameter is the encoding +//! used to serialize and deserialize the data. +//! +//! Dioxus Fullstack provides several built-in encodings: +//! - JsonEncoding: the default, uses JSON for serialization +//! - CborEncoding: uses CBOR for binary serialization +//! - PostcardEncoding: uses Postcard for binary serialization +//! - MsgPackEncoding: uses MessagePack for binary serialization +//! - RkyvEncoding: uses Rkyv for zero-copy binary serialization +//! +//! The default encoding is `JsonEncoding`, which works well for most use cases and can be used by +//! most clients. If you need a more efficient binary encoding, consider using one of the +//! binary encodings. + +use bytes::Bytes; +use dioxus::{ + fullstack::{JsonEncoding, Streaming, TextStream}, + prelude::*, +}; + +fn main() { + dioxus::launch(app) +} + +fn app() -> Element { + let mut text_responses = use_signal(String::new); + let mut json_responses = use_signal(Vec::new); + + let mut start_text_stream = use_action(move || async move { + text_responses.write().clear(); + let mut stream = text_stream(Some(100)).await?; + + while let Some(Ok(text)) = stream.next().await { + text_responses.write().push_str(&text); + text_responses.write().push('\n'); + } + + dioxus::Ok(()) + }); + + let mut start_json_stream = use_action(move || async move { + json_responses.write().clear(); + let mut stream = json_stream().await?; + + while let Some(Ok(dog)) = stream.next().await { + json_responses.write().push(dog); + } + + dioxus::Ok(()) + }); + + rsx! { + div { + button { onclick: move |_| start_text_stream.call(), "Start text stream" } + button { onclick: move |_| start_text_stream.cancel(), "Stop text stream" } + pre { "{text_responses}" } + } + div { + button { onclick: move |_| start_json_stream.call(), "Start JSON stream" } + button { onclick: move |_| start_json_stream.cancel(), "Stop JSON stream" } + for dog in json_responses.read().iter() { + pre { "{dog:?}" } + } + } + } +} + +/// The `TextStream` type is an alias for `Streaming` with a text/plain encoding. +/// +/// The `TextStream::new()` method takes anything that implements `Stream`, so +/// we can use a channel to send strings from a background task. +#[get("/api/test_stream?start")] +async fn text_stream(start: Option) -> Result { + let (tx, rx) = futures::channel::mpsc::unbounded(); + + tokio::spawn(async move { + let mut count = start.unwrap_or(0); + loop { + let message = format!("Hello, world! {}", count); + if tx.unbounded_send(message).is_err() { + break; + } + + count += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + }); + + Ok(Streaming::new(rx)) +} + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +struct Dog { + name: String, + age: u8, +} + +/// A custom `Streaming` endpoint that streams JSON-encoded `Dog` structs to the client. +/// +/// Dioxus provides the `JsonEncoding` type which can be used to encode and decode JSON data. +#[get("/api/json_stream")] +async fn json_stream() -> Result> { + let (tx, rx) = futures::channel::mpsc::unbounded(); + + tokio::spawn(async move { + let mut count = 0; + loop { + let dog = Dog { + name: format!("Dog {}", count), + age: (count % 10) as u8, + }; + if tx.unbounded_send(dog).is_err() { + // If the channel is closed, stop sending chunks + break; + } + count += 1; + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + }); + + Ok(Streaming::new(rx)) +} + +/// An example of streaming raw bytes to the client using `Streaming`. +/// This is useful for sending binary data, such as images, files, or zero-copy data. +#[get("/api/byte_stream")] +async fn byte_stream() -> Result> { + let (tx, rx) = futures::channel::mpsc::unbounded(); + + tokio::spawn(async move { + let mut count = 0; + loop { + let bytes = vec![count; 10]; + if tx.unbounded_send(bytes.into()).is_err() { + break; + } + count = (count + 1) % 255; + tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; + } + }); + + Ok(Streaming::new(rx)) +} diff --git a/examples/07-fullstack/streaming_file_upload.rs b/examples/07-fullstack/streaming_file_upload.rs new file mode 100644 index 000000000..1aaadeff0 --- /dev/null +++ b/examples/07-fullstack/streaming_file_upload.rs @@ -0,0 +1,205 @@ +//! This example showcases how to upload files from the client to the server. +//! +//! We can use the `FileStream` type to handle file uploads in a streaming fashion. +//! This allows us to handle large files without loading them entirely into memory. +//! +//! `FileStream` and `FileDownload` are built on multi-part form data and streams, which we +//! also showcase here. + +use dioxus::{ + fullstack::{ByteStream, FileStream}, + prelude::*, +}; +use dioxus_html::{FileData, HasFileData}; +use futures::StreamExt; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + // Dioxus provides the `FileStream` type for efficiently uploading files in a streaming fashion. + // This approach automatically automatically sets relevant metadata such as headers like + // Content-Type, Content-Length, and Content-Disposition. + // + // The `FileStream` type can be created from a `FileData` instance using `.into()`. + // This approach is better suited for public-facing APIs where standard headers are expected. + // + // `FileStream` uses the platform's native file streaming capabilities when available, + // making it more efficient than manually streaming bytes. + let mut upload_as_file_upload = use_action(move |files: Vec| async move { + for file in files { + upload_file_as_filestream(file.into()).await?; + } + dioxus::Ok(()) + }); + + // We can upload files by directly using the `ByteStream` type. With this approach, we need to + // specify the file name and size as query parameters since its an opaque stream. + // + // The `FileData` type has a `byte_stream` method which returns a `Pin + Send>>` + // that we can turn into a `ByteStream` with `.into()`. + // + // In WASM, this will buffer the entire file in memory, so it's not the most efficient way to upload files. + // This approach is best suited for data created by the user in the browser. + let mut upload_files_as_bytestream = use_action(move |files: Vec| async move { + info!("Uploading {} files", files.len()); + for file in files { + upload_as_bytestream(file.name(), file.size(), file.byte_stream().into()).await?; + } + dioxus::Ok(()) + }); + + let mut download_file = use_action(move || async move { + let mut file = download_as_filestream().await?; + let mut bytes = vec![]; + + info!("Downloaded file: {:?}", file); + + while let Some(Ok(chunk)) = file.next().await { + bytes.extend_from_slice(&chunk); + } + + dioxus::Ok(String::from_utf8_lossy(&bytes).to_string()) + }); + + rsx! { + Stylesheet { href: asset!("/examples/assets/file_upload.css") } + div { + max_width: "600px", + margin: "auto", + h1 { "File upload example" } + div { + h3 { "Upload as FileUpload" } + div { + class: "drop-zone", + ondragover: move |evt| evt.prevent_default(), + ondrop: move |evt| async move { + evt.prevent_default(); + upload_as_file_upload.call(evt.files()).await; + }, + "Drop files here" + } + pre { "{upload_as_file_upload.result():?}" } + } + + div { + h3 { "Upload as ByteStream" } + div { + class: "drop-zone", + ondragover: move |evt| evt.prevent_default(), + ondrop: move |evt| async move { + evt.prevent_default(); + upload_files_as_bytestream.call(evt.files()).await; + }, + "Drop files here" + } + } + + div { + h3 { "Download a file from the server" } + button { onclick: move |_| download_file.call(), "Download file" } + if let Some(Ok(content)) = &download_file.result() { + pre { "{content}" } + } else if let Some(Err(e)) = &download_file.result() { + pre { "Error downloading file: {e}" } + } + } + } + } +} + +/// Upload a file using the `FileStream` type which automatically sets relevant metadata +/// as headers like Content-Type, Content-Length, and Content-Disposition. +#[post("/api/upload_as_file_stream")] +async fn upload_file_as_filestream(mut upload: FileStream) -> Result { + use futures::StreamExt; + use std::env::temp_dir; + use tokio::io::AsyncWriteExt; + + info!("Received file upload: {:?}", upload); + + // Create a temporary file to write the uploaded data to. + let upload_file = std::path::absolute(temp_dir().join(upload.file_name()))?; + + // Reject paths that are outside the temp directory for security reasons. + if !upload_file.starts_with(temp_dir()) { + HttpError::bad_request("Invalid file path")?; + } + + info!( + "Uploading bytes of {:?} file to {:?}", + upload.size(), + upload_file + ); + + // Open the file for writing. + tokio::fs::create_dir_all(upload_file.parent().unwrap()).await?; + let mut file = tokio::fs::File::create(&upload_file).await?; + let expected = upload.size(); + + // Stream the data from the request body to the file. + let mut uploaded: u64 = 0; + let mut errored = false; + while let Some(chunk) = upload.next().await { + match chunk { + Ok(bytes) => { + uploaded += bytes.len() as u64; + if file.write_all(&bytes).await.is_err() { + errored = true; + break; + } + + // 1GB max file size or attempting to upload more than expected. + if uploaded > expected.unwrap_or(1024 * 1024 * 1024) { + errored = true; + break; + } + } + Err(_) => { + errored = true; + break; + } + } + } + + // Clean up the file if there was an error during upload. + if errored { + _ = file.sync_data().await; + let _ = tokio::fs::remove_file(&upload_file).await; + HttpError::internal_server_error("Failed to upload file")?; + } + + Ok(uploaded as u32) +} + +/// Upload a file as a raw byte stream. This requires us to specify the file name and size +/// as query parameters since the `ByteStream` type is an opaque stream without metadata. +/// +/// We could also use custom headers to pass metadata if we wanted to avoid query parameters. +#[post("/api/upload_as_bytestream?name&size")] +async fn upload_as_bytestream(name: String, size: u64, mut stream: ByteStream) -> Result<()> { + let mut collected = 0; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + collected += chunk.len() as u64; + + info!("Received {} bytes for file {}", chunk.len(), name); + + if collected > size { + HttpError::bad_request("Received more data than expected")?; + } + } + + Ok(()) +} + +/// Download a file from the server as a `FileStream`. This automatically sets relevant +/// headers like Content-Type, Content-Length, and Content-Disposition. +/// +/// This endpoint is nice because 3rd-party clients can visit it directly and download the file! +/// Try visiting this endpoint directly in your browser. +#[get("/api/download_as_filestream")] +async fn download_as_filestream() -> Result { + Ok(FileStream::from_path(file!()).await?) +} diff --git a/examples/07-fullstack/through_reqwest.rs b/examples/07-fullstack/through_reqwest.rs new file mode 100644 index 000000000..f80405d97 --- /dev/null +++ b/examples/07-fullstack/through_reqwest.rs @@ -0,0 +1,47 @@ +//! This example demonstrates that dioxus server functions can be be called directly as a Rust +//! function or via an HTTP request using reqwest. +//! +//! Dioxus server functions generated a REST endpoint that can be called using any HTTP client. +//! By default, they also support different serialization formats like JSON and CBOR. Try changing +//! your `accept` header to see the different formats. + +use dioxus::prelude::*; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + let mut user_from_server_fn = use_action(get_user); + + let mut user_from_reqwest = use_action(move |id: i32| async move { + let port = dioxus::cli_config::server_port().unwrap_or(8080); + reqwest::get(&format!("http://localhost:{}/api/user/{}", port, id)) + .await? + .json::() + .await + }); + + rsx! { + button { onclick: move |_| user_from_server_fn.call(123), "Fetch Data" } + button { onclick: move |_| user_from_reqwest.call(456), "Fetch From Endpoint" } + div { display: "flex", flex_direction: "column", + pre { "User from server: {user_from_server_fn.value():?}", } + pre { "User from server: {user_from_reqwest.value():?}", } + } + } +} + +#[derive(serde::Serialize, serde::Deserialize, Debug)] +struct User { + id: String, + name: String, +} + +#[get("/api/user/{id}")] +async fn get_user(id: i32) -> Result { + Ok(User { + id: id.to_string(), + name: "John Doe".into(), + }) +} diff --git a/examples/07-fullstack/websocket.rs b/examples/07-fullstack/websocket.rs new file mode 100644 index 000000000..0268e103a --- /dev/null +++ b/examples/07-fullstack/websocket.rs @@ -0,0 +1,90 @@ +//! This example showcases the built-in websocket functionality in Dioxus Fullstack. +//! +//! We can create a new websocket endpoint that takes the WebSocketOptions as a body and returns +//! a `Websocket` instance that the client uses to communicate with the server. +//! +//! The `Websocket` type is generic over the message types and the encoding used to serialize the messages. +//! +//! By default, we use `JsonEncoding`, but in this example, we use `CborEncoding` to demonstrate that +//! binary encodings also work. +//! +//! The `use_websocket` hook wraps the `Websocket` instance and provides a reactive interface to the +//! state of the connection, as well as methods to send and receive messages. +//! +//! Because the websocket is generic over the message types, calls to `.recv()` and `.send()` are +//! strongly typed, making it easy to send and receive messages without having to manually +//! serialize and deserialize them. + +use dioxus::{fullstack::CborEncoding, prelude::*}; +use dioxus_fullstack::{WebSocketOptions, Websocket, use_websocket}; +use serde::{Deserialize, Serialize}; + +fn main() { + dioxus::launch(app); +} + +fn app() -> Element { + // Track the messages we've received from the server. + let mut messages = use_signal(std::vec::Vec::new); + + // The `use_websocket` wraps the `WebSocket` connection and provides a reactive handle to easily + // send and receive messages and track the connection state. + // + // We can customize the websocket connection with the `WebSocketOptions` struct, allowing us to + // set things like custom headers, protocols, reconnection strategies, etc. + let mut socket = use_websocket(|| uppercase_ws("John Doe".into(), 30, WebSocketOptions::new())); + + // Calling `.recv()` automatically waits for the connection to be established and deserializes + // messages as they arrive. + use_future(move || async move { + while let Ok(msg) = socket.recv().await { + messages.push(msg); + } + }); + + rsx! { + h1 { "WebSocket Example" } + p { "Type a message and see it echoed back in uppercase!" } + p { "Connection status: {socket.status():?}" } + input { + placeholder: "Type a message", + oninput: move |e| async move { _ = socket.send(ClientEvent::TextInput(e.value())).await; }, + } + button { onclick: move |_| messages.clear(), "Clear messages" } + for message in messages.read().iter().rev() { + pre { "{message:?}" } + } + } +} + +#[derive(Serialize, Deserialize, Debug)] +enum ClientEvent { + TextInput(String), +} + +#[derive(Serialize, Deserialize, Debug)] +enum ServerEvent { + Uppercase(String), +} + +#[get("/api/uppercase_ws?name&age")] +async fn uppercase_ws( + name: String, + age: i32, + options: WebSocketOptions, +) -> Result> { + Ok(options.on_upgrade(move |mut socket| async move { + // send back a greeting message + _ = socket + .send(ServerEvent::Uppercase(format!( + "Fist message from server: Hello, {}! You are {} years old.", + name, age + ))) + .await; + + // Loop and echo back uppercase messages + while let Ok(ClientEvent::TextInput(next)) = socket.recv().await { + _ = socket.send(ServerEvent::Uppercase(next)).await; + } + })) +} diff --git a/examples/control_focus.rs b/examples/08-apis/control_focus.rs similarity index 96% rename from examples/control_focus.rs rename to examples/08-apis/control_focus.rs index 0834f1f5f..156b10dd8 100644 --- a/examples/control_focus.rs +++ b/examples/08-apis/control_focus.rs @@ -40,7 +40,7 @@ fn app() -> Element { }); rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } h1 { "Input Roulette" } button { onclick: move |_| running.toggle(), "Toggle roulette" } div { id: "roulette-grid", diff --git a/examples/custom_html.rs b/examples/08-apis/custom_html.rs similarity index 100% rename from examples/custom_html.rs rename to examples/08-apis/custom_html.rs diff --git a/examples/custom_menu.rs b/examples/08-apis/custom_menu.rs similarity index 100% rename from examples/custom_menu.rs rename to examples/08-apis/custom_menu.rs diff --git a/examples/eval.rs b/examples/08-apis/eval.rs similarity index 100% rename from examples/eval.rs rename to examples/08-apis/eval.rs diff --git a/examples/file_upload.rs b/examples/08-apis/file_upload.rs similarity index 72% rename from examples/file_upload.rs rename to examples/08-apis/file_upload.rs index 3fbe9f135..18b5d9e36 100644 --- a/examples/file_upload.rs +++ b/examples/08-apis/file_upload.rs @@ -3,10 +3,9 @@ //! Dioxus intercepts these events and provides a Rusty interface to the file data. Since we want this interface to //! be crossplatform, -use std::sync::Arc; - +use dioxus::html::HasFileData; use dioxus::prelude::*; -use dioxus::{html::HasFileData, prelude::dioxus_elements::FileEngine}; +use dioxus_html::FileData; const STYLE: Asset = asset!("/examples/assets/file_upload.css"); @@ -24,26 +23,25 @@ fn app() -> Element { let mut files_uploaded = use_signal(|| Vec::new() as Vec); let mut hovered = use_signal(|| false); - let read_files = move |file_engine: Arc| async move { - let files = file_engine.files(); - for file_name in &files { - if let Some(contents) = file_engine.read_file_to_string(file_name).await { + let upload_files = move |files: Vec| async move { + for file in files { + let filename = file.name(); + if let Ok(contents) = file.read_string().await { files_uploaded.write().push(UploadedFile { - name: file_name.clone(), + name: filename, contents, }); + } else { + files_uploaded.write().push(UploadedFile { + name: filename, + contents: "Failed to read file".into(), + }); } } }; - let upload_files = move |evt: FormEvent| async move { - if let Some(file_engine) = evt.files() { - read_files(file_engine).await; - } - }; - rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } h1 { "File Upload Example" } p { "Drop a .txt, .rs, or .js file here to read it" } @@ -67,7 +65,9 @@ fn app() -> Element { multiple: true, name: "textreader", directory: enable_directory_upload, - onchange: upload_files, + onchange: move |evt| async move { + upload_files(evt.files()).await + }, } } @@ -79,14 +79,10 @@ fn app() -> Element { hovered.set(true) }, ondragleave: move |_| hovered.set(false), - ondrop: move |evt| { + ondrop: move |evt| async move { evt.prevent_default(); hovered.set(false); - if let Some(file_engine) = evt.files() { - spawn(async move { - read_files(file_engine).await; - }); - } + upload_files(evt.files()).await; }, "Drop files here" } diff --git a/examples/form.rs b/examples/08-apis/form.rs similarity index 100% rename from examples/form.rs rename to examples/08-apis/form.rs diff --git a/examples/logging.rs b/examples/08-apis/logging.rs similarity index 96% rename from examples/logging.rs rename to examples/08-apis/logging.rs index 1f75d4780..ba3d1f1d4 100644 --- a/examples/logging.rs +++ b/examples/08-apis/logging.rs @@ -11,7 +11,7 @@ //! //! To use the dioxus logger in your app, simply call any of the tracing functions (info!(), warn!(), error!()) -use dioxus::logger::tracing::{debug, error, info, warn, Level}; +use dioxus::logger::tracing::{Level, debug, error, info, warn}; use dioxus::prelude::*; fn main() { diff --git a/examples/multiwindow.rs b/examples/08-apis/multiwindow.rs similarity index 100% rename from examples/multiwindow.rs rename to examples/08-apis/multiwindow.rs diff --git a/examples/multiwindow_with_tray_icon.rs b/examples/08-apis/multiwindow_with_tray_icon.rs similarity index 97% rename from examples/multiwindow_with_tray_icon.rs rename to examples/08-apis/multiwindow_with_tray_icon.rs index 4215c8da1..ce31e9459 100644 --- a/examples/multiwindow_with_tray_icon.rs +++ b/examples/08-apis/multiwindow_with_tray_icon.rs @@ -7,8 +7,9 @@ //! This is useful for apps that incorporate settings panels or persistent windows like Raycast. use dioxus::desktop::{ + WindowCloseBehaviour, trayicon::{default_tray_icon, init_tray_icon}, - window, WindowCloseBehaviour, + window, }; use dioxus::prelude::*; diff --git a/examples/resize.rs b/examples/08-apis/on_resize.rs similarity index 85% rename from examples/resize.rs rename to examples/08-apis/on_resize.rs index 97bdfb52a..f12a500ad 100644 --- a/examples/resize.rs +++ b/examples/08-apis/on_resize.rs @@ -15,10 +15,7 @@ fn app() -> Element { let mut dimensions = use_signal(Size2D::zero); rsx!( - document::Link { - rel: "stylesheet", - href: asset!("/examples/assets/read_size.css"), - } + Stylesheet { href: asset!("/examples/assets/read_size.css") } div { width: "50%", height: "50%", diff --git a/examples/visible.rs b/examples/08-apis/on_visible.rs similarity index 97% rename from examples/visible.rs rename to examples/08-apis/on_visible.rs index 9ed46b732..a0ceeef61 100644 --- a/examples/visible.rs +++ b/examples/08-apis/on_visible.rs @@ -10,8 +10,7 @@ fn app() -> Element { let mut animated_classes = use_signal(|| ["animated-text", ""]); rsx! { - document::Link { rel: "stylesheet", href: asset!("/examples/assets/visible.css") } - + Stylesheet { href: asset!("/examples/assets/visible.css") } div { class: "container", diff --git a/examples/overlay.rs b/examples/08-apis/overlay.rs similarity index 88% rename from examples/overlay.rs rename to examples/08-apis/overlay.rs index 405765ce8..24573fea8 100644 --- a/examples/overlay.rs +++ b/examples/08-apis/overlay.rs @@ -6,7 +6,7 @@ //! We also add a global shortcut to toggle the overlay on and off, so you could build a raycast-type app with this. use dioxus::desktop::{ - tao::dpi::PhysicalPosition, use_global_shortcut, HotKeyState, LogicalSize, WindowBuilder, + HotKeyState, LogicalSize, WindowBuilder, tao::dpi::PhysicalPosition, use_global_shortcut, }; use dioxus::prelude::*; @@ -26,10 +26,7 @@ fn app() -> Element { }); rsx! { - document::Link { - rel: "stylesheet", - href: asset!("/examples/assets/overlay.css"), - } + Stylesheet { href: asset!("/examples/assets/overlay.css") } if show_overlay() { div { width: "100%", diff --git a/examples/read_size.rs b/examples/08-apis/read_size.rs similarity index 90% rename from examples/read_size.rs rename to examples/08-apis/read_size.rs index 4a3f0ad4b..fe6cc2059 100644 --- a/examples/read_size.rs +++ b/examples/08-apis/read_size.rs @@ -28,10 +28,7 @@ fn app() -> Element { }; rsx! { - document::Link { - rel: "stylesheet", - href: asset!("/examples/assets/read_size.css"), - } + Stylesheet { href: asset!("/examples/assets/read_size.css") } div { width: "50%", height: "50%", diff --git a/examples/scroll_to_offset.rs b/examples/08-apis/scroll_to_offset.rs similarity index 100% rename from examples/scroll_to_offset.rs rename to examples/08-apis/scroll_to_offset.rs diff --git a/examples/scroll_to_top.rs b/examples/08-apis/scroll_to_top.rs similarity index 100% rename from examples/scroll_to_top.rs rename to examples/08-apis/scroll_to_top.rs diff --git a/examples/shortcut.rs b/examples/08-apis/shortcut.rs similarity index 92% rename from examples/shortcut.rs rename to examples/08-apis/shortcut.rs index eed58533f..6aa780144 100644 --- a/examples/shortcut.rs +++ b/examples/08-apis/shortcut.rs @@ -5,7 +5,7 @@ //! //! These are *global* shortcuts, so they will work even if your app is not in focus. -use dioxus::desktop::{use_global_shortcut, HotKeyState}; +use dioxus::desktop::{HotKeyState, use_global_shortcut}; use dioxus::prelude::*; fn main() { diff --git a/examples/ssr.rs b/examples/08-apis/ssr.rs similarity index 100% rename from examples/ssr.rs rename to examples/08-apis/ssr.rs diff --git a/examples/title.rs b/examples/08-apis/title.rs similarity index 82% rename from examples/title.rs rename to examples/08-apis/title.rs index f836a7f3d..5e5a6b8c2 100644 --- a/examples/title.rs +++ b/examples/08-apis/title.rs @@ -12,8 +12,9 @@ fn app() -> Element { rsx! { div { // You can set the title of the page with the Title component - // In web applications, this sets the title in the head. On desktop, it sets the window title - document::Title { "My Application (Count {count})" } + // In web applications, this sets the title in the head. + // On desktop, it sets the window title + Title { "My Application (Count {count})" } button { onclick: move |_| count += 1, "Up high!" } button { onclick: move |_| count -= 1, "Down low!" } } diff --git a/examples/video_stream.rs b/examples/08-apis/video_stream.rs similarity index 98% rename from examples/video_stream.rs rename to examples/08-apis/video_stream.rs index e91c586bb..5ed068fd3 100644 --- a/examples/video_stream.rs +++ b/examples/08-apis/video_stream.rs @@ -4,7 +4,7 @@ use dioxus::desktop::wry::http; use dioxus::desktop::wry::http::Response; -use dioxus::desktop::{use_asset_handler, AssetRequest}; +use dioxus::desktop::{AssetRequest, use_asset_handler}; use dioxus::prelude::*; use http::{header::*, response::Builder as ResponseBuilder, status::StatusCode}; use std::{io::SeekFrom, path::PathBuf}; @@ -16,7 +16,7 @@ fn main() { // For the sake of this example, we will download the video file if it doesn't exist ensure_video_is_loaded(); - dioxus::LaunchBuilder::desktop().launch(app); + dioxus::launch(app); } fn app() -> Element { diff --git a/examples/wgpu_child_window.rs b/examples/08-apis/wgpu_child_window.rs similarity index 99% rename from examples/wgpu_child_window.rs rename to examples/08-apis/wgpu_child_window.rs index 235cd1b11..55907e44f 100644 --- a/examples/wgpu_child_window.rs +++ b/examples/08-apis/wgpu_child_window.rs @@ -6,8 +6,9 @@ //! To use this feature set `with_as_child_window()` on your desktop config which will then let you use dioxus::desktop::{ + Config, DesktopContext, tao::{event::Event as WryEvent, window::WindowBuilder}, - use_wry_event_handler, window, Config, DesktopContext, + use_wry_event_handler, window, }; use dioxus::prelude::*; diff --git a/examples/window_event.rs b/examples/08-apis/window_event.rs similarity index 98% rename from examples/window_event.rs rename to examples/08-apis/window_event.rs index c5d5dd3e2..99e05271d 100644 --- a/examples/window_event.rs +++ b/examples/08-apis/window_event.rs @@ -9,7 +9,7 @@ //! //! The entire featuresuite of wry and tao is available to you -use dioxus::desktop::{window, Config, WindowBuilder}; +use dioxus::desktop::{Config, WindowBuilder, window}; use dioxus::prelude::*; fn main() { diff --git a/examples/window_focus.rs b/examples/08-apis/window_focus.rs similarity index 100% rename from examples/window_focus.rs rename to examples/08-apis/window_focus.rs diff --git a/examples/popup.rs b/examples/08-apis/window_popup.rs similarity index 100% rename from examples/popup.rs rename to examples/08-apis/window_popup.rs diff --git a/examples/window_zoom.rs b/examples/08-apis/window_zoom.rs similarity index 100% rename from examples/window_zoom.rs rename to examples/08-apis/window_zoom.rs diff --git a/examples/all_events.rs b/examples/09-reference/all_events.rs similarity index 97% rename from examples/all_events.rs rename to examples/09-reference/all_events.rs index 63ddaa82e..814a04884 100644 --- a/examples/all_events.rs +++ b/examples/09-reference/all_events.rs @@ -28,7 +28,7 @@ fn app() -> Element { let random_text = "This is some random repeating text. ".repeat(1000); rsx! { - document::Link { rel: "stylesheet", href: STYLE } + Stylesheet { href: STYLE } div { id: "container", // focusing is necessary to catch keyboard events div { id: "receiver", tabindex: 0, diff --git a/examples/generic_component.rs b/examples/09-reference/generic_component.rs similarity index 100% rename from examples/generic_component.rs rename to examples/09-reference/generic_component.rs diff --git a/examples/optional_props.rs b/examples/09-reference/optional_props.rs similarity index 100% rename from examples/optional_props.rs rename to examples/09-reference/optional_props.rs diff --git a/examples/rsx_usage.rs b/examples/09-reference/rsx_usage.rs similarity index 97% rename from examples/rsx_usage.rs rename to examples/09-reference/rsx_usage.rs index 12dc7cbfc..90c973edc 100644 --- a/examples/rsx_usage.rs +++ b/examples/09-reference/rsx_usage.rs @@ -110,10 +110,7 @@ fn app() -> Element { class: if formatting.contains("form") { "{asd}" } else if formatting.contains("my other form") { "{asd}" }, class: if formatting.contains("form") { "{asd}" } else if formatting.contains("my other form") { "{asd}" } else { "{asd}" }, div { - class: { - const WORD: &str = "expressions"; - format_args!("Arguments can be passed in through curly braces for complex {WORD}") - } + class: format_args!("Arguments can be passed in through curly braces for complex {asd}") } } @@ -245,7 +242,8 @@ fn app() -> Element { {String::from("Hello world!")} // So can format_args - {format_args!("Hello {}!", "world")} + // todo(jon): this is broken in edition 2024 + // {format_args!("Hello {}!", "world")} // Or we can shell out to a helper function {format_dollars(10, 50)} diff --git a/examples/shorthand.rs b/examples/09-reference/shorthand.rs similarity index 100% rename from examples/shorthand.rs rename to examples/09-reference/shorthand.rs diff --git a/examples/simple_list.rs b/examples/09-reference/simple_list.rs similarity index 100% rename from examples/simple_list.rs rename to examples/09-reference/simple_list.rs diff --git a/examples/spread.rs b/examples/09-reference/spread.rs similarity index 100% rename from examples/spread.rs rename to examples/09-reference/spread.rs diff --git a/examples/web_component.rs b/examples/09-reference/web_component.rs similarity index 100% rename from examples/web_component.rs rename to examples/09-reference/web_component.rs diff --git a/examples/xss_safety.rs b/examples/09-reference/xss_safety.rs similarity index 100% rename from examples/xss_safety.rs rename to examples/09-reference/xss_safety.rs diff --git a/examples/bevy/Cargo.toml b/examples/10-integrations/bevy/Cargo.toml similarity index 89% rename from examples/bevy/Cargo.toml rename to examples/10-integrations/bevy/Cargo.toml index e23a697f5..cdb63a204 100644 --- a/examples/bevy/Cargo.toml +++ b/examples/10-integrations/bevy/Cargo.toml @@ -13,7 +13,7 @@ tracing = ["dep:tracing-subscriber", "dioxus-native/tracing"] [dependencies] bevy = { version = "0.16" } -dioxus-native = { path = "../../packages/native" } +dioxus-native = { workspace = true } dioxus = { workspace = true } wgpu = { workspace = true } color = "0.3" diff --git a/examples/bevy/src/bevy_renderer.rs b/examples/10-integrations/bevy/src/bevy_renderer.rs similarity index 100% rename from examples/bevy/src/bevy_renderer.rs rename to examples/10-integrations/bevy/src/bevy_renderer.rs diff --git a/examples/bevy/src/bevy_scene_plugin.rs b/examples/10-integrations/bevy/src/bevy_scene_plugin.rs similarity index 100% rename from examples/bevy/src/bevy_scene_plugin.rs rename to examples/10-integrations/bevy/src/bevy_scene_plugin.rs diff --git a/examples/bevy/src/demo_renderer.rs b/examples/10-integrations/bevy/src/demo_renderer.rs similarity index 100% rename from examples/bevy/src/demo_renderer.rs rename to examples/10-integrations/bevy/src/demo_renderer.rs diff --git a/examples/bevy/src/main.rs b/examples/10-integrations/bevy/src/main.rs similarity index 97% rename from examples/bevy/src/main.rs rename to examples/10-integrations/bevy/src/main.rs index 41ac6458a..3635c6b2c 100644 --- a/examples/bevy/src/main.rs +++ b/examples/10-integrations/bevy/src/main.rs @@ -46,7 +46,7 @@ fn app() -> Element { use_effect(move || println!("{:?}", color().components)); rsx!( - document::Link { rel: "stylesheet", href: STYLES } + Stylesheet { href: STYLES } div { id:"overlay", h2 { "Control Panel" }, button { diff --git a/examples/bevy/src/styles.css b/examples/10-integrations/bevy/src/styles.css similarity index 100% rename from examples/bevy/src/styles.css rename to examples/10-integrations/bevy/src/styles.css diff --git a/examples/native-headless-in-bevy/Cargo.toml b/examples/10-integrations/native-headless-in-bevy/Cargo.toml similarity index 100% rename from examples/native-headless-in-bevy/Cargo.toml rename to examples/10-integrations/native-headless-in-bevy/Cargo.toml diff --git a/examples/native-headless-in-bevy/README.md b/examples/10-integrations/native-headless-in-bevy/README.md similarity index 100% rename from examples/native-headless-in-bevy/README.md rename to examples/10-integrations/native-headless-in-bevy/README.md diff --git a/examples/native-headless-in-bevy/src/bevy_scene_plugin.rs b/examples/10-integrations/native-headless-in-bevy/src/bevy_scene_plugin.rs similarity index 100% rename from examples/native-headless-in-bevy/src/bevy_scene_plugin.rs rename to examples/10-integrations/native-headless-in-bevy/src/bevy_scene_plugin.rs diff --git a/examples/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs b/examples/10-integrations/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs similarity index 99% rename from examples/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs rename to examples/10-integrations/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs index 25b439abe..b8e42c61b 100644 --- a/examples/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs +++ b/examples/10-integrations/native-headless-in-bevy/src/dioxus_in_bevy_plugin.rs @@ -166,7 +166,7 @@ impl render_graph::Node for TextureGetterNodeDriver { _graph: &mut RenderGraphContext, _render_context: &mut RenderContext, _world: &World, - ) -> Result<(), NodeRunError> { + ) -> bevy::prelude::Result<(), NodeRunError> { Ok(()) } } diff --git a/examples/native-headless-in-bevy/src/main.rs b/examples/10-integrations/native-headless-in-bevy/src/main.rs similarity index 100% rename from examples/native-headless-in-bevy/src/main.rs rename to examples/10-integrations/native-headless-in-bevy/src/main.rs diff --git a/examples/native-headless-in-bevy/src/ui.css b/examples/10-integrations/native-headless-in-bevy/src/ui.css similarity index 100% rename from examples/native-headless-in-bevy/src/ui.css rename to examples/10-integrations/native-headless-in-bevy/src/ui.css diff --git a/examples/native-headless-in-bevy/src/ui.rs b/examples/10-integrations/native-headless-in-bevy/src/ui.rs similarity index 100% rename from examples/native-headless-in-bevy/src/ui.rs rename to examples/10-integrations/native-headless-in-bevy/src/ui.rs diff --git a/examples/native-headless/Cargo.toml b/examples/10-integrations/native-headless/Cargo.toml similarity index 86% rename from examples/native-headless/Cargo.toml rename to examples/10-integrations/native-headless/Cargo.toml index 641b7bcdb..c3b1b6c49 100644 --- a/examples/native-headless/Cargo.toml +++ b/examples/10-integrations/native-headless/Cargo.toml @@ -9,7 +9,7 @@ publish = false tracing = ["dep:tracing-subscriber", "dioxus-native-dom/tracing"] [dependencies] -dioxus = { workspace = true, default-features = false, features = ["html", "hooks", "signals"] } +dioxus = { workspace = true, features = ["html", "hooks", "signals"] } dioxus-native-dom = { workspace = true } anyrender_vello = { workspace = true } vello = "0.5" diff --git a/examples/native-headless/src/main.rs b/examples/10-integrations/native-headless/src/main.rs similarity index 100% rename from examples/native-headless/src/main.rs rename to examples/10-integrations/native-headless/src/main.rs diff --git a/examples/pwa/Cargo.toml b/examples/10-integrations/pwa/Cargo.toml similarity index 100% rename from examples/pwa/Cargo.toml rename to examples/10-integrations/pwa/Cargo.toml diff --git a/examples/pwa/Dioxus.toml b/examples/10-integrations/pwa/Dioxus.toml similarity index 100% rename from examples/pwa/Dioxus.toml rename to examples/10-integrations/pwa/Dioxus.toml diff --git a/examples/pwa/LICENSE b/examples/10-integrations/pwa/LICENSE similarity index 100% rename from examples/pwa/LICENSE rename to examples/10-integrations/pwa/LICENSE diff --git a/examples/pwa/README.md b/examples/10-integrations/pwa/README.md similarity index 100% rename from examples/pwa/README.md rename to examples/10-integrations/pwa/README.md diff --git a/examples/pwa/index.html b/examples/10-integrations/pwa/index.html similarity index 100% rename from examples/pwa/index.html rename to examples/10-integrations/pwa/index.html diff --git a/examples/pwa/public/favicon.ico b/examples/10-integrations/pwa/public/favicon.ico similarity index 100% rename from examples/pwa/public/favicon.ico rename to examples/10-integrations/pwa/public/favicon.ico diff --git a/examples/pwa/public/logo_192.png b/examples/10-integrations/pwa/public/logo_192.png similarity index 100% rename from examples/pwa/public/logo_192.png rename to examples/10-integrations/pwa/public/logo_192.png diff --git a/examples/pwa/public/logo_512.png b/examples/10-integrations/pwa/public/logo_512.png similarity index 100% rename from examples/pwa/public/logo_512.png rename to examples/10-integrations/pwa/public/logo_512.png diff --git a/examples/pwa/public/manifest.json b/examples/10-integrations/pwa/public/manifest.json similarity index 100% rename from examples/pwa/public/manifest.json rename to examples/10-integrations/pwa/public/manifest.json diff --git a/examples/pwa/public/sw.js b/examples/10-integrations/pwa/public/sw.js similarity index 100% rename from examples/pwa/public/sw.js rename to examples/10-integrations/pwa/public/sw.js diff --git a/examples/pwa/src/main.rs b/examples/10-integrations/pwa/src/main.rs similarity index 100% rename from examples/pwa/src/main.rs rename to examples/10-integrations/pwa/src/main.rs diff --git a/examples/tailwind/.gitignore b/examples/10-integrations/tailwind/.gitignore similarity index 100% rename from examples/tailwind/.gitignore rename to examples/10-integrations/tailwind/.gitignore diff --git a/examples/tailwind/Cargo.toml b/examples/10-integrations/tailwind/Cargo.toml similarity index 100% rename from examples/tailwind/Cargo.toml rename to examples/10-integrations/tailwind/Cargo.toml diff --git a/examples/tailwind/README.md b/examples/10-integrations/tailwind/README.md similarity index 100% rename from examples/tailwind/README.md rename to examples/10-integrations/tailwind/README.md diff --git a/examples/tailwind/public/tailwind.css b/examples/10-integrations/tailwind/assets/tailwind.css similarity index 100% rename from examples/tailwind/public/tailwind.css rename to examples/10-integrations/tailwind/assets/tailwind.css diff --git a/examples/tailwind/src/main.rs b/examples/10-integrations/tailwind/src/main.rs similarity index 98% rename from examples/tailwind/src/main.rs rename to examples/10-integrations/tailwind/src/main.rs index 0b4b62126..1250daa9e 100644 --- a/examples/tailwind/src/main.rs +++ b/examples/10-integrations/tailwind/src/main.rs @@ -8,7 +8,7 @@ fn app() -> Element { let grey_background = true; rsx! { - document::Link { rel: "stylesheet", href: asset!("/public/tailwind.css") } + Stylesheet { href: asset!("/assets/tailwind.css") } div { header { class: "text-gray-400 body-font", diff --git a/examples/tailwind/tailwind.css b/examples/10-integrations/tailwind/tailwind.css similarity index 100% rename from examples/tailwind/tailwind.css rename to examples/10-integrations/tailwind/tailwind.css diff --git a/examples/wgpu-texture/Cargo.toml b/examples/10-integrations/wgpu-texture/Cargo.toml similarity index 89% rename from examples/wgpu-texture/Cargo.toml rename to examples/10-integrations/wgpu-texture/Cargo.toml index aafdd1b58..e70c23c9e 100644 --- a/examples/wgpu-texture/Cargo.toml +++ b/examples/10-integrations/wgpu-texture/Cargo.toml @@ -12,7 +12,7 @@ native = ["dioxus/native"] tracing = ["dep:tracing-subscriber", "dioxus-native/tracing"] [dependencies] -dioxus-native = { path = "../../packages/native" } +dioxus-native = { workspace = true } dioxus = { workspace = true } wgpu = { workspace = true } winit = "0.30" diff --git a/examples/wgpu-texture/src/demo_renderer.rs b/examples/10-integrations/wgpu-texture/src/demo_renderer.rs similarity index 100% rename from examples/wgpu-texture/src/demo_renderer.rs rename to examples/10-integrations/wgpu-texture/src/demo_renderer.rs diff --git a/examples/wgpu-texture/src/main.rs b/examples/10-integrations/wgpu-texture/src/main.rs similarity index 98% rename from examples/wgpu-texture/src/main.rs rename to examples/10-integrations/wgpu-texture/src/main.rs index e46fc4385..d5d32ce4c 100644 --- a/examples/wgpu-texture/src/main.rs +++ b/examples/10-integrations/wgpu-texture/src/main.rs @@ -58,7 +58,7 @@ fn app() -> Element { use_effect(move || println!("{:?}", color().components)); rsx!( - document::Link { rel: "stylesheet", href: STYLES } + Stylesheet { href: STYLES } div { id:"overlay", h2 { "Control Panel" }, button { diff --git a/examples/wgpu-texture/src/shader.wgsl b/examples/10-integrations/wgpu-texture/src/shader.wgsl similarity index 100% rename from examples/wgpu-texture/src/shader.wgsl rename to examples/10-integrations/wgpu-texture/src/shader.wgsl diff --git a/examples/wgpu-texture/src/styles.css b/examples/10-integrations/wgpu-texture/src/styles.css similarity index 100% rename from examples/wgpu-texture/src/styles.css rename to examples/10-integrations/wgpu-texture/src/styles.css diff --git a/examples/assets/file_upload.css b/examples/assets/file_upload.css index 58d1bb3dc..788ad10b9 100644 --- a/examples/assets/file_upload.css +++ b/examples/assets/file_upload.css @@ -8,7 +8,7 @@ body { min-height: 100vh; } -#drop-zone { +.drop-zone { border: 2px dashed #ccc; border-radius: 3px; padding: 20px; diff --git a/examples/bluetooth-scanner/src/main.rs b/examples/bluetooth-scanner/src/main.rs deleted file mode 100644 index c53498445..000000000 --- a/examples/bluetooth-scanner/src/main.rs +++ /dev/null @@ -1,94 +0,0 @@ -use dioxus::prelude::*; - -fn main() { - dioxus::launch(app) -} - -fn app() -> Element { - let mut status = use_resource(|| async { - use btleplug::api::{Central, Manager as _, Peripheral, ScanFilter}; - - let manager = btleplug::platform::Manager::new().await.unwrap(); - - // get the first bluetooth adapter - let adapters = manager.adapters().await.unwrap(); - let central = adapters.into_iter().next().unwrap(); - - // start scanning for devices - central.start_scan(ScanFilter::default()).await.unwrap(); - - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - - // Return the list of peripherals after scanning - let mut devices = vec![]; - for p in central.peripherals().await.unwrap() { - if let Some(p) = p.properties().await.unwrap() { - devices.push(p); - } - } - - println!("Found {} Bluetooth devices", devices.len()); - - devices - }); - - let scanning = !status.finished(); - - rsx! { - link { rel: "stylesheet", href: asset!("/assets/tailwind.css") }, - div { - div { class: "py-8 px-6", - div { class: "container px-4 mx-auto", - h2 { class: "text-2xl font-bold", "Scan for Bluetooth Devices" } - button { - class: "inline-block w-full md:w-auto px-6 py-3 font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded transition duration-200", - disabled: scanning, - onclick: move |_| { - status.restart(); - }, - if scanning { "Scanning" } else { "Scan" } - } - } - } - - section { class: "py-8", - div { class: "container px-4 mx-auto", - div { class: "p-4 mb-6 bg-white shadow rounded overflow-x-auto", - table { class: "table-auto w-full", - thead { - tr { class: "text-xs text-gray-500 text-left", - th { class: "pl-6 pb-3 font-medium", "Strength" } - th { class: "pb-3 font-medium", "Network" } - th { class: "pb-3 font-medium", "Channel" } - th { class: "pb-3 px-2 font-medium", "Security" } - } - } - - match &*status.read() { - None => rsx!("no devices yet!"), - Some(peripherals) => { - // Create vector of tuples of (signal_level, wifi) for sorting by signal_level - let mut sorted_devices = peripherals.clone(); - sorted_devices.sort_by(|a, b| a.rssi.partial_cmp(&b.rssi).unwrap()); - - rsx! { - tbody { - for peripheral in sorted_devices.into_iter().rev() { - tr { class: "text-xs bg-gray-50", - td { class: "py-5 px-6 font-medium", "{peripheral.rssi.unwrap_or(-100)}" } - td { class: "flex py-3 font-medium", "{peripheral.local_name.clone().unwrap_or_default()}" } - td { span { class: "inline-block py-1 px-2 text-white bg-green-500 rounded-full", "{peripheral.address}" } } - td { span { class: "inline-block py-1 px-2 text-purple-500 bg-purple-50 rounded-full", "{peripheral.tx_power_level.unwrap_or_default()}" } } - } - } - } - } - } - } - } - } - } - } - } - } -} diff --git a/examples/dog_app.rs b/examples/dog_app.rs deleted file mode 100644 index 0fb35c71b..000000000 --- a/examples/dog_app.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! This example demonstrates a simple app that fetches a list of dog breeds and displays a random dog. -//! -//! The app uses the `use_signal` and `use_resource` hooks to manage state and fetch data from the Dog API. -//! `use_resource` is basically an async version of use_memo - it will track dependencies between .await points -//! and then restart the future if any of the dependencies change. -//! -//! You should generally throttle requests to an API - either client side or server side. This example doesn't do that -//! since it's unlikely the user will rapidly cause new fetches, but it's something to keep in mind. - -use dioxus::prelude::*; -use std::collections::HashMap; - -fn main() { - dioxus::launch(app); -} - -fn app() -> Element { - // Breed is a signal that will be updated when the user clicks a breed in the list - // `shiba` is just a default that we know will exist. We could also use a `None` instead - let mut breed = use_signal(|| "shiba".to_string()); - - // Fetch the list of breeds from the Dog API - // Since there are no dependencies, this will never restart - let breed_list = use_resource(move || async move { - #[derive(Debug, Clone, PartialEq, serde::Deserialize)] - struct ListBreeds { - message: HashMap>, - } - - let list = reqwest::get("https://dog.ceo/api/breeds/list/all") - .await - .unwrap() - .json::() - .await; - - let Ok(breeds) = list else { - return rsx! { "error fetching breeds" }; - }; - - rsx! { - for cur_breed in breeds.message.keys().take(20).cloned() { - button { onclick: move |_| breed.set(cur_breed.clone()), - "{cur_breed}" - } - } - } - }); - - // We can use early returns in dioxus! - // Traditional signal-based libraries can't do this since the scope is by default non-reactive - let Some(breed_list) = breed_list() else { - return rsx! { "loading breeds..." }; - }; - - rsx! { - h1 { "Select a dog breed: {breed}" } - BreedPic { breed } - div { width: "400px", {breed_list} } - } -} - -#[component] -fn BreedPic(breed: WriteSignal) -> Element { - // This resource will restart whenever the breed changes - let mut fut = use_resource(move || async move { - #[derive(serde::Deserialize, Debug)] - struct DogApi { - message: String, - } - - reqwest::get(format!("https://dog.ceo/api/breed/{breed}/images/random")) - .await - .unwrap() - .json::() - .await - }); - - match fut.read_unchecked().as_ref() { - Some(Ok(resp)) => rsx! { - div { - button { onclick: move |_| fut.restart(), padding: "5px", background_color: "gray", color: "white", border_radius: "5px", "Click to fetch another doggo" } - img { max_width: "500px", max_height: "500px", src: "{resp.message}" } - } - }, - Some(Err(_)) => rsx! { "loading image failed" }, - None => rsx! { "loading image..." }, - } -} diff --git a/examples/ecommerce-site/src/components/cart.rs b/examples/ecommerce-site/src/components/cart.rs deleted file mode 100644 index 8b1378917..000000000 --- a/examples/ecommerce-site/src/components/cart.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/examples/ecommerce-site/src/components/home.rs b/examples/ecommerce-site/src/components/home.rs deleted file mode 100644 index 11006472a..000000000 --- a/examples/ecommerce-site/src/components/home.rs +++ /dev/null @@ -1,24 +0,0 @@ -// The homepage is statically rendered, so we don't need to a persistent websocket connection. - -use crate::{ - api::{fetch_products, Sort}, - components::nav, - components::product_item::product_item, -}; -use dioxus::prelude::*; - -pub(crate) fn Home() -> Element { - let products = use_server_future(|| fetch_products(10, Sort::Ascending))?; - let products = products().unwrap()?; - - rsx! { - nav::nav {} - section { class: "p-10", - for product in products { - product_item { - product - } - } - } - } -} diff --git a/examples/ecommerce-site/src/components/loading.rs b/examples/ecommerce-site/src/components/loading.rs deleted file mode 100644 index 2f4b1f2a3..000000000 --- a/examples/ecommerce-site/src/components/loading.rs +++ /dev/null @@ -1,32 +0,0 @@ -use dioxus::prelude::*; - -#[component] -pub(crate) fn ChildrenOrLoading(children: Element) -> Element { - rsx! { - document::Link { - rel: "stylesheet", - href: asset!("/public/loading.css") - } - SuspenseBoundary { - fallback: |context: SuspenseContext| { - rsx! { - if let Some(placeholder) = context.suspense_placeholder() { - {placeholder} - } else { - LoadingIndicator {} - } - } - }, - {children} - } - } -} - -#[component] -fn LoadingIndicator() -> Element { - rsx! { - div { - class: "spinner", - } - } -} diff --git a/examples/fullstack-auth/.gitignore b/examples/fullstack-auth/.gitignore deleted file mode 100644 index 21fff11dd..000000000 --- a/examples/fullstack-auth/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -target -static -.dioxus \ No newline at end of file diff --git a/examples/fullstack-auth/src/main.rs b/examples/fullstack-auth/src/main.rs deleted file mode 100644 index 808a72079..000000000 --- a/examples/fullstack-auth/src/main.rs +++ /dev/null @@ -1,150 +0,0 @@ -#![allow(non_snake_case, unused)] - -#[cfg(feature = "server")] -mod auth; - -use dioxus::prelude::*; -use serde::{Deserialize, Serialize}; - -fn main() { - // Set the logger ahead of time since we don't use `dioxus::launch` on the server - dioxus::logger::initialize_default(); - - #[cfg(feature = "web")] - // Hydrate the application on the client - LaunchBuilder::web().launch(app); - - #[cfg(feature = "server")] - { - use crate::auth::*; - use axum::routing::*; - use axum_session::SessionConfig; - use axum_session::SessionStore; - use axum_session_auth::AuthConfig; - use axum_session_sqlx::SessionSqlitePool; - tokio::runtime::Runtime::new() - .unwrap() - .block_on(async move { - let pool = connect_to_database().await; - - //This Defaults as normal Cookies. - //To enable Private cookies for integrity, and authenticity please check the next Example. - let session_config = SessionConfig::default().with_table_name("test_table"); - let auth_config = AuthConfig::::default().with_anonymous_user_id(Some(1)); - let session_store = SessionStore::::new( - Some(pool.clone().into()), - session_config, - ) - .await - .unwrap(); - - User::create_user_tables(&pool).await; - - // build our application with some routes - let app = Router::new() - // Server side render the application, serve static assets, and register server functions - .serve_dioxus_application(ServeConfig::new().unwrap(), app) - .layer( - axum_session_auth::AuthSessionLayer::< - crate::auth::User, - i64, - SessionSqlitePool, - sqlx::SqlitePool, - >::new(Some(pool)) - .with_config(auth_config), - ) - .layer(axum_session::SessionLayer::new(session_store)); - - // serve the app using the address passed by the CLI - let addr = dioxus::cli_config::fullstack_address_or_localhost(); - let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); - - axum::serve(listener, app.into_make_service()) - .await - .unwrap(); - }); - } -} -// -fn app() -> Element { - let mut user_name = use_signal(|| "?".to_string()); - let mut permissions = use_signal(|| "?".to_string()); - - rsx! { - div { - button { onclick: move |_| { - async move { - login().await?; - Ok(()) - } - }, - "Login Test User" - } - } - div { - button { - onclick: move |_| async move { - let data = get_user_name().await?; - user_name.set(data); - Ok(()) - }, - "Get User Name" - } - "User name: {user_name}" - } - div { - button { - onclick: move |_| async move { - let data = get_permissions().await?; - permissions.set(data); - Ok(()) - }, - "Get Permissions" - } - "Permissions: {permissions}" - } - } -} - -#[server] -pub async fn get_user_name() -> ServerFnResult { - let auth = auth::get_session().await?; - Ok(auth.current_user.unwrap().username.to_string()) -} - -#[server] -pub async fn login() -> ServerFnResult { - let auth = auth::get_session().await?; - auth.login_user(2); - Ok(()) -} - -#[server] -pub async fn get_permissions() -> ServerFnResult { - let method: axum::http::Method = extract().await?; - let auth = auth::get_session().await?; - let current_user = auth.current_user.clone().unwrap_or_default(); - - // lets check permissions only and not worry about if they are anon or not - if !axum_session_auth::Auth::::build( - [axum::http::Method::POST], - false, - ) - .requires(axum_session_auth::Rights::any([ - axum_session_auth::Rights::permission("Category::View"), - axum_session_auth::Rights::permission("Admin::View"), - ])) - .validate(¤t_user, &method, None) - .await - { - return Ok(format!( - "User {}, Does not have permissions needed to view this page please login", - current_user.username - )); - } - - Ok(format!( - "User has Permissions needed. Here are the Users permissions: {:?}", - current_user.permissions - )) -} diff --git a/examples/fullstack-desktop/.gitignore b/examples/fullstack-desktop/.gitignore deleted file mode 100644 index 21fff11dd..000000000 --- a/examples/fullstack-desktop/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -target -static -.dioxus \ No newline at end of file diff --git a/examples/fullstack-hello-world/.gitignore b/examples/fullstack-hello-world/.gitignore deleted file mode 100644 index 21fff11dd..000000000 --- a/examples/fullstack-hello-world/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -target -static -.dioxus \ No newline at end of file diff --git a/examples/fullstack-hello-world/assets/hello.css b/examples/fullstack-hello-world/assets/hello.css deleted file mode 100644 index 905bc8dc8..000000000 --- a/examples/fullstack-hello-world/assets/hello.css +++ /dev/null @@ -1,3 +0,0 @@ -body { - background-color: rgb(108, 104, 104); -} diff --git a/examples/fullstack-hello-world/src/main.rs b/examples/fullstack-hello-world/src/main.rs deleted file mode 100644 index d86a0d891..000000000 --- a/examples/fullstack-hello-world/src/main.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Run with: -//! -//! ```sh -//! dx serve --platform web -//! ``` - -#![allow(non_snake_case, unused)] -use dioxus::prelude::*; -use serde::{Deserialize, Serialize}; - -fn app() -> Element { - let mut count = use_signal(|| 0); - let mut text = use_signal(|| "...".to_string()); - let server_future = use_server_future(get_server_data)?; - - rsx! { - document::Link { href: asset!("/assets/hello.css"), rel: "stylesheet" } - h1 { "High-Five counter: {count}" } - button { onclick: move |_| count += 1, "Up high!" } - button { onclick: move |_| count -= 1, "Down low!" } - button { - onclick: move |_| async move { - let data = get_server_data().await?; - println!("Client received: {}", data); - text.set(data.clone()); - post_server_data(data).await?; - Ok(()) - }, - "Run a server function!" - } - "Server said: {text}" - } -} - -#[server] -async fn post_server_data(data: String) -> ServerFnResult { - println!("Server received: {}", data); - - Ok(()) -} - -#[server] -async fn get_server_data() -> ServerFnResult { - Ok(reqwest::get("https://httpbin.org/ip").await?.text().await?) -} - -fn main() { - dioxus::launch(app); -} diff --git a/examples/fullstack-router/.gitignore b/examples/fullstack-router/.gitignore deleted file mode 100644 index 21fff11dd..000000000 --- a/examples/fullstack-router/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -target -static -.dioxus \ No newline at end of file diff --git a/examples/fullstack-streaming/.gitignore b/examples/fullstack-streaming/.gitignore deleted file mode 100644 index 21fff11dd..000000000 --- a/examples/fullstack-streaming/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -target -static -.dioxus \ No newline at end of file diff --git a/examples/fullstack-streaming/Cargo.toml b/examples/fullstack-streaming/Cargo.toml deleted file mode 100644 index cda0ea11e..000000000 --- a/examples/fullstack-streaming/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "fullstack-streaming-example" -version = "0.1.0" -edition = "2021" -publish = false - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -dioxus = { workspace = true, features = ["fullstack"] } -serde = { workspace = true } -futures = { workspace = true } -tokio = { workspace = true, optional = true } -futures-util.workspace = true - -[features] -default = [] -server = ["dioxus/server", "dep:tokio"] -web = ["dioxus/web"] diff --git a/examples/fullstack-streaming/src/main.rs b/examples/fullstack-streaming/src/main.rs deleted file mode 100644 index 48a0a33e8..000000000 --- a/examples/fullstack-streaming/src/main.rs +++ /dev/null @@ -1,45 +0,0 @@ -use dioxus::prelude::*; -use futures::StreamExt; -use server_fn::codec::{StreamingText, TextStream}; - -fn app() -> Element { - let mut response = use_signal(String::new); - - rsx! { - button { - onclick: move |_| async move { - response.write().clear(); - let stream = test_stream().await?; - response.write().push_str("Stream started\n"); - let mut stream = stream.into_inner(); - while let Some(Ok(text)) = stream.next().await { - response.write().push_str(&text); - } - Ok(()) - }, - "Start stream" - } - "{response}" - } -} - -#[server(output = StreamingText)] -pub async fn test_stream() -> ServerFnResult> { - let (tx, rx) = futures::channel::mpsc::unbounded(); - tokio::spawn(async move { - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - dioxus::logger::tracing::info!("Sending new chunk!"); - if tx.unbounded_send(Ok("Hello, world!".to_string())).is_err() { - // If the channel is closed, stop sending chunks - break; - } - } - }); - - Ok(TextStream::new(rx)) -} - -fn main() { - dioxus::launch(app) -} diff --git a/examples/fullstack-websockets/.gitignore b/examples/fullstack-websockets/.gitignore deleted file mode 100644 index 21fff11dd..000000000 --- a/examples/fullstack-websockets/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -dist -target -static -.dioxus \ No newline at end of file diff --git a/examples/fullstack-websockets/src/main.rs b/examples/fullstack-websockets/src/main.rs deleted file mode 100644 index 414d1a034..000000000 --- a/examples/fullstack-websockets/src/main.rs +++ /dev/null @@ -1,69 +0,0 @@ -#![allow(non_snake_case)] -use dioxus::prelude::{ - server_fn::{codec::JsonEncoding, BoxedStream, Websocket}, - *, -}; -use futures::{channel::mpsc, SinkExt, StreamExt}; - -fn main() { - launch(app); -} - -fn app() -> Element { - let mut uppercase = use_signal(String::new); - let mut uppercase_channel = use_signal(|| None); - - // Start the websocket connection in a background task - use_future(move || async move { - let (tx, rx) = mpsc::channel(1); - let mut receiver = uppercase_ws(rx.into()).await.unwrap(); - // Store the channel in a signal for use in the input handler - uppercase_channel.set(Some(tx)); - // Whenever we get a message from the server, update the uppercase signal - while let Some(Ok(msg)) = receiver.next().await { - uppercase.set(msg); - } - }); - - rsx! { - input { - oninput: move |e| async move { - if let Some(mut uppercase_channel) = uppercase_channel() { - let msg = e.value(); - uppercase_channel.send(Ok(msg)).await.unwrap(); - } - }, - } - "Uppercase: {uppercase}" - } -} - -// The server macro accepts a protocol parameter which implements the protocol trait. The protocol -// controls how the inputs and outputs are encoded when handling the server function. In this case, -// the websocket protocol can encode a stream input and stream output where messages are -// serialized as JSON -#[server(protocol = Websocket)] -async fn uppercase_ws( - input: BoxedStream, -) -> ServerFnResult> { - let mut input = input; - - // Create a channel with the output of the websocket - let (mut tx, rx) = mpsc::channel(1); - - // Spawn a task that processes the input stream and sends any new messages to the output - tokio::spawn(async move { - while let Some(msg) = input.next().await { - if tx - .send(msg.map(|msg| msg.to_ascii_uppercase())) - .await - .is_err() - { - break; - } - } - }); - - // Return the output stream - Ok(rx.into()) -} diff --git a/examples/hotdog/src/backend.rs b/examples/hotdog/src/backend.rs deleted file mode 100644 index 8c7f9245d..000000000 --- a/examples/hotdog/src/backend.rs +++ /dev/null @@ -1,43 +0,0 @@ -use dioxus::prelude::*; - -#[cfg(feature = "server")] -thread_local! { - static DB: rusqlite::Connection = { - let conn = rusqlite::Connection::open("hotdogdb/hotdog.db").expect("Failed to open database"); - - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS dogs ( - id INTEGER PRIMARY KEY, - url TEXT NOT NULL - );", - ).unwrap(); - - conn - }; -} - -#[server(endpoint = "list_dogs")] -pub async fn list_dogs() -> Result, ServerFnError> { - let dogs = DB.with(|f| { - f.prepare("SELECT id, url FROM dogs ORDER BY id DESC LIMIT 10") - .unwrap() - .query_map([], |row| Ok((row.get(0)?, row.get(1)?))) - .unwrap() - .map(|r| r.unwrap()) - .collect() - }); - - Ok(dogs) -} - -#[server(endpoint = "remove_dog")] -pub async fn remove_dog(id: usize) -> Result<(), ServerFnError> { - DB.with(|f| f.execute("DELETE FROM dogs WHERE id = ?1", [&id]))?; - Ok(()) -} - -#[server(endpoint = "save_dog")] -pub async fn save_dog(image: String) -> Result<(), ServerFnError> { - _ = DB.with(|f| f.execute("INSERT INTO dogs (url) VALUES (?1)", [&image])); - Ok(()) -} diff --git a/examples/hotdog/src/components/favorites.rs b/examples/hotdog/src/components/favorites.rs deleted file mode 100644 index afae04396..000000000 --- a/examples/hotdog/src/components/favorites.rs +++ /dev/null @@ -1,25 +0,0 @@ -use dioxus::prelude::*; - -#[component] -pub fn Favorites() -> Element { - let mut favorites = use_resource(crate::backend::list_dogs); - - rsx! { - div { id: "favorites", - div { id: "favorites-container", - for (id , url) in favorites.suspend()?.cloned().unwrap() { - div { class: "favorite-dog", key: "{id}", - img { src: "{url}" } - button { - onclick: move |_| async move { - crate::backend::remove_dog(id).await.unwrap(); - favorites.restart(); - }, - "❌" - } - } - } - } - } - } -} diff --git a/examples/hotdog/src/components/mod.rs b/examples/hotdog/src/components/mod.rs deleted file mode 100644 index a3f4b203b..000000000 --- a/examples/hotdog/src/components/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -mod favorites; -mod nav; -mod view; - -pub use favorites::*; -pub use nav::*; -pub use view::*; diff --git a/examples/hotdog/src/components/nav.rs b/examples/hotdog/src/components/nav.rs deleted file mode 100644 index d0eee87e6..000000000 --- a/examples/hotdog/src/components/nav.rs +++ /dev/null @@ -1,19 +0,0 @@ -use crate::Route; -use dioxus::prelude::*; - -#[component] -pub fn NavBar() -> Element { - rsx! { - div { id: "title", - span {} - - Link { to: Route::DogView, - h1 { "🌭 HotDog! " } - } - - Link { to: Route::Favorites, id: "heart", "♥️" } - } - - Outlet:: {} - } -} diff --git a/examples/hotdog/src/components/view.rs b/examples/hotdog/src/components/view.rs deleted file mode 100644 index 3cfa11171..000000000 --- a/examples/hotdog/src/components/view.rs +++ /dev/null @@ -1,42 +0,0 @@ -use dioxus::prelude::*; - -#[component] -pub fn DogView() -> Element { - let mut img_src = use_resource(|| async move { - #[derive(serde::Deserialize)] - struct DogApi { - message: String, - } - - reqwest::get("https://dog.ceo/api/breeds/image/random") - .await - .unwrap() - .json::() - .await - .unwrap() - .message - }); - - rsx! { - div { id: "dogview", - img { id: "dogimg", src: "{img_src().unwrap_or_default()}" } - } - div { id: "buttons", - button { - id: "skip", - onclick: move |_| { - img_src.restart(); - }, - "skip" - } - button { - id: "save", - onclick: move |_| async move { - img_src.restart(); - crate::backend::save_dog(img_src().unwrap()).await.unwrap(); - }, - "save!" - } - } - } -} diff --git a/examples/hydration.rs b/examples/hydration.rs deleted file mode 100644 index 06513a47d..000000000 --- a/examples/hydration.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Example: real-world usage of hydration -//! ------------------------------------ -//! -//! This example shows how to pre-render a page using dioxus SSR and then how to rehydrate it on the client side. -//! -//! To accomplish hydration on the web, you'll want to set up a slightly more sophisticated build & bundle strategy. In -//! the official docs, we have a guide for using DioxusStudio as a build tool with pre-rendering and hydration. -//! -//! In this example, we pre-render the page to HTML and then pass it into the desktop configuration. This serves as a -//! proof-of-concept for the hydration feature, but you'll probably only want to use hydration for the web. - -use dioxus::desktop::Config; -use dioxus::prelude::*; - -fn main() { - dioxus::LaunchBuilder::desktop() - .with_cfg(Config::new().with_prerendered({ - // We build the dom a first time, then pre-render it to HTML - let pre_rendered_dom = VirtualDom::prebuilt(app); - - // We then launch the app with the pre-rendered HTML - dioxus_ssr::pre_render(&pre_rendered_dom) - })) - .launch(app) -} - -fn app() -> Element { - let mut val = use_signal(|| 0); - - rsx! { - div { - h1 { "hello world. Count: {val}" } - button { onclick: move |_| val += 1, "click to increment" } - } - } -} diff --git a/examples/image_generator_openai.rs b/examples/image_generator_openai.rs deleted file mode 100644 index 600440e7e..000000000 --- a/examples/image_generator_openai.rs +++ /dev/null @@ -1,123 +0,0 @@ -use dioxus::prelude::*; -use serde::{Deserialize, Serialize}; -use serde_json::{json, Error}; - -fn main() { - dioxus::launch(app) -} - -fn app() -> Element { - let mut loading = use_signal(|| "".to_string()); - let mut api = use_signal(|| "".to_string()); - let mut prompt = use_signal(|| "".to_string()); - let mut n_image = use_signal(|| 1.to_string()); - let mut image = use_signal(|| ImageResponse { - created: 0, - data: Vec::new(), - }); - - let mut generate_images = use_resource(move || async move { - let api_key = api.peek().clone(); - let prompt = prompt.peek().clone(); - let number_of_images = n_image.peek().clone(); - - if api_key.is_empty() || prompt.is_empty() || number_of_images.is_empty() { - return; - } - - loading.set("is-loading".to_string()); - - match request(api_key, prompt, number_of_images).await { - Ok(imgz) => image.set(imgz), - Err(e) => println!("Error: {:?}", e), - } - - loading.set("".to_string()); - }); - - rsx! { - document::Link { rel: "stylesheet", href: "https://unpkg.com/bulma@0.9.0/css/bulma.min.css" } - div { class: "container", - div { class: "columns", - div { class: "column", - input { class: "input is-primary mt-4", - value: "{api}", - r#type: "text", - placeholder: "API", - oninput: move |evt| api.set(evt.value()), - } - input { class: "input is-primary mt-4", - placeholder: "MAX 1000 Dgts", - r#type: "text", - value:"{prompt}", - oninput: move |evt| prompt.set(evt.value()) - } - input { class: "input is-primary mt-4", - r#type: "number", - min:"1", - max:"10", - value:"{n_image}", - oninput: move |evt| n_image.set(evt.value()), - } - } - } - button { class: "button is-primary {loading}", - onclick: move |_| generate_images.restart(), - "Generate image" - } - br {} - for image in image.read().data.as_slice() { - section { class: "is-flex", - div { class: "container is-fluid", - div { class: "container has-text-centered", - div { class: "is-justify-content-center", - div { class: "level", - div { class: "level-item", - figure { class: "image", img { alt: "", src: "{image.url}", } } - } - } - } - } - } - } - } - } - } -} -async fn request(api: String, prompt: String, n_image: String) -> Result { - let client = reqwest::Client::new(); - let body = json!({ - "prompt": prompt, - "n":n_image.parse::().unwrap_or(1), - "size":"1024x1024", - }); - - let mut authorization = "Bearer ".to_string(); - authorization.push_str(&api); - - let res = client - .post("https://api.openai.com/v1/images/generations") - .body(body.to_string()) - .header("Content-Type", "application/json") - .header("Authorization", authorization) - .send() - .await - .unwrap() - .text() - .await - .unwrap(); - - let deserialized: ImageResponse = serde_json::from_str(&res)?; - Ok(deserialized) -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Props, Clone)] -struct UrlImage { - url: String, -} - -#[derive(Serialize, Deserialize, Debug, PartialEq, Props, Clone)] -struct ImageResponse { - created: i32, - data: Vec, -} diff --git a/examples/login_form.rs b/examples/login_form.rs deleted file mode 100644 index 6c2098e1a..000000000 --- a/examples/login_form.rs +++ /dev/null @@ -1,49 +0,0 @@ -//! Implementing a login form -//! -//! This example demonstrates how to implement a login form using Dioxus desktop. Since forms typically navigate the -//! page on submit, we need to intercept the onsubmit event and send a request to a server. On the web, we could -//! just leave the `submit action` as is, but on desktop, we need to handle the form submission ourselves. -//! -//! Todo: actually spin up a server and run the login flow. Login is way more complex than a form override :) - -use dioxus::prelude::*; - -fn main() { - dioxus::launch(app); -} - -fn app() -> Element { - let onsubmit = move |evt: FormEvent| async move { - let resp = reqwest::Client::new() - .post("http://localhost:8080/login") - .form(&[ - ("username", evt.values()["username"].as_value()), - ("password", evt.values()["password"].as_value()), - ]) - .send() - .await; - - match resp { - // Parse data from here, such as storing a response token - Ok(_data) => println!("Login successful!"), - - //Handle any errors from the fetch here - Err(_err) => { - println!("Login failed - you need a login server running on localhost:8080.") - } - } - }; - - rsx! { - h1 { "Login" } - form { onsubmit, - input { r#type: "text", id: "username", name: "username" } - label { "Username" } - br {} - input { r#type: "password", id: "password", name: "password" } - label { "Password" } - br {} - button { "Login" } - } - } -} diff --git a/examples/scripts/scrape_examples.rs b/examples/scripts/scrape_examples.rs new file mode 100644 index 000000000..4baac0106 --- /dev/null +++ b/examples/scripts/scrape_examples.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +struct Example { + name: String, + path: PathBuf, +} + +fn main() { + let dir = PathBuf::from("/Users/jonathankelley/Development/dioxus/examples"); + let out_file = PathBuf::from("/Users/jonathankelley/Development/dioxus/target/decl.toml"); + + let mut out_items = vec![]; + + // Iterate through the sub-directories of the examples directory + for dir in dir.read_dir().unwrap().flatten() { + // For each sub-directory, walk it too, collecting .rs files + let Ok(dir) = dir.path().read_dir() else { + continue; + }; + + for dir in dir.flatten() { + let path = dir.path(); + if path.extension().and_then(|s| s.to_str()) == Some("rs") { + let file_stem = path.file_stem().and_then(|s| s.to_str()).unwrap(); + let workspace_path = path + .strip_prefix("/Users/jonathankelley/Development/dioxus") + .unwrap(); + out_items.push(Example { + name: file_stem.to_string(), + path: workspace_path.to_path_buf(), + }); + } + } + } + + let mut out_toml = String::new(); + out_items.sort_by(|a, b| a.path.cmp(&b.path)); + + for item in out_items { + out_toml.push_str(&format!( + "[[example]]\nname = \"{}\"\npath = \"{}\"\ndoc-scrape-examples = true\n\n", + item.name, + item.path.display() + )); + } + + std::fs::write(out_file, out_toml).unwrap(); +} diff --git a/examples/suspense.rs b/examples/suspense.rs deleted file mode 100644 index 656e808b7..000000000 --- a/examples/suspense.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Suspense in Dioxus -//! -//! Currently, `rsx!` does not accept futures as values. To achieve the functionality -//! of suspense, we need to make a new component that performs its own suspense -//! handling. -//! -//! In this example, we render the `Doggo` component which starts a future that -//! will cause it to fetch a random dog image from the Dog API. Since the data -//! is not ready immediately, we render some loading text. -//! -//! We can achieve the majority of suspense functionality by composing "suspenseful" -//! primitives in our own custom components. - -use dioxus::desktop::{Config, LogicalSize, WindowBuilder}; -use dioxus::prelude::*; - -fn main() { - dioxus::LaunchBuilder::new() - .with_cfg(desktop! { - Config::new().with_window( - WindowBuilder::new() - .with_title("Doggo Fetcher") - .with_inner_size(LogicalSize::new(600.0, 800.0)), - ) - }) - .launch(app) -} - -fn app() -> Element { - rsx! { - div { - h1 { "Dogs are very important" } - p { - "The dog or domestic dog (Canis familiaris[4][5] or Canis lupus familiaris[5])" - "is a domesticated descendant of the wolf which is characterized by an upturning tail." - "The dog derived from an ancient, extinct wolf,[6][7] and the modern grey wolf is the" - "dog's nearest living relative.[8] The dog was the first species to be domesticated,[9][8]" - "by hunter–gatherers over 15,000 years ago,[7] before the development of agriculture.[1]" - } - - h3 { "Illustrious Dog Photo" } - SuspenseBoundary { - fallback: move |suspense: SuspenseContext| suspense.suspense_placeholder().unwrap_or_else(|| rsx! { - div { - "Loading..." - } - }), - Doggo {} - } - } - } -} - -/// This component will re-render when the future has finished -/// Suspense is achieved my moving the future into only the component that -/// actually renders the data. -#[component] -fn Doggo() -> Element { - let mut resource = use_resource(move || async move { - #[derive(serde::Deserialize)] - struct DogApi { - message: String, - } - - reqwest::get("https://dog.ceo/api/breeds/image/random/") - .await - .unwrap() - .json::() - .await - }); - - // You can suspend the future and only continue rendering when it's ready - let value = resource.suspend().with_loading_placeholder(|| { - rsx! { - div { - "Loading doggos..." - } - } - })?; - - match value.read_unchecked().as_ref() { - Ok(resp) => rsx! { - button { onclick: move |_| resource.restart(), "Click to fetch another doggo" } - div { img { max_width: "500px", max_height: "500px", src: "{resp.message}" } } - }, - Err(_) => rsx! { - div { "loading dogs failed" } - button { - onclick: move |_| resource.restart(), - "retry" - } - }, - } -} diff --git a/examples/tailwind/Dioxus.toml b/examples/tailwind/Dioxus.toml deleted file mode 100644 index af7fcf3b8..000000000 --- a/examples/tailwind/Dioxus.toml +++ /dev/null @@ -1,27 +0,0 @@ -[application] - -# App (Project) Name -name = "Tailwind CSS + Dioxus" - -# Dioxus App Default Platform -# desktop, web, mobile, ssr -default_platform = "web" - -# `build` & `serve` dist path -out_dir = "dist" - -# resource (public) file folder -asset_dir = "public" - -[web.app] - -# HTML title tag content -title = "dioxus | ⛺" - -[web.watcher] - -# when watcher trigger, regenerate the `index.html` -reload_html = true - -# which files or dirs will be watcher monitoring -watch_path = ["src", "public"] diff --git a/packages/asset-resolver/src/native.rs b/packages/asset-resolver/src/native.rs index 3f2784891..55b2b43a9 100644 --- a/packages/asset-resolver/src/native.rs +++ b/packages/asset-resolver/src/native.rs @@ -207,7 +207,7 @@ fn get_mime_by_ext(trimmed: &Path) -> &'static str { } /// Get the mime type from a URI using its extension -fn get_mime_from_ext(ext: Option<&str>) -> &'static str { +pub fn get_mime_from_ext(ext: Option<&str>) -> &'static str { match ext { // The common assets are all utf-8 encoded Some("js") => "text/javascript; charset=utf-8", diff --git a/packages/cli-config/src/lib.rs b/packages/cli-config/src/lib.rs index e3092266a..da3e77b44 100644 --- a/packages/cli-config/src/lib.rs +++ b/packages/cli-config/src/lib.rs @@ -222,6 +222,7 @@ pub fn is_cli_enabled() -> bool { /// be served at `http://localhost:8080/dogapp`. /// /// All assets will be served from this base path as well, ie `http://localhost:8080/dogapp/assets/logo.png`. +#[allow(unreachable_code)] pub fn base_path() -> Option { // This may trigger when compiling to the server if you depend on another crate that pulls in // the web feature. It might be better for the renderers to provide the current platform diff --git a/packages/cli-harnesses/.gitignore b/packages/cli-harnesses/.gitignore new file mode 100644 index 000000000..0088a323e --- /dev/null +++ b/packages/cli-harnesses/.gitignore @@ -0,0 +1,2 @@ +!README.md +!.gitignore diff --git a/packages/cli-harnesses/README.md b/packages/cli-harnesses/README.md new file mode 100644 index 000000000..f397af1d1 --- /dev/null +++ b/packages/cli-harnesses/README.md @@ -0,0 +1,65 @@ +# Test harnesses + +This folder contains a series of CLI harnesses that represent common structures of dioxus apps. + +We test these projects as a form of smoke testing to ensure our argument resolution works properly. + +These projects might not be functionally useful, but they do have interesting properties that the CLI tests. + +The tests for the CLI are contained within the CLI itself. + +## This Folder + +The items in this folder are procedurally generated by tests in the CLI crate. We will delete EVERYTHING that ends up here. + +## How platform resolution works + +Determine which features, triple, profile, etc to pass to the build. + +Most of the time, users should use `dx serve --` where the platform name directly +corresponds to the feature in their cargo.toml. So, +- `dx serve --web` will enable the `web` feature +- `dx serve --mobile` will enable the `mobile` feature +- `dx serve --desktop` will enable the `desktop` feature + +In this case, we set default-features to false and then add back the default features that +aren't renderers, and then add the feature for the given renderer (ie web/desktop/mobile). +We call this "no-default-features-stripped." + +There are a few cases where the user doesn't need to pass a platform. +- they selected one via `dioxus = { features = ["web"] }` +- they have a single platform in their default features `default = ["web"]` +- there is only a single non-server renderer as a feature `web = ["dioxus/web"], server = ["dioxus/server"]` +- they compose the super triple via triple + bundleformat + features + +Note that we only use the names of the features to correspond with the platform. +Platforms are "super triples", meaning they contain information about +- bundle format +- target triple +- how to serve +- enabled features + +By default, the --platform presets correspond to: +- web: bundle(web), triple(wasm32), serve(http-serve), features("web") +- desktop: alias to mac/win/linux +- mac: bundle(mac), triple(host), serve(appbundle-open), features("desktop") +- windows: bundle(exefolder), triple(host), serve(run-exe), features("desktop") +- linux: bundle(appimage), triple(host), serve(run-exe), features("desktop") +- ios: bundle(ios), triple(arm64-apple-ios), serve(ios-simulator/xcrun), features("mobile") +- android: bundle(android), triple(arm64-apple-ios), serve(android-emulator/adb), features("mobile") +- server: bundle(server), triple(host), serve(run-exe), features("server") +- liveview: bundle(liveview), triple(host), serve(run-exe), features("liveview") +- unknown: + +Fullstack usage is inferred from the presence of the fullstack feature or --fullstack. + +## List of harnesses (brainstorming) + +- app that doesn't use dioxus +- simple app where the renderer is used as an explicit feature +- simple app, same as above, but with fullstack enabled + - since no `server` feature is present, we don't run the server +- simple app, same as above, but with a `server` feature + - server should launch and take precedence over the client +- simple server-only app + - IE you're doing SSR with dioxus diff --git a/packages/cli-harnesses/harness-default-to-non-default/Cargo.toml b/packages/cli-harnesses/harness-default-to-non-default/Cargo.toml new file mode 100644 index 000000000..d053b0c7f --- /dev/null +++ b/packages/cli-harnesses/harness-default-to-non-default/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "harness-default-to-non-default" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = [] } + + +[features] +default=["web"] +web=["dioxus/web"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-default-to-non-default/src/main.rs b/packages/cli-harnesses/harness-default-to-non-default/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-default-to-non-default/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-fullstack-desktop-with-default/Cargo.toml b/packages/cli-harnesses/harness-fullstack-desktop-with-default/Cargo.toml new file mode 100644 index 000000000..6f4f09d7d --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-desktop-with-default/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "harness-fullstack-desktop-with-default" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } +anyhow = { workspace = true, optional = true } + + +[features] +default=["desktop"] +desktop=["dioxus/desktop", "has_anyhow"] +has_anyhow=["dep:anyhow"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-fullstack-desktop-with-default/src/main.rs b/packages/cli-harnesses/harness-fullstack-desktop-with-default/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-desktop-with-default/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-fullstack-desktop-with-features/Cargo.toml b/packages/cli-harnesses/harness-fullstack-desktop-with-features/Cargo.toml new file mode 100644 index 000000000..a75a8a520 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-desktop-with-features/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "harness-fullstack-desktop-with-features" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } +anyhow = { workspace = true, optional = true } + + +[features] +desktop=["dioxus/desktop", "has_anyhow"] +has_anyhow=["dep:anyhow"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-fullstack-desktop-with-features/src/main.rs b/packages/cli-harnesses/harness-fullstack-desktop-with-features/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-desktop-with-features/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-fullstack-desktop/Cargo.toml b/packages/cli-harnesses/harness-fullstack-desktop/Cargo.toml new file mode 100644 index 000000000..762c1c122 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-desktop/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "harness-fullstack-desktop" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +desktop=["dioxus/desktop"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-fullstack-desktop/src/main.rs b/packages/cli-harnesses/harness-fullstack-desktop/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-desktop/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-fullstack-multi-target-no-default/Cargo.toml b/packages/cli-harnesses/harness-fullstack-multi-target-no-default/Cargo.toml new file mode 100644 index 000000000..ac59db7f7 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-multi-target-no-default/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "harness-fullstack-multi-target-no-default" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +web=["dioxus/web"] +desktop=["dioxus/desktop"] +mobile=["dioxus/mobile"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-fullstack-multi-target-no-default/src/main.rs b/packages/cli-harnesses/harness-fullstack-multi-target-no-default/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-multi-target-no-default/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-fullstack-multi-target/Cargo.toml b/packages/cli-harnesses/harness-fullstack-multi-target/Cargo.toml new file mode 100644 index 000000000..18df1867f --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-multi-target/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "harness-fullstack-multi-target" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +default=["web", "desktop", "mobile", "server"] +web=["dioxus/web"] +desktop=["dioxus/desktop"] +mobile=["dioxus/mobile"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-fullstack-multi-target/src/main.rs b/packages/cli-harnesses/harness-fullstack-multi-target/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-multi-target/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/examples/fullstack-websockets/Cargo.toml b/packages/cli-harnesses/harness-fullstack-with-optional-tokio/Cargo.toml similarity index 59% rename from examples/fullstack-websockets/Cargo.toml rename to packages/cli-harnesses/harness-fullstack-with-optional-tokio/Cargo.toml index bcbd49fdc..e9ae65c45 100644 --- a/examples/fullstack-websockets/Cargo.toml +++ b/packages/cli-harnesses/harness-fullstack-with-optional-tokio/Cargo.toml @@ -1,16 +1,19 @@ [package] -name = "fullstack-websocket-example" -version = "0.1.0" +name = "harness-fullstack-with-optional-tokio" +version = "0.0.1" edition = "2021" +license = "MIT OR Apache-2.0" publish = false -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - [dependencies] dioxus = { workspace = true, features = ["fullstack"] } -futures.workspace = true +serde = "1.0.219" tokio = { workspace = true, features = ["full"], optional = true } + [features] +default = [] server = ["dioxus/server", "dep:tokio"] web = ["dioxus/web"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-fullstack-with-optional-tokio/src/main.rs b/packages/cli-harnesses/harness-fullstack-with-optional-tokio/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-fullstack-with-optional-tokio/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-no-dioxus/Cargo.toml b/packages/cli-harnesses/harness-no-dioxus/Cargo.toml new file mode 100644 index 000000000..686630314 --- /dev/null +++ b/packages/cli-harnesses/harness-no-dioxus/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "harness-no-dioxus" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +anyhow = { workspace = true, optional = true } + + +[features] +web=["dep:anyhow"] +server=[] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-no-dioxus/src/main.rs b/packages/cli-harnesses/harness-no-dioxus/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/packages/cli-harnesses/harness-no-dioxus/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/packages/cli-harnesses/harness-renderer-swap/Cargo.toml b/packages/cli-harnesses/harness-renderer-swap/Cargo.toml new file mode 100644 index 000000000..978d35e52 --- /dev/null +++ b/packages/cli-harnesses/harness-renderer-swap/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "harness-renderer-swap" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +default=["desktop", "server"] +desktop=["dioxus/desktop"] +native=["dioxus/native"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-renderer-swap/src/main.rs b/packages/cli-harnesses/harness-renderer-swap/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-renderer-swap/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-simple-dedicated-client/Cargo.toml b/packages/cli-harnesses/harness-simple-dedicated-client/Cargo.toml new file mode 100644 index 000000000..f74298f84 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-dedicated-client/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "harness-simple-dedicated-client" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["web"] } + + +[features] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-dedicated-client/src/main.rs b/packages/cli-harnesses/harness-simple-dedicated-client/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-dedicated-client/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/packages/cli-harnesses/harness-simple-dedicated-server/Cargo.toml b/packages/cli-harnesses/harness-simple-dedicated-server/Cargo.toml new file mode 100644 index 000000000..092b5b2e8 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-dedicated-server/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "harness-simple-dedicated-server" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] + + +[features] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-dedicated-server/src/main.rs b/packages/cli-harnesses/harness-simple-dedicated-server/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-dedicated-server/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/packages/cli-harnesses/harness-simple-desktop/Cargo.toml b/packages/cli-harnesses/harness-simple-desktop/Cargo.toml new file mode 100644 index 000000000..90a04b4f9 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-desktop/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "harness-simple-desktop" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["desktop"] } + + +[features] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-desktop/src/main.rs b/packages/cli-harnesses/harness-simple-desktop/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-desktop/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/packages/cli-harnesses/harness-simple-fullstack-native-with-default/Cargo.toml b/packages/cli-harnesses/harness-simple-fullstack-native-with-default/Cargo.toml new file mode 100644 index 000000000..d7f14804a --- /dev/null +++ b/packages/cli-harnesses/harness-simple-fullstack-native-with-default/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "harness-simple-fullstack-native-with-default" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +default=["native", "server"] +native=["dioxus/native"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-fullstack-native-with-default/src/main.rs b/packages/cli-harnesses/harness-simple-fullstack-native-with-default/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-fullstack-native-with-default/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-simple-fullstack-with-default/Cargo.toml b/packages/cli-harnesses/harness-simple-fullstack-with-default/Cargo.toml new file mode 100644 index 000000000..65d66ced8 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-fullstack-with-default/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "harness-simple-fullstack-with-default" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +default=["web", "server"] +web=["dioxus/web"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-fullstack-with-default/src/main.rs b/packages/cli-harnesses/harness-simple-fullstack-with-default/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-fullstack-with-default/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-simple-fullstack/Cargo.toml b/packages/cli-harnesses/harness-simple-fullstack/Cargo.toml new file mode 100644 index 000000000..6b27e2810 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-fullstack/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "harness-simple-fullstack" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["fullstack"] } + + +[features] +web=["dioxus/web"] +server=["dioxus/server"] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-fullstack/src/main.rs b/packages/cli-harnesses/harness-simple-fullstack/src/main.rs new file mode 100644 index 000000000..f3c5d1182 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-fullstack/src/main.rs @@ -0,0 +1,4 @@ +use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} diff --git a/packages/cli-harnesses/harness-simple-mobile/Cargo.toml b/packages/cli-harnesses/harness-simple-mobile/Cargo.toml new file mode 100644 index 000000000..3bd122025 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-mobile/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "harness-simple-mobile" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["mobile"] } + + +[features] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-mobile/src/main.rs b/packages/cli-harnesses/harness-simple-mobile/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-mobile/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/packages/cli-harnesses/harness-simple-web/Cargo.toml b/packages/cli-harnesses/harness-simple-web/Cargo.toml new file mode 100644 index 000000000..f2bf291f9 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-web/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "harness-simple-web" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +dioxus = { workspace = true, features = ["web"] } + + +[features] + + \ No newline at end of file diff --git a/packages/cli-harnesses/harness-simple-web/src/main.rs b/packages/cli-harnesses/harness-simple-web/src/main.rs new file mode 100644 index 000000000..e7a11a969 --- /dev/null +++ b/packages/cli-harnesses/harness-simple-web/src/main.rs @@ -0,0 +1,3 @@ +fn main() { + println!("Hello, world!"); +} diff --git a/packages/cli-opt/src/hash.rs b/packages/cli-opt/src/hash.rs index 05163e050..3127bcfbd 100644 --- a/packages/cli-opt/src/hash.rs +++ b/packages/cli-opt/src/hash.rs @@ -47,6 +47,7 @@ fn hash_file(options: &AssetOptions, source: &Path) -> anyhow::Result // Create a hasher let mut hash = std::collections::hash_map::DefaultHasher::new(); options.hash(&mut hash); + // Hash the version of CLI opt hash.write(crate::build_info::version().as_bytes()); hash_file_with_options(options, source, &mut hash, false)?; @@ -70,6 +71,7 @@ pub(crate) fn hash_file_with_options( ResolvedAssetType::Scss(options) => { hash_scss(options, source, hasher)?; } + ResolvedAssetType::Js(options) => { hash_js(options, source, hasher, !in_folder)?; } @@ -82,12 +84,24 @@ pub(crate) fn hash_file_with_options( | ResolvedAssetType::File => { hash_file_contents(source, hasher)?; } + // Or the folder contents recursively ResolvedAssetType::Folder(_) => { - let files = std::fs::read_dir(source)?; - for file in files.flatten() { + for file in std::fs::read_dir(source)?.flatten() { let path = file.path(); - hash_file_with_options(options, &path, hasher, true)?; + hash_file_with_options( + // We can't reuse the options here since they contain the source variant which no + // longer applies to the nested files + // + // We don't hash nested files either since we assume the parent here is already being hashed + // (or being opted out of hashing) + &AssetOptions::builder() + .with_hash_suffix(false) + .into_asset_options(), + &path, + hasher, + true, + )?; } } } @@ -126,6 +140,7 @@ pub fn add_hash_to_asset(asset: &mut BundledAsset) { tracing::error!("Failed to get file name from path: {source}"); return; }; + // The output extension path is the extension set by the options // or the extension of the source file if we don't recognize the file let mut ext = asset.options().extension().map(Into::into).or_else(|| { @@ -163,7 +178,7 @@ pub fn add_hash_to_asset(asset: &mut BundledAsset) { *asset = BundledAsset::new(source, &bundled_path, options); } Err(err) => { - tracing::error!("Failed to hash asset: {err}"); + tracing::error!("Failed to hash asset {source}: {err}"); } } } diff --git a/packages/cli/src/build/assets.rs b/packages/cli/src/build/assets.rs index e67947f4c..4ca40f3a5 100644 --- a/packages/cli/src/build/assets.rs +++ b/packages/cli/src/build/assets.rs @@ -320,7 +320,10 @@ pub(crate) async fn extract_assets_from_file(path: impl AsRef) -> Result Result> { }) .collect::>(); + let mut exported = HashSet::new(); + // Wasm-bindgen will synthesize imports to satisfy its external calls. This facilitates things // like inline-js, snippets, and literally the `#[wasm_bindgen]` macro. All calls to JS are // just `extern "wbg"` blocks! @@ -1227,9 +1229,10 @@ pub fn prepare_wasm_base_module(bytes: &[u8]) -> Result> { let new_func_id = module.funcs.add_local(builder.local_func(locals)); - module - .exports - .add(&format!("__saved_wbg_{}", import.name), new_func_id); + let saved_name = format!("__saved_wbg_{}", import.name); + if exported.insert(saved_name.clone()) { + module.exports.add(&saved_name, new_func_id); + } make_indirect.push(new_func_id); } @@ -1254,9 +1257,10 @@ pub fn prepare_wasm_base_module(bytes: &[u8]) -> Result> { // // https://github.com/rustwasm/wasm-bindgen/blob/c35cc9369d5e0dc418986f7811a0dd702fb33ef9/crates/cli-support/src/wit/mod.rs#L1505 if name.starts_with("__wbindgen") { - module - .exports - .add(&format!("__saved_wbg_{name}"), func.id()); + let saved_name = format!("__saved_wbg_{}", name); + if exported.insert(saved_name.clone()) { + module.exports.add(&saved_name, func.id()); + } } // This is basically `--export-all` but designed to work around wasm-bindgen not properly gc-ing diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 89106691c..f5ba458ab 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -321,7 +321,7 @@ use crate::{ AndroidTools, BuildContext, BundleFormat, DioxusConfig, Error, LinkAction, LinkerFlavor, - Renderer, Result, RustcArgs, TargetAlias, TargetArgs, TraceSrc, WasmBindgen, WasmOptConfig, + Platform, Renderer, Result, RustcArgs, TargetArgs, TraceSrc, WasmBindgen, WasmOptConfig, Workspace, DX_RUSTC_WRAPPER_ENV_VAR, }; use anyhow::{bail, Context}; @@ -348,7 +348,7 @@ use std::{ }, time::{SystemTime, UNIX_EPOCH}, }; -use target_lexicon::{Architecture, Environment, OperatingSystem, Triple}; +use target_lexicon::{Architecture, OperatingSystem, Triple}; use tempfile::TempDir; use tokio::{io::AsyncBufReadExt, process::Command}; use uuid::Uuid; @@ -549,105 +549,257 @@ impl BuildRequest { })? .clone(); - // The crate might enable multiple platforms or no platforms at - // We collect all the platforms it enables first and then select based on the --platform arg - let enabled_renderers = - Self::enabled_cargo_toml_renderers(main_package, args.no_default_features); - let using_dioxus_explicitly = main_package - .dependencies - .iter() - .any(|dep| dep.name == "dioxus"); - - let mut features = args.features.clone(); - let mut no_default_features = args.no_default_features; - - // Apply the platform alias if present - let mut target_alias = args.target_alias; - let mut renderer = args.renderer; - let mut bundle = args.bundle; - if let Some(platform) = args.platform { - let (default_target, default_renderer, default_bundle) = platform.into_triple(); - target_alias = target_alias.or(default_target); - renderer.renderer = Some(renderer.renderer.unwrap_or(default_renderer)); - bundle = Some(bundle.unwrap_or(default_bundle)); - } - - let mut renderer: Option = match renderer.into() { - Some(renderer) => match enabled_renderers.len() { - 0 => Some(renderer), - - // The user passed --platform XYZ but already has `default = ["ABC"]` in their Cargo.toml or dioxus = { features = ["abc"] } - // We want to strip out the default platform and use the one they passed, setting no-default-features - _ => { - features.extend(Self::rendererless_features(main_package)); - no_default_features = true; - Some(renderer) - } - }, - None if !using_dioxus_explicitly => None, - None => match enabled_renderers.as_slice() { - [] => None, // Wait until we resolve everything else first then we will resolve it from the triple - [(renderer, feature)] => { - let targeting_mobile = match (&args.target, target_alias) { - (_, TargetAlias::Android | TargetAlias::Ios) => true, - (Some(target), _) => { - matches!( - (target.environment, target.operating_system), - (Environment::Android, OperatingSystem::IOS(_)) - ) - } - _ => false, - }; - // The renderer usually maps directly to a feature flag, but the mobile crate is an exception. - // If we see the `desktop` renderer in the default features, but the user is targeting mobile, - // we should remove the default `desktop` feature, but still target webview. The feature selection - // logic below will handle adding back in the `mobile` feature flag - if targeting_mobile && feature == "desktop" { - features.extend(Self::rendererless_features(main_package)); - no_default_features = true; - } - Some(*renderer) - } - _ => { - bail!( - "Multiple platforms enabled in Cargo.toml. Please specify a platform with `--web`, `--webview`, or `--native` or set a default platform in Cargo.toml" - ); - } - }, - }; - // We usually use the simulator unless --device is passed *or* a device is detected by probing. // For now, though, since we don't have probing, it just defaults to false // Tools like xcrun/adb can detect devices let device = args.device.clone(); - // Resolve the target alias and renderer into a concrete target alias. - // If the user didn't pass a platform, but we have a renderer, get the default platform for that renderer - if let (TargetAlias::Unknown, Some(renderer)) = (target_alias, renderer) { - target_alias = renderer.default_platform(); + let using_dioxus_explicitly = main_package + .dependencies + .iter() + .any(|dep| dep.name == "dioxus"); + + /* + Determine which features, triple, profile, etc to pass to the build. + + Most of the time, users should use `dx serve --` where the platform name directly + corresponds to the feature in their cargo.toml. So, + - `dx serve --web` will enable the `web` feature + - `dx serve --mobile` will enable the `mobile` feature + - `dx serve --desktop` will enable the `desktop` feature + + In this case, we set default-features to false and then add back the default features that + aren't renderers, and then add the feature for the given renderer (ie web/desktop/mobile). + We call this "no-default-features-stripped." + + There are a few cases where the user doesn't need to pass a platform. + - they selected one via `dioxus = { features = ["web"] }` + - they have a single platform in their default features `default = ["web"]` + - there is only a single non-server renderer as a feature `web = ["dioxus/web"], server = ["dioxus/server"]` + - they compose the super triple via triple + bundleformat + features + + Note that we only use the names of the features to correspond with the platform. + Platforms are "super triples", meaning they contain information about + - bundle format + - target triple + - how to serve + - enabled features + + By default, the --platform presets correspond to: + - web: bundle(web), triple(wasm32), serve(http-serve), features("web") + - desktop: alias to mac/win/linux + - mac: bundle(mac), triple(host), serve(appbundle-open), features("desktop") + - windows: bundle(exefolder), triple(host), serve(run-exe), features("desktop") + - linux: bundle(appimage), triple(host), serve(run-exe), features("desktop") + - ios: bundle(ios), triple(arm64-apple-ios), serve(ios-simulator/xcrun), features("mobile") + - android: bundle(android), triple(arm64-apple-ios), serve(android-emulator/adb), features("mobile") + - server: bundle(server), triple(host), serve(run-exe), features("server") (and disables the client) + - liveview: bundle(liveview), triple(host), serve(run-exe), features("liveview") + - unknown: + + Fullstack usage is inferred from the presence of the fullstack feature or --fullstack. + */ + let mut features = args.features.clone(); + let mut no_default_features = args.no_default_features; + let mut triple = args.target.clone(); + let mut renderer = args.renderer; + let mut bundle_format = args.bundle; + let mut platform = args.platform; + + // the crate might be selecting renderers but the user also passes a renderer. this is weird + // ie dioxus = { features = ["web"] } but also --platform desktop + // anyways, we collect it here in the event we need it if platform is not specified. + let dioxus_direct_renderer = Self::renderer_enabled_by_dioxus_dependency(main_package); + let known_features_as_renderers = Self::features_that_enable_renderers(main_package); + + // The crate might enable multiple platforms or no platforms at + // We collect all the platforms it enables first and then select based on the --platform arg + let enabled_renderers = if no_default_features { + vec![] + } else { + Self::enabled_cargo_toml_default_features_renderers(main_package) }; - // We want a real triple to build with, so we'll autodetect it if it's not provided - // The triple ends up being a source of truth for us later hence all this work to figure it out - let triple = match (args.target.clone(), target_alias) { - // If there is an explicit target, use it - (Some(target), _) => target, - // If there is a platform, use it to determine the target triple - (None, platform) => platform.into_target(device.is_some(), &workspace).await?, - }; + // Try the easy autodetects. + // - if the user has `dioxus = { features = ["web"] }` + // - if the `default =["web"]` or `default = ["dioxus/web"]` + // - if there's only one non-server platform ie `web = ["dioxus/web"], server = ["dioxus/server"]` + // Only do this if we're explicitly using dioxus + if matches!(platform, Platform::Unknown) && using_dioxus_explicitly { + let auto = dioxus_direct_renderer + .or_else(|| { + if enabled_renderers.len() == 1 { + Some(enabled_renderers[0].clone()) + } else { + None + } + }) + .or_else(|| { + let non_server_features = known_features_as_renderers + .iter() + .filter(|f| f.1.as_str() != "server") + .collect::>(); + if non_server_features.len() == 1 { + Some(non_server_features[0].clone()) + } else { + None + } + }); - // If the user didn't pass a renderer, and we didn't find a good default renderer, but they are using dioxus explicitly, - // then we can try to guess the renderer based on the target triple. - if renderer.is_none() && using_dioxus_explicitly { - renderer = Some(Renderer::from_target(&triple)); + if let Some((direct, feature)) = auto { + match direct { + _ if feature == "mobile" || feature == "dioxus/mobile" => { + bail!( + "Could not autodetect mobile platform. Use --ios or --android instead." + ); + } + Renderer::Webview | Renderer::Native => { + if cfg!(target_os = "macos") { + platform = Platform::MacOS; + } else if cfg!(target_os = "linux") { + platform = Platform::Linux; + } else if cfg!(target_os = "windows") { + platform = Platform::Windows; + } + } + Renderer::Server => platform = Platform::Server, + Renderer::Liveview => platform = Platform::Liveview, + Renderer::Web => platform = Platform::Web, + } + renderer = renderer.or(Some(direct)); + } } - // Resolve the bundle format based on the combination of the target triple and renderer - let bundle = match bundle { - // If there is an explicit bundle format, use it - Some(bundle) => bundle, - // Otherwise guess a bundle format based on the target triple and renderer - None => BundleFormat::from_target(&triple, renderer)?, + // Set the super triple from the platform if it's provided. + // Otherwise, we attempt to guess it from the rest of their inputs. + match platform { + Platform::Unknown => {} + + Platform::Web => { + if main_package.features.contains_key("web") && renderer.is_none() { + features.push("web".into()); + } + renderer = renderer.or(Some(Renderer::Web)); + bundle_format = bundle_format.or(Some(BundleFormat::Web)); + triple = triple.or(Some("wasm32-unknown-unknown".parse()?)); + no_default_features = true; + } + Platform::MacOS => { + if main_package.features.contains_key("desktop") && renderer.is_none() { + features.push("desktop".into()); + } + renderer = renderer.or(Some(Renderer::Webview)); + bundle_format = bundle_format.or(Some(BundleFormat::MacOS)); + triple = triple.or(Some(Triple::host())); + no_default_features = true; + } + Platform::Windows => { + if main_package.features.contains_key("desktop") && renderer.is_none() { + features.push("desktop".into()); + } + renderer = renderer.or(Some(Renderer::Webview)); + bundle_format = bundle_format.or(Some(BundleFormat::Windows)); + triple = triple.or(Some(Triple::host())); + no_default_features = true; + } + Platform::Linux => { + if main_package.features.contains_key("desktop") && renderer.is_none() { + features.push("desktop".into()); + } + renderer = renderer.or(Some(Renderer::Webview)); + bundle_format = bundle_format.or(Some(BundleFormat::Linux)); + triple = triple.or(Some(Triple::host())); + no_default_features = true; + } + Platform::Ios => { + if main_package.features.contains_key("mobile") && renderer.is_none() { + features.push("mobile".into()); + } + renderer = renderer.or(Some(Renderer::Webview)); + bundle_format = bundle_format.or(Some(BundleFormat::Ios)); + no_default_features = true; + match device.is_some() { + // If targeting device, we want to build for the device which is always aarch64 + true => triple = triple.or(Some("aarch64-apple-ios".parse()?)), + + // If the host is aarch64, we assume the user wants to build for iOS simulator + false if matches!(Triple::host().architecture, Architecture::Aarch64(_)) => { + triple = triple.or(Some("aarch64-apple-ios-sim".parse()?)) + } + + // Otherwise, it's the x86_64 simulator, which is just x86_64-apple-ios + _ => triple = triple.or(Some("x86_64-apple-ios".parse()?)), + } + } + Platform::Android => { + if main_package.features.contains_key("mobile") && renderer.is_none() { + features.push("mobile".into()); + } + + renderer = renderer.or(Some(Renderer::Webview)); + bundle_format = bundle_format.or(Some(BundleFormat::Android)); + no_default_features = true; + + // maybe probe adb? + if let Some(_device_name) = device.as_ref() { + if triple.is_none() { + triple = Some( + crate::get_android_tools() + .context("Failed to get android tools")? + .autodetect_android_device_triple() + .await, + ); + } + } else { + triple = triple.or(Some({ + match Triple::host().architecture { + Architecture::X86_32(_) => "i686-linux-android".parse()?, + Architecture::X86_64 => "x86_64-linux-android".parse()?, + Architecture::Aarch64(_) => "aarch64-linux-android".parse()?, + _ => "aarch64-linux-android".parse()?, + } + })); + } + } + Platform::Server => { + if main_package.features.contains_key("server") && renderer.is_none() { + features.push("server".into()); + } + renderer = renderer.or(Some(Renderer::Server)); + bundle_format = bundle_format.or(Some(BundleFormat::Server)); + triple = triple.or(Some(Triple::host())); + no_default_features = true; + } + Platform::Liveview => { + if main_package.features.contains_key("liveview") && renderer.is_none() { + features.push("liveview".into()); + } + renderer = renderer.or(Some(Renderer::Liveview)); + bundle_format = bundle_format.or(Some(BundleFormat::Server)); + triple = triple.or(Some(Triple::host())); + no_default_features = true; + } + } + + // If no default features are enabled, we need to add the rendererless features + if no_default_features { + features.extend(Self::rendererless_features(main_package)); + features.dedup(); + features.sort(); + } + + // The triple will be the triple passed or the host if using dioxus. + let triple = if using_dioxus_explicitly { + triple.context("Could not automatically detect target triple")? + } else { + triple.unwrap_or(Triple::host()) + }; + + // The bundle format will be the bundle format passed or the host. + let bundle = if using_dioxus_explicitly { + bundle_format.context("Could not automatically detect bundle format")? + } else { + bundle_format.unwrap_or(BundleFormat::host()) }; // Add any features required to turn on the client @@ -657,6 +809,7 @@ impl BuildRequest { &triple, renderer, )); + features.dedup(); } // Set the profile of the build if it's not already set @@ -714,6 +867,14 @@ impl BuildRequest { ]); } + // automatically set the getrandom backend for web builds if the user requested it + if matches!(bundle, BundleFormat::Web) && args.wasm_js_cfg { + rustflags.flags.extend( + cargo_config2::Flags::from_space_separated(r#"--cfg getrandom_backend="wasm_js""#) + .flags, + ); + } + // Make sure to take into account the RUSTFLAGS env var and the CARGO_TARGET__RUSTFLAGS for env in [ "RUSTFLAGS".to_string(), @@ -1032,7 +1193,11 @@ impl BuildRequest { // since that is a really bad user experience. Message::BuildFinished(finished) => { if !finished.success { - bail!("cargo build finished with errors.") + bail!( + "cargo build finished with errors for target: {} [{}]", + self.main_target, + self.triple + ); } } _ => {} @@ -1190,7 +1355,6 @@ impl BuildRequest { async fn write_frameworks(&self, _ctx: &BuildContext, direct_rustc: &RustcArgs) -> Result<()> { let framework_dir = self.frameworks_folder(); - _ = std::fs::create_dir_all(&framework_dir); // We have some prebuilt stuff that needs to be copied into the framework dir let openssl_dir = AndroidTools::openssl_lib_dir(&self.triple); @@ -1208,6 +1372,8 @@ impl BuildRequest { tracing::debug!("Copying framework from {from:?} to {to:?}"); + _ = std::fs::create_dir_all(&framework_dir); + // in dev and on normal oses, we want to symlink the file // otherwise, just copy it (since in release you want to distribute the framework) if cfg!(any(windows, unix)) && !self.release { @@ -1225,6 +1391,11 @@ impl BuildRequest { } } + // Always create the framework dir for android + if self.bundle == BundleFormat::Android { + _ = std::fs::create_dir_all(&framework_dir); + } + // On android, the c++_shared flag means we need to copy the libc++_shared.so precompiled // library to the jniLibs folder if self.bundle == BundleFormat::Android && arg.contains("-lc++_shared") { @@ -3352,13 +3523,9 @@ impl BuildRequest { } } - /// Return the platforms that are enabled for the package - /// - /// Ideally only one platform is enabled but we need to be able to - pub(crate) fn enabled_cargo_toml_renderers( + pub(crate) fn renderer_enabled_by_dioxus_dependency( package: &krates::cm::Package, - no_default_features: bool, - ) -> Vec<(Renderer, String)> { + ) -> Option<(Renderer, String)> { let mut renderers = vec![]; // Attempt to discover the platform directly from the dioxus dependency @@ -3367,13 +3534,40 @@ impl BuildRequest { // dioxus = { features = ["web"] } // if let Some(dxs) = package.dependencies.iter().find(|dep| dep.name == "dioxus") { - for f in dxs.features.iter() { - if let Some(renderer) = Renderer::autodetect_from_cargo_feature(f) { - renderers.push((renderer, f.clone())); + for feature in dxs.features.iter() { + if let Some(renderer) = Renderer::autodetect_from_cargo_feature(feature) { + renderers.push((renderer, format!("dioxus/{}", feature))); } } } + if renderers.len() != 1 { + return None; + } + + Some(renderers[0].clone()) + } + + pub(crate) fn features_that_enable_renderers( + package: &krates::cm::Package, + ) -> Vec<(Renderer, String)> { + package + .features + .keys() + .filter_map(|key| { + Renderer::autodetect_from_cargo_feature(key).map(|v| (v, key.to_string())) + }) + .collect() + } + + /// Return the platforms that are enabled for the package only from the default features + /// + /// Ideally only one platform is enabled but we need to be able to + pub(crate) fn enabled_cargo_toml_default_features_renderers( + package: &krates::cm::Package, + ) -> Vec<(Renderer, String)> { + let mut renderers = vec![]; + // Start searching through the default features // // [features] @@ -3384,10 +3578,6 @@ impl BuildRequest { // [features] // default = ["web"] // web = ["dioxus/web"] - if no_default_features { - return renderers; - } - let Some(default) = package.features.get("default") else { return renderers; }; @@ -4396,7 +4586,7 @@ __wbg_init({{module_or_path: "/{}/{wasm_path}"}}).then((wasm) => {{ /// /// This might stop working if/when cargo stabilizes contents-based fingerprinting. fn bust_fingerprint(&self, ctx: &BuildContext) -> Result<()> { - if matches!(ctx.mode, BuildMode::Fat | BuildMode::Base { run: true }) { + if matches!(ctx.mode, BuildMode::Fat) { // `dx` compiles everything with `--target` which ends up with a structure like: // target///.fingerprint/- // diff --git a/packages/cli/src/build/tools.rs b/packages/cli/src/build/tools.rs index 99a3765b1..a0121fdf6 100644 --- a/packages/cli/src/build/tools.rs +++ b/packages/cli/src/build/tools.rs @@ -3,8 +3,7 @@ use anyhow::Context; use itertools::Itertools; use std::{path::PathBuf, sync::Arc}; use target_lexicon::{ - Aarch64Architecture, Architecture, ArmArchitecture, Environment, OperatingSystem, Triple, - X86_32Architecture, + Aarch64Architecture, Architecture, ArmArchitecture, Triple, X86_32Architecture, }; use tokio::process::Command; @@ -236,9 +235,6 @@ impl AndroidTools { // - We try to match the architecture unless otherwise specified. This is because // emulators that match the host arch are usually faster. let mut triple = "aarch64-linux-android".parse::().unwrap(); - triple.operating_system = OperatingSystem::Linux; - triple.environment = Environment::Android; - triple.architecture = target_lexicon::HOST.architecture; // TODO: Wire this up with --device flag. (add `-s serial`` flag before `shell` arg) let output = Command::new(&self.adb) diff --git a/packages/cli/src/cli/build.rs b/packages/cli/src/cli/build.rs index 67efc708e..19cc87d35 100644 --- a/packages/cli/src/cli/build.rs +++ b/packages/cli/src/cli/build.rs @@ -1,7 +1,8 @@ use dioxus_dx_wire_format::StructuredBuildArtifacts; use crate::{ - cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildMode, BuildRequest, TargetArgs, Workspace, + cli::*, Anonymized, AppBuilder, BuildArtifacts, BuildMode, BuildRequest, BundleFormat, + TargetArgs, Workspace, }; /// Build the Rust Dioxus app and all of its assets. @@ -116,14 +117,22 @@ impl CommandWithPlatformOverrides { Some(server_args) => { // Make sure we set the client target here so @server knows to place its output into the @client target directory. server_args.build_arguments.client_target = Some(client.main_target.clone()); + + // We don't override anything except the bundle format since @server usually implies a server output + server_args.build_arguments.bundle = server_args + .build_arguments + .bundle + .or(Some(BundleFormat::Server)); + server = Some( BuildRequest::new(&server_args.build_arguments, workspace.clone()).await?, ); } None => { let mut args = self.shared.build_arguments.clone(); - args.platform = Some(crate::Platform::Server); - args.renderer.renderer = Some(crate::Renderer::Server); + args.platform = crate::Platform::Server; + args.renderer = Some(crate::Renderer::Server); + args.bundle = Some(crate::BundleFormat::Server); args.target = Some(target_lexicon::Triple::host()); server = Some(BuildRequest::new(&args, workspace.clone()).await?); } diff --git a/packages/cli/src/cli/link.rs b/packages/cli/src/cli/link.rs index 0e121435f..08539718a 100644 --- a/packages/cli/src/cli/link.rs +++ b/packages/cli/src/cli/link.rs @@ -190,8 +190,8 @@ impl LinkAction { target_lexicon::BinaryFormat::Macho => object::BinaryFormat::MachO, target_lexicon::BinaryFormat::Wasm => object::BinaryFormat::Wasm, target_lexicon::BinaryFormat::Xcoff => object::BinaryFormat::Xcoff, - target_lexicon::BinaryFormat::Unknown => todo!(), - _ => todo!("Binary format not supported"), + target_lexicon::BinaryFormat::Unknown => unimplemented!(), + _ => unimplemented!("Binary format not supported"), }; let arch = match triple.architecture { @@ -202,13 +202,13 @@ impl LinkAction { target_lexicon::Architecture::Aarch64(_) => object::Architecture::Aarch64, target_lexicon::Architecture::LoongArch64 => object::Architecture::LoongArch64, target_lexicon::Architecture::Unknown => object::Architecture::Unknown, - _ => todo!("Architecture not supported"), + _ => unimplemented!("Architecture not supported"), }; let endian = match triple.endianness() { Ok(target_lexicon::Endianness::Little) => object::Endianness::Little, Ok(target_lexicon::Endianness::Big) => object::Endianness::Big, - Err(_) => todo!("Endianness not supported"), + Err(_) => unimplemented!("Endianness not supported"), }; let bytes = object::write::Object::new(format, arch, endian) diff --git a/packages/cli/src/cli/run.rs b/packages/cli/src/cli/run.rs index c0cf71963..830043b1c 100644 --- a/packages/cli/src/cli/run.rs +++ b/packages/cli/src/cli/run.rs @@ -102,7 +102,9 @@ impl RunArgs { path, } => tracing::info!( "[{bundle_format}] Copying asset {} ({current}/{total})", - path.display(), + path.file_name() + .map(|f| f.to_string_lossy()) + .unwrap_or_default(), ), BuildStage::Bundling => { tracing::info!("[{bundle_format}] Bundling app") diff --git a/packages/cli/src/cli/serve.rs b/packages/cli/src/cli/serve.rs index d0e235fbb..0802ff78d 100644 --- a/packages/cli/src/cli/serve.rs +++ b/packages/cli/src/cli/serve.rs @@ -61,7 +61,7 @@ pub(crate) struct ServeArgs { pub(crate) hot_patch: bool, /// Watch the filesystem for changes and trigger a rebuild [default: true] - #[clap(long, default_missing_value = "true")] + #[clap(long, default_missing_value = "true", num_args=0..=1)] pub(crate) watch: Option, /// Exit the CLI after running into an error. This is mainly used to test hot patching internally @@ -92,10 +92,6 @@ impl ServeArgs { /// /// We also set up proper panic handling since the TUI has a tendency to corrupt the terminal. pub(crate) async fn serve(self, tracer: &TraceController) -> Result { - if std::env::var("RUST_BACKTRACE").is_err() { - std::env::set_var("RUST_BACKTRACE", "1"); - } - // Redirect all logging the cli logger - if there's any pending after a panic, we flush it let is_interactive_tty = self.is_interactive_tty(); if is_interactive_tty { diff --git a/packages/cli/src/cli/target.rs b/packages/cli/src/cli/target.rs index 7829b4449..ee3a8a4a7 100644 --- a/packages/cli/src/cli/target.rs +++ b/packages/cli/src/cli/target.rs @@ -1,8 +1,8 @@ -use crate::cli::*; use crate::BundleFormat; use crate::Platform; -use crate::RendererArg; -use crate::TargetAlias; +use crate::{cli::*, Renderer}; +// use crate::RendererArg; +// use crate::PlatformAlias; use target_lexicon::Triple; const HELP_HEADING: &str = "Target Options"; @@ -10,18 +10,6 @@ const HELP_HEADING: &str = "Target Options"; /// A single target to build for #[derive(Clone, Debug, Default, Deserialize, Parser)] pub(crate) struct TargetArgs { - /// The target alias to use for this build. Supports wasm, macos, windows, linux, ios, android, and host [default: "host"] - #[clap(flatten)] - pub(crate) target_alias: TargetAlias, - - /// Build renderer: supports web, webview, native, server, and liveview - #[clap(flatten)] - pub(crate) renderer: RendererArg, - - /// The bundle format to target for the build: supports web, macos, windows, linux, ios, android, and server - #[clap(long, value_enum, help_heading = HELP_HEADING)] - pub(crate) bundle: Option, - /// Build platform: supports Web, MacOS, Windows, Linux, iOS, Android, and Server /// /// The platform implies a combination of the target alias, renderer, and bundle format flags. @@ -29,8 +17,16 @@ pub(crate) struct TargetArgs { /// You should generally prefer to use the `--web`, `--webview`, or `--native` flags to set the renderer /// or the `--wasm`, `--macos`, `--windows`, `--linux`, `--ios`, or `--android` flags to set the target alias /// instead of this flag. The renderer, target alias, and bundle format will be inferred if you only pass one. + #[clap(flatten)] + pub(crate) platform: Platform, + + /// Which renderer to use? By default, this is usually inferred from the platform. #[clap(long, value_enum, help_heading = HELP_HEADING)] - pub(crate) platform: Option, + pub(crate) renderer: Option, + + /// The bundle format to target for the build: supports web, macos, windows, linux, ios, android, and server + #[clap(long, value_enum, help_heading = HELP_HEADING)] + pub(crate) bundle: Option, /// Build in release mode [default: false] #[clap(long, short, help_heading = HELP_HEADING)] @@ -112,7 +108,7 @@ pub(crate) struct TargetArgs { /// simulator. If the device name is passed, we will upload to that device instead. /// /// This performs a search among devices, and fuzzy matches might be found. - #[arg(long, default_missing_value=None, num_args=0..=1, help_heading = HELP_HEADING)] + #[arg(long, default_missing_value=Some("".into()), num_args=0..=1)] pub(crate) device: Option, /// The base path the build will fetch assets relative to. This will override the @@ -153,12 +149,15 @@ pub(crate) struct TargetArgs { /// when merging `@client and @server` targets together. #[clap(long, help_heading = HELP_HEADING)] pub(crate) client_target: Option, + + /// Automatically pass `--features=js_cfg` when building for wasm targets. This is enabled by default. + #[clap(long, default_value_t = true, help_heading = HELP_HEADING, num_args = 0..=1)] + pub(crate) wasm_js_cfg: bool, } impl Anonymized for TargetArgs { fn anonymized(&self) -> Value { json! {{ - "target_alias": self.target_alias, "renderer": self.renderer, "bundle": self.bundle, "platform": self.platform, diff --git a/packages/cli/src/main.rs b/packages/cli/src/main.rs index d6c063363..a406873cf 100644 --- a/packages/cli/src/main.rs +++ b/packages/cli/src/main.rs @@ -18,6 +18,7 @@ mod rustcwrapper; mod serve; mod settings; mod tailwind; +mod test_harnesses; mod wasm_bindgen; mod wasm_opt; mod workspace; diff --git a/packages/cli/src/platform.rs b/packages/cli/src/platform.rs index d9aa092b3..f3d483f0b 100644 --- a/packages/cli/src/platform.rs +++ b/packages/cli/src/platform.rs @@ -1,82 +1,106 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use clap::{arg, ArgMatches, Args, FromArgMatches}; use serde::{Deserialize, Serialize}; use std::fmt::Display; use std::str::FromStr; -use target_lexicon::{Architecture, Environment, OperatingSystem, Triple}; - -use crate::Workspace; +use target_lexicon::{Environment, OperatingSystem, Triple}; #[derive( Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Debug, Default, )] #[non_exhaustive] -pub(crate) enum TargetAlias { - /// Targeting the WASM architecture - Wasm, +pub(crate) enum Platform { + /// Alias for `--target wasm32-unknown-unknown --renderer websys --bundle-format web` + #[serde(rename = "web")] + Web, - /// Targeting macos desktop + /// Alias for `--target --renderer webview --bundle-format macos` + #[serde(rename = "macos")] MacOS, - /// Targeting windows desktop + /// Alias for `--target --renderer webview --bundle-format windows` + #[serde(rename = "windows")] Windows, - /// Targeting linux desktop + /// Alias for `--target --renderer webview --bundle-format linux` + #[serde(rename = "linux")] Linux, - /// Targeting the ios platform - /// - /// Can't work properly if you're not building from an Apple device. + /// Alias for `--target --renderer webview --bundle-format ios` + #[serde(rename = "ios")] Ios, - /// Targeting the android platform + /// Alias for `--target --renderer webview --bundle-format android` + #[serde(rename = "android")] Android, - /// Targeting the current target - Host, + /// Alias for `--target --renderer ssr --bundle-format server` + #[serde(rename = "server")] + Server, - /// An unknown target platform + /// Alias for `--target --renderer liveview --bundle-format server` + #[serde(rename = "liveview")] + Liveview, + + /// No platform was specified, so the CLI is free to choose the best one. #[default] Unknown, } -impl Args for TargetAlias { +impl Args for Platform { + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + Self::augment_args(cmd) + } + fn augment_args(cmd: clap::Command) -> clap::Command { - const HELP_HEADING: &str = "Target Alias"; - cmd.arg(arg!(--wasm "Target the wasm triple").help_heading(HELP_HEADING)) - .arg(arg!(--macos "Target the macos triple").help_heading(HELP_HEADING)) - .arg(arg!(--windows "Target the windows triple").help_heading(HELP_HEADING)) - .arg(arg!(--linux "Target the linux triple").help_heading(HELP_HEADING)) - .arg(arg!(--ios "Target the ios triple").help_heading(HELP_HEADING)) - .arg(arg!(--android "Target the android triple").help_heading(HELP_HEADING)) - .arg(arg!(--host "Target the host triple").help_heading(HELP_HEADING)) - .arg(arg!(--desktop "Target the host triple").help_heading(HELP_HEADING)) + const HELP_HEADING: &str = "Platform"; + cmd.arg(arg!(--web "Target a web app").help_heading(HELP_HEADING)) + .arg(arg!(--desktop "Target a desktop app").help_heading(HELP_HEADING)) + .arg(arg!(--macos "Target a macos desktop app").help_heading(HELP_HEADING)) + .arg(arg!(--windows "Target a windows desktop app").help_heading(HELP_HEADING)) + .arg(arg!(--linux "Target a linux desktop app").help_heading(HELP_HEADING)) + .arg(arg!(--ios "Target an ios app").help_heading(HELP_HEADING)) + .arg(arg!(--android "Target an android app").help_heading(HELP_HEADING)) + .arg(arg!(--server "Target a server build").help_heading(HELP_HEADING)) + .arg(arg!(--liveview "Target a liveview build").help_heading(HELP_HEADING)) .group( clap::ArgGroup::new("target_alias") .args([ - "wasm", "macos", "windows", "linux", "ios", "android", "host", "host", + "web", "desktop", "macos", "windows", "linux", "ios", "android", "server", + "liveview", ]) .multiple(false) .required(false), ) } - - fn augment_args_for_update(cmd: clap::Command) -> clap::Command { - Self::augment_args(cmd) - } } -impl FromArgMatches for TargetAlias { +impl FromArgMatches for Platform { fn from_arg_matches(matches: &ArgMatches) -> Result { if let Some(platform) = matches.get_one::("target_alias") { match platform.as_str() { - "wasm" => Ok(Self::Wasm), + "web" => Ok(Self::Web), + "desktop" => { + if cfg!(target_os = "macos") { + Ok(Self::MacOS) + } else if cfg!(target_os = "windows") { + Ok(Self::Windows) + } else if cfg!(unix) { + Ok(Self::Linux) + } else { + Err(clap::Error::raw( + clap::error::ErrorKind::InvalidValue, + "Desktop alias is not supported on this platform", + )) + } + } "macos" => Ok(Self::MacOS), "windows" => Ok(Self::Windows), "linux" => Ok(Self::Linux), "ios" => Ok(Self::Ios), "android" => Ok(Self::Android), - "host" => Ok(Self::Host), + "liveview" => Ok(Self::Liveview), + "server" => Ok(Self::Server), _ => Err(clap::Error::raw( clap::error::ErrorKind::InvalidValue, format!("Unknown target alias: {platform}"), @@ -86,134 +110,26 @@ impl FromArgMatches for TargetAlias { Ok(Self::Unknown) } } + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> { *self = Self::from_arg_matches(matches)?; Ok(()) } } -impl TargetAlias { - #[cfg(target_os = "macos")] - pub(crate) const TARGET_PLATFORM: Option = Some(Self::MacOS); - #[cfg(target_os = "windows")] - pub(crate) const TARGET_PLATFORM: Option = Some(Self::Windows); - #[cfg(target_os = "linux")] - pub(crate) const TARGET_PLATFORM: Option = Some(Self::Linux); - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - pub(crate) const TARGET_PLATFORM: Option = None; - - pub(crate) async fn into_target(self, device: bool, workspace: &Workspace) -> Result { - match self { - // Generally just use the host's triple for native executables unless specified otherwise - Self::MacOS | Self::Windows | Self::Linux | Self::Host | Self::Unknown => { - Ok(Triple::host()) - } - - // We currently assume unknown-unknown for web, but we might want to eventually - // support emscripten - Self::Wasm => Ok("wasm32-unknown-unknown".parse()?), - - // For iOS we should prefer the actual architecture for the simulator, but in lieu of actually - // figuring that out, we'll assume aarch64 on m-series and x86_64 otherwise - Self::Ios => { - // use the host's architecture and sim if --device is passed - use target_lexicon::{Architecture, HOST}; - let triple_str = match HOST.architecture { - Architecture::Aarch64(_) if device => "aarch64-apple-ios", - Architecture::Aarch64(_) => "aarch64-apple-ios-sim", - _ if device => "x86_64-apple-ios", - _ => "x86_64-apple-ios", - }; - Ok(triple_str.parse()?) - } - - // Same idea with android but we figure out the connected device using adb - Self::Android => Ok(workspace - .android_tools()? - .autodetect_android_device_triple() - .await), - } - } - - pub(crate) fn or(self, other: Self) -> Self { - if self == Self::Unknown { - other - } else { - self - } - } -} - #[derive( - Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Debug, Default, + Copy, + Clone, + Hash, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + Debug, + clap::ValueEnum, )] -pub(crate) struct RendererArg { - pub(crate) renderer: Option, -} - -impl Args for RendererArg { - fn augment_args(cmd: clap::Command) -> clap::Command { - const HELP_HEADING: &str = "Renderer"; - cmd.arg(arg!(--web "Enable the dioxus web renderer").help_heading(HELP_HEADING)) - .arg(arg!(--webview "Enable the dioxus webview renderer").help_heading(HELP_HEADING)) - .arg(arg!(--native "Enable the dioxus native renderer").help_heading(HELP_HEADING)) - .arg(arg!(--server "Enable the dioxus server renderer").help_heading(HELP_HEADING)) - .arg(arg!(--liveview "Enable the dioxus liveview renderer").help_heading(HELP_HEADING)) - .group( - clap::ArgGroup::new("renderer") - .args(["web", "webview", "native", "server", "liveview"]) - .multiple(false) - .required(false), - ) - } - - fn augment_args_for_update(cmd: clap::Command) -> clap::Command { - Self::augment_args(cmd) - } -} - -impl FromArgMatches for RendererArg { - fn from_arg_matches(matches: &ArgMatches) -> Result { - if let Some(renderer) = matches.get_one::("renderer") { - match renderer.as_str() { - "web" => Ok(Self { - renderer: Some(Renderer::Web), - }), - "webview" => Ok(Self { - renderer: Some(Renderer::Webview), - }), - "native" => Ok(Self { - renderer: Some(Renderer::Native), - }), - "server" => Ok(Self { - renderer: Some(Renderer::Server), - }), - "liveview" => Ok(Self { - renderer: Some(Renderer::Liveview), - }), - _ => Err(clap::Error::raw( - clap::error::ErrorKind::InvalidValue, - format!("Unknown platform: {renderer}"), - )), - } - } else { - Ok(Self { renderer: None }) - } - } - - fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), clap::Error> { - *self = Self::from_arg_matches(matches)?; - Ok(()) - } -} - -impl From for Option { - fn from(val: RendererArg) -> Self { - val.renderer - } -} - -#[derive(Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Debug)] #[non_exhaustive] pub(crate) enum Renderer { /// Targeting webview renderer @@ -231,7 +147,7 @@ pub(crate) enum Renderer { /// Targeting the static generation platform using SSR and Dioxus-Fullstack Liveview, - /// Targeting the web renderer + /// Targeting the web-sys renderer Web, } @@ -260,24 +176,6 @@ impl Renderer { _ => None, } } - - pub(crate) fn default_platform(&self) -> TargetAlias { - match self { - Renderer::Webview | Renderer::Native | Renderer::Server | Renderer::Liveview => { - TargetAlias::TARGET_PLATFORM.unwrap() - } - Renderer::Web => TargetAlias::Wasm, - } - } - - pub(crate) fn from_target(triple: &Triple) -> Self { - match triple.architecture { - // Assume any wasm32 or wasm64 target is a web target - Architecture::Wasm32 | Architecture::Wasm64 => Self::Web, - // Otherwise, assume webview for native targets - _ => Self::Webview, - } - } } #[derive(Debug)] @@ -357,57 +255,19 @@ pub(crate) enum BundleFormat { Android, } -#[derive(Debug)] -pub(crate) struct UnknownBundleFormatError; - -impl std::error::Error for UnknownBundleFormatError {} - -impl std::fmt::Display for UnknownBundleFormatError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Unknown bundle format") - } -} - -impl FromStr for BundleFormat { - type Err = UnknownBundleFormatError; - - fn from_str(s: &str) -> Result { - match s { - "web" => Ok(Self::Web), - "macos" => Ok(Self::MacOS), - "windows" => Ok(Self::Windows), - "linux" => Ok(Self::Linux), - "server" => Ok(Self::Server), - "ios" => Ok(Self::Ios), - "android" => Ok(Self::Android), - _ => Err(UnknownBundleFormatError), +impl BundleFormat { + /// The native "desktop" host app format. + pub(crate) fn host() -> Self { + if cfg!(target_os = "macos") { + Self::MacOS + } else if cfg!(target_os = "windows") { + Self::Windows + } else if cfg!(target_os = "linux") { + Self::Linux + } else { + Self::Web } } -} - -impl Display for BundleFormat { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - BundleFormat::Web => "web", - BundleFormat::MacOS => "macos", - BundleFormat::Windows => "windows", - BundleFormat::Linux => "linux", - BundleFormat::Server => "server", - BundleFormat::Ios => "ios", - BundleFormat::Android => "android", - }) - } -} - -impl BundleFormat { - #[cfg(target_os = "macos")] - pub(crate) const TARGET_PLATFORM: Option = Some(Self::MacOS); - #[cfg(target_os = "windows")] - pub(crate) const TARGET_PLATFORM: Option = Some(Self::Windows); - #[cfg(target_os = "linux")] - pub(crate) const TARGET_PLATFORM: Option = Some(Self::Linux); - #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] - pub(crate) const TARGET_PLATFORM: Option = None; /// Get the name of the folder we need to generate for this platform /// @@ -449,121 +309,46 @@ impl BundleFormat { Self::Server => "Server", } } +} - pub(crate) fn from_target(target: &Triple, renderer: Option) -> Result { - match ( - renderer, - target.architecture, - target.environment, - target.operating_system, - ) { - // The server always uses the server bundle format - (Some(Renderer::Server), _, _, _) => Ok(BundleFormat::Server), - // The web renderer always uses the web bundle format - (Some(Renderer::Web), _, _, _) => Ok(BundleFormat::Web), - // Otherwise, guess it based on the target - // Assume any wasm32 or wasm64 target is a web target - (_, Architecture::Wasm32 | Architecture::Wasm64, _, _) => Ok(BundleFormat::Web), - // For native targets, we need to determine the bundle format based on the OS - (_, _, Environment::Android, _) => Ok(BundleFormat::Android), - (_, _, _, OperatingSystem::IOS(_)) => Ok(BundleFormat::Ios), - (_, _, _, OperatingSystem::MacOSX(_) | OperatingSystem::Darwin(_)) => { - Ok(BundleFormat::MacOS) - } - (_, _, _, OperatingSystem::Linux) => Ok(BundleFormat::Linux), - (_, _, _, OperatingSystem::Windows) => Ok(BundleFormat::Windows), - // If we don't recognize the target, default to desktop - _ => BundleFormat::TARGET_PLATFORM.context( - "failed to determine bundle format. Try setting the `--bundle` flag manually", - ), +#[derive(Debug)] +pub(crate) struct UnknownBundleFormatError; + +impl std::error::Error for UnknownBundleFormatError {} + +impl std::fmt::Display for UnknownBundleFormatError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Unknown bundle format") + } +} + +impl FromStr for BundleFormat { + type Err = UnknownBundleFormatError; + + fn from_str(s: &str) -> Result { + match s { + "web" => Ok(Self::Web), + "macos" => Ok(Self::MacOS), + "windows" => Ok(Self::Windows), + "linux" => Ok(Self::Linux), + "server" => Ok(Self::Server), + "ios" => Ok(Self::Ios), + "android" => Ok(Self::Android), + _ => Err(UnknownBundleFormatError), } } } -#[derive( - Copy, - Clone, - Hash, - PartialEq, - Eq, - PartialOrd, - Ord, - Serialize, - Deserialize, - Debug, - Default, - clap::ValueEnum, -)] -#[non_exhaustive] -pub(crate) enum Platform { - /// Alias for `--wasm --web --bundle-format web` - #[clap(name = "web")] - #[serde(rename = "web")] - #[default] - Web, - - /// Alias for `--macos --webview --bundle-format macos` - #[cfg_attr(target_os = "macos", clap(alias = "desktop"))] - #[clap(name = "macos")] - #[serde(rename = "macos")] - MacOS, - - /// Alias for `--windows --webview --bundle-format windows` - #[cfg_attr(target_os = "windows", clap(alias = "desktop"))] - #[clap(name = "windows")] - #[serde(rename = "windows")] - Windows, - - /// Alias for `--linux --webview --bundle-format linux` - #[cfg_attr(target_os = "linux", clap(alias = "desktop"))] - #[clap(name = "linux")] - #[serde(rename = "linux")] - Linux, - - /// Alias for `--ios --webview --bundle-format ios` - #[clap(name = "ios")] - #[serde(rename = "ios")] - Ios, - - /// Alias for `--android --webview --bundle-format android` - #[clap(name = "android")] - #[serde(rename = "android")] - Android, - - /// Alias for `--host --server --bundle-format server` - #[clap(name = "server")] - #[serde(rename = "server")] - Server, - - /// Alias for `--host --liveview --bundle-format host` - #[clap(name = "liveview")] - #[serde(rename = "liveview")] - Liveview, -} - -impl Platform { - pub(crate) fn into_triple(self) -> (TargetAlias, Renderer, BundleFormat) { - match self { - Platform::Web => (TargetAlias::Wasm, Renderer::Web, BundleFormat::Web), - Platform::MacOS => (TargetAlias::MacOS, Renderer::Webview, BundleFormat::MacOS), - Platform::Windows => ( - TargetAlias::Windows, - Renderer::Webview, - BundleFormat::Windows, - ), - Platform::Linux => (TargetAlias::Linux, Renderer::Webview, BundleFormat::Linux), - Platform::Ios => (TargetAlias::Ios, Renderer::Webview, BundleFormat::Ios), - Platform::Android => ( - TargetAlias::Android, - Renderer::Webview, - BundleFormat::Android, - ), - Platform::Server => (TargetAlias::Host, Renderer::Server, BundleFormat::Server), - Platform::Liveview => ( - TargetAlias::Host, - Renderer::Liveview, - BundleFormat::TARGET_PLATFORM.unwrap(), - ), - } +impl Display for BundleFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + BundleFormat::Web => "web", + BundleFormat::MacOS => "macos", + BundleFormat::Windows => "windows", + BundleFormat::Linux => "linux", + BundleFormat::Server => "server", + BundleFormat::Ios => "ios", + BundleFormat::Android => "android", + }) } } diff --git a/packages/cli/src/serve/proxy_ws.rs b/packages/cli/src/serve/proxy_ws.rs index 3d2ab16df..2eed6ed69 100644 --- a/packages/cli/src/serve/proxy_ws.rs +++ b/packages/cli/src/serve/proxy_ws.rs @@ -21,13 +21,13 @@ pub(crate) async fn proxy_websocket( .await .map_err(IntoResponse::into_response)?; - tracing::info!(dx_src = ?TraceSrc::Dev, "Proxying websocket connection {req:?}"); + tracing::trace!(dx_src = ?TraceSrc::Dev, "Proxying websocket connection {req:?}"); let proxied_request = into_proxied_request(req, backend_url).map_err(handle_proxy_error)?; - tracing::info!(dx_src = ?TraceSrc::Dev, "Connection proxied to {proxied_uri}", proxied_uri = proxied_request.uri()); + tracing::trace!(dx_src = ?TraceSrc::Dev, "Connection proxied to {proxied_uri}", proxied_uri = proxied_request.uri()); Ok(ws.on_upgrade(move |client_ws| async move { match handle_ws_connection(client_ws, proxied_request).await { - Ok(()) => tracing::info!(dx_src = ?TraceSrc::Dev, "Websocket connection closed"), + Ok(()) => tracing::trace!(dx_src = ?TraceSrc::Dev, "Websocket connection closed"), Err(e) => { tracing::error!(dx_src = ?TraceSrc::Dev, "Error proxying websocket connection: {e}") } diff --git a/packages/cli/src/serve/server.rs b/packages/cli/src/serve/server.rs index 6993ef90b..4886fd4f2 100644 --- a/packages/cli/src/serve/server.rs +++ b/packages/cli/src/serve/server.rs @@ -337,10 +337,6 @@ impl WebServer { /// Tells all clients to reload if possible for new changes. pub(crate) async fn send_reload_command(&mut self) { - tracing::trace!("Sending reload to toast"); - - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - self.build_status.set(Status::Ready); self.send_build_status().await; self.send_devserver_message_to_all(DevserverMsg::FullReloadCommand) diff --git a/packages/cli/src/test_harnesses.rs b/packages/cli/src/test_harnesses.rs new file mode 100644 index 000000000..7f08494f0 --- /dev/null +++ b/packages/cli/src/test_harnesses.rs @@ -0,0 +1,445 @@ +use crate::{BuildTargets, BundleFormat, Cli, Commands, Workspace}; +use anyhow::Result; +use clap::Parser; +use futures_util::{stream::FuturesUnordered, StreamExt}; +use std::{ + collections::HashSet, + fmt::Write, + path::{Path, PathBuf}, + pin::Pin, + prelude::rust_2024::Future, +}; +use target_lexicon::Triple; +use tracing_subscriber::{prelude::*, util::SubscriberInitExt, EnvFilter, Layer}; + +#[tokio::test] +async fn run_harness() { + test_harnesses().await; +} + +#[allow(dead_code)] +async fn test_harnesses() { + tracing_subscriber::registry() + .with(tracing_subscriber::fmt::layer().with_filter(EnvFilter::new("error,dx=debug,dioxus_cli=debug,manganis_cli_support=debug,wasm_split_cli=debug,subsecond_cli_support=debug",))) + .init(); + + TestHarnessBuilder::run(vec![ + TestHarnessBuilder::new("harness-simple-web") + .deps(r#"dioxus = { workspace = true, features = ["web"] }"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + assert_eq!(t.client.triple, "wasm32-unknown-unknown".parse().unwrap()); + assert!(t.server.is_none()); + }), + TestHarnessBuilder::new("harness-simple-desktop") + .deps(r#"dioxus = { workspace = true, features = ["desktop"] }"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + assert_eq!(t.client.triple, Triple::host()); + assert!(t.server.is_none()); + }), + TestHarnessBuilder::new("harness-simple-mobile") + .deps(r#"dioxus = { workspace = true, features = ["mobile"] }"#) + .asrt( + "dx build", + |targets| async move { assert!(targets.is_err()) }, + ), + TestHarnessBuilder::new("harness-simple-fullstack") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"web=["dioxus/web"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-simple-fullstack-with-default") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"default=["web", "server"]"#) + .fetr(r#"web=["dioxus/web"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-simple-fullstack-native-with-default") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"default=["native", "server"]"#) + .fetr(r#"native=["dioxus/native"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + assert_eq!(t.client.features.len(), 1); + assert_eq!(t.client.features[0], "native"); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-fullstack-multi-target") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"default=["web", "desktop", "mobile", "server"]"#) + .fetr(r#"web=["dioxus/web"]"#) + .fetr(r#"desktop=["dioxus/desktop"]"#) + .fetr(r#"mobile=["dioxus/mobile"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |t| async move { assert!(t.is_err()) }) + .asrt(r#"dx build --web"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + }) + .asrt(r#"dx build --desktop"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + }) + .asrt(r#"dx build --ios"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Ios); + assert_eq!(t.client.triple, TestHarnessBuilder::host_ios_triple_sim()); + }) + .asrt(r#"dx build --ios --device"#, |targets| async move { + let targets = targets.unwrap(); + assert_eq!(targets.client.bundle, BundleFormat::Ios); + assert_eq!(targets.client.triple, "aarch64-apple-ios".parse().unwrap()); + }) + .asrt(r#"dx build --android --device"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Android); + assert_eq!(t.client.triple, "aarch64-linux-android".parse().unwrap()); + }), + TestHarnessBuilder::new("harness-fullstack-multi-target-no-default") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"web=["dioxus/web"]"#) + .fetr(r#"desktop=["dioxus/desktop"]"#) + .fetr(r#"mobile=["dioxus/mobile"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + assert!(targets.is_err()) + }) + .asrt(r#"dx build --desktop"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }) + .asrt(r#"dx build --ios"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Ios); + assert_eq!(t.client.triple, TestHarnessBuilder::host_ios_triple_sim()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-fullstack-desktop") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"desktop=["dioxus/desktop"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-fullstack-desktop-with-features") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .deps(r#"anyhow = { workspace = true, optional = true }"#) + .fetr(r#"desktop=["dioxus/desktop", "has_anyhow"]"#) + .fetr(r#"has_anyhow=["dep:anyhow"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-fullstack-desktop-with-default") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .deps(r#"anyhow = { workspace = true, optional = true }"#) + .fetr(r#"default=["desktop"]"#) + .fetr(r#"desktop=["dioxus/desktop", "has_anyhow"]"#) + .fetr(r#"has_anyhow=["dep:anyhow"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }), + TestHarnessBuilder::new("harness-no-dioxus") + .deps(r#"anyhow = { workspace = true, optional = true }"#) + .fetr(r#"web=["dep:anyhow"]"#) + .fetr(r#"server=[]"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + assert!(t.server.is_none()); + }), + TestHarnessBuilder::new("harness-simple-dedicated-server"), + TestHarnessBuilder::new("harness-simple-dedicated-client") + .deps(r#"dioxus = { workspace = true, features = ["web"] }"#) + .asrt(r#"dx build"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + assert!(t.server.is_none()); + }) + .asrt(r#"dx build @client --package harness-simple-dedicated-client @server --package harness-simple-dedicated-server"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + let s = t.server.unwrap(); + assert_eq!(s.bundle, BundleFormat::Server); + assert_eq!(s.triple, Triple::host()); + }, + ) + .asrt(r#"dx build @client --package harness-simple-dedicated-client @server --package harness-simple-dedicated-server --target wasm32-unknown-unknown"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + let s = t.server.unwrap(); + assert_eq!(s.bundle, BundleFormat::Server); + assert_eq!(s.triple, "wasm32-unknown-unknown".parse().unwrap()); + }), + TestHarnessBuilder::new("harness-renderer-swap") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .fetr(r#"default=["desktop", "server"]"#) + .fetr(r#"desktop=["dioxus/desktop"]"#) + .fetr(r#"native=["dioxus/native"]"#) + .fetr(r#"server=["dioxus/server"]"#) + .asrt( + r#"dx build --desktop --renderer native"#, + |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::host()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }, + ), + TestHarnessBuilder::new("harness-default-to-non-default") + .deps(r#"dioxus = { workspace = true, features = [] }"#) + .fetr(r#"default=["web"]"#) + .fetr(r#"web=["dioxus/web"]"#) + .asrt( + r#"dx build --ios"#, + |targets| async move { + let t = targets.unwrap(); + assert!(t.server.is_none()); + assert_eq!(t.client.bundle, BundleFormat::Ios); + assert_eq!(t.client.triple, TestHarnessBuilder::host_ios_triple_sim()); + assert!(t.client.no_default_features); + }, + ), + TestHarnessBuilder::new("harness-fullstack-with-optional-tokio") + .deps(r#"dioxus = { workspace = true, features = ["fullstack"] }"#) + .deps(r#"serde = "1.0.219""#) + .deps(r#"tokio = { workspace = true, features = ["full"], optional = true }"#) + .fetr(r#"default = []"#) + .fetr(r#"server = ["dioxus/server", "dep:tokio"]"#) + .fetr(r#"web = ["dioxus/web"]"#) + // .asrt(r#"dx build"#, |targets| async move { + // assert!(targets.is_err()) + // }) + .asrt(r#"dx build --web"#, |targets| async move { + let t = targets.unwrap(); + assert_eq!(t.client.bundle, BundleFormat::Web); + assert_eq!(t.client.triple, "wasm32-unknown-unknown".parse().unwrap()); + let server = t.server.unwrap(); + assert_eq!(server.bundle, BundleFormat::Server); + assert_eq!(server.triple, Triple::host()); + }) + ]) + .await; +} + +#[derive(Default)] +struct TestHarnessBuilder { + name: String, + dependencies: String, + features: String, + futures: Vec, +} + +struct TestHarnessTestCase { + args: String, + #[allow(clippy::type_complexity)] + callback: Box) -> Pin>>>, +} + +impl TestHarnessBuilder { + fn new(name: &str) -> Self { + Self { + name: name.into(), + dependencies: Default::default(), + features: Default::default(), + futures: Default::default(), + } + } + + /// Add a dependency to the test harness. + fn deps(mut self, dependencies: impl Into) -> Self { + writeln!(&mut self.dependencies, "{}", dependencies.into()).unwrap(); + self + } + + /// Add a feature to the test harness. + fn fetr(mut self, features: impl Into) -> Self { + writeln!(&mut self.features, "{}", features.into()).unwrap(); + self + } + + /// Assert the expected behavior of the test harness. + fn asrt( + mut self, + args: impl Into, + future: impl FnOnce(Result) -> F + 'static, + ) -> Self + where + F: Future + 'static, + { + self.futures.push(TestHarnessTestCase { + args: args.into(), + callback: Box::new(move |args| Box::pin(future(args))), + }); + self + } + + /// Write the test harness to the filesystem. + fn build(&self, harness_dir: &Path) { + let name = self.name.clone(); + let dependencies = self.dependencies.clone(); + let features = self.features.clone(); + + let test_dir = harness_dir.join(&name); + + _ = std::fs::remove_dir_all(&test_dir); + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::create_dir_all(test_dir.join("src")).unwrap(); + + let cargo_toml = format!( + r#"[package] +name = "{name}" +version = "0.0.1" +edition = "2021" +license = "MIT OR Apache-2.0" +publish = false + +[dependencies] +{dependencies} + +[features] +{features} + "#, + name = name, + dependencies = dependencies, + features = features + ); + + std::fs::write(test_dir.join("Cargo.toml"), cargo_toml).unwrap(); + + let contents = if features.contains("dioxus") { + r#"use dioxus::prelude::*; +fn main() { + dioxus::launch(|| rsx! { "hello world!" }) +} +"# + } else { + r#"fn main() { + println!("Hello, world!"); +} +"# + }; + + std::fs::write(test_dir.join("src/main.rs"), contents).unwrap(); + } + + async fn run(harnesses: Vec) { + _ = crate::VERBOSITY.set(crate::Verbosity { + verbose: true, + trace: true, + json_output: false, + log_to_file: None, + locked: false, + offline: false, + frozen: false, + }); + + let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR") + .map(PathBuf::from) + .unwrap(); + + let harness_dir = cargo_manifest_dir.parent().unwrap().join("cli-harnesses"); + + // make sure we don't start deleting random stuff. + if !harness_dir.exists() { + panic!( + "cli-harnesses directory does not exist, aborting: {:?}", + harness_dir + ); + } + + // Erase old entries in the harness directory, but keep files (ie README.md) around + for entry in std::fs::read_dir(&harness_dir).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + std::fs::remove_dir_all(entry.path()).unwrap(); + } + } + + // Now that the harnesses are written to the filesystem, we can call cargo_metadata + // It will be cached from here + let mut futures = FuturesUnordered::new(); + let mut seen_names = HashSet::new(); + + for harness in harnesses { + if !seen_names.insert(harness.name.clone()) { + panic!("Duplicate test harness name found: {}", harness.name); + } + + harness.build(&harness_dir); + + for case in harness.futures { + let mut escaped = shell_words::split(&case.args).unwrap(); + if !(escaped.contains(&"--package".to_string()) + || escaped.contains(&"@server".to_string()) + || escaped.contains(&"@client".to_string())) + { + escaped.push("--package".to_string()); + escaped.push(harness.name.clone()); + } + let args = Cli::try_parse_from(escaped).unwrap(); + let Commands::Build(build_args) = args.action else { + panic!("Expected build command"); + }; + + futures.push(async move { + let targets = build_args.into_targets().await; + (case.callback)(targets).await; + }); + } + } + + // Give a moment for fs to catch up + std::thread::sleep(std::time::Duration::from_secs(1)); + + let _workspace = Workspace::current().await.unwrap(); + + while let Some(_res) = futures.next().await {} + } + + fn host_ios_triple_sim() -> Triple { + if cfg!(target_arch = "aarch64") { + "aarch64-apple-ios-sim".parse().unwrap() + } else { + "x86_64-apple-ios".parse().unwrap() + } + } +} diff --git a/packages/core-macro/src/props/mod.rs b/packages/core-macro/src/props/mod.rs index d994b9057..235c66149 100644 --- a/packages/core-macro/src/props/mod.rs +++ b/packages/core-macro/src/props/mod.rs @@ -855,17 +855,19 @@ Finally, call `.build()` to create the instance of `{name}`. let ty = f.ty; quote!(#name: #ty) }) - .chain(self.has_child_owned_fields().then(|| quote!(owner: Owner))); + .chain( + self.has_child_owned_fields() + .then(|| quote!(owner: dioxus_core::internal::generational_box::Owner)), + ); let global_fields_value = self .extend_fields() .map(|f| { let name = f.extends_vec_ident(); quote!(#name: Vec::new()) }) - .chain( - self.has_child_owned_fields() - .then(|| quote!(owner: Owner::default())), - ); + .chain(self.has_child_owned_fields().then( + || quote!(owner: dioxus_core::internal::generational_box::Owner::default()), + )); Ok(quote! { impl #impl_generics #name #ty_generics #where_clause { diff --git a/packages/core/Cargo.toml b/packages/core/Cargo.toml index 689145572..368ea6048 100644 --- a/packages/core/Cargo.toml +++ b/packages/core/Cargo.toml @@ -25,6 +25,7 @@ warnings = { workspace = true } futures-util = { workspace = true, default-features = false, features = ["alloc", "std"] } serde = { workspace = true, optional = true, features = ["derive"] } subsecond = { workspace = true } +anyhow = { workspace = true } [dev-dependencies] dioxus = { workspace = true } diff --git a/packages/core/src/any_props.rs b/packages/core/src/any_props.rs index e3dcc90c8..5f34d4d30 100644 --- a/packages/core/src/any_props.rs +++ b/packages/core/src/any_props.rs @@ -85,7 +85,7 @@ impl + Clone, P: Clone + 'static, M: 'static> AnyProp { tracing::error!("Panic while rendering component `{_name}`: {err:?}"); } - Element::Err(CapturedPanic { error: err }.into()) + Element::Err(CapturedPanic(err).into()) } } } diff --git a/packages/core/src/diff/component.rs b/packages/core/src/diff/component.rs index ceea6db7f..77b494084 100644 --- a/packages/core/src/diff/component.rs +++ b/packages/core/src/diff/component.rs @@ -9,8 +9,8 @@ use crate::{ ElementRef, MountId, ScopeOrder, SuspenseBoundaryProps, SuspenseBoundaryPropsWithOwner, VComponent, WriteMutations, }, - nodes::{AsVNode, VNode}, - scopes::ScopeId, + nodes::VNode, + scopes::{LastRenderedNode, ScopeId}, virtual_dom::VirtualDom, Element, SuspenseContext, }; @@ -50,10 +50,9 @@ impl VirtualDom { // If it is suspended, we need to diff it but write the mutations nothing // Note: It is important that we still diff the scope even if it is suspended, because the scope may render other child components which may change between renders let mut render_to = to.filter(|_| self.runtime.scope_should_render(scope)); - old.as_vnode() - .diff_node(new_real_nodes, self, render_to.as_deref_mut()); + old.diff_node(new_real_nodes, self, render_to.as_deref_mut()); - self.scopes[scope.0].last_rendered_node = Some(new_nodes); + self.scopes[scope.0].last_rendered_node = Some(LastRenderedNode::new(new_nodes)); if render_to.is_some() { self.runtime.get_state(scope).unwrap().mount(&self.runtime); @@ -69,7 +68,7 @@ impl VirtualDom { &mut self, to: Option<&mut M>, scope: ScopeId, - new_nodes: Element, + new_nodes: LastRenderedNode, parent: Option, ) -> usize { self.runtime.clone().with_scope_on_stack(scope, || { @@ -79,9 +78,7 @@ impl VirtualDom { let mut render_to = to.filter(|_| self.runtime.scope_should_render(scope)); // Create the node - let nodes = new_nodes - .as_vnode() - .create(self, parent, render_to.as_deref_mut()); + let nodes = new_nodes.create(self, parent, render_to.as_deref_mut()); // Then set the new node as the last rendered node self.scopes[scope.0].last_rendered_node = Some(new_nodes); @@ -105,13 +102,8 @@ impl VirtualDom { SuspenseContext::remove_suspended_nodes::(self, scope_id, destroy_component_state); // Remove the component from the dom - if let Some(node) = self.scopes[scope_id.0].last_rendered_node.as_ref() { - node.clone().as_vnode().remove_node_inner( - self, - to, - destroy_component_state, - replace_with, - ) + if let Some(node) = self.scopes[scope_id.0].last_rendered_node.clone() { + node.remove_node_inner(self, to, destroy_component_state, replace_with) }; if destroy_component_state { @@ -210,16 +202,15 @@ impl VNode { let new = dom.run_scope(scope_id); // Then set the new node as the last rendered node - dom.scopes[scope_id.0].last_rendered_node = Some(new); + dom.scopes[scope_id.0].last_rendered_node = Some(LastRenderedNode::new(new)); } let scope = ScopeId(dom.get_mounted_dyn_node(mount, idx)); let new_node = dom.scopes[scope.0] .last_rendered_node - .as_ref() - .expect("Component to be mounted") - .clone(); + .clone() + .expect("Component to be mounted"); dom.create_scope(to, scope, new_node, parent) } diff --git a/packages/core/src/error_boundary.rs b/packages/core/src/error_boundary.rs index 698e4b64d..fddc59eaf 100644 --- a/packages/core/src/error_boundary.rs +++ b/packages/core/src/error_boundary.rs @@ -1,18 +1,29 @@ use crate::{ - global_context::current_scope_id, innerlude::provide_context, try_consume_context, use_hook, - Element, IntoDynNode, Properties, ReactiveContext, ScopeId, Subscribers, Template, - TemplateAttribute, TemplateNode, VNode, + innerlude::{provide_context, CapturedError}, + try_consume_context, use_hook, Element, IntoDynNode, Properties, ReactiveContext, Subscribers, + Template, TemplateAttribute, TemplateNode, VNode, }; use std::{ any::Any, - backtrace::Backtrace, - cell::{Ref, RefCell}, - error::Error, + cell::RefCell, fmt::{Debug, Display}, rc::Rc, - str::FromStr, }; +/// Return early with an error. +#[macro_export] +macro_rules! bail { + ($msg:literal $(,)?) => { + return $crate::internal::Err($crate::internal::__anyhow!($msg).into()) + }; + ($err:expr $(,)?) => { + return $crate::internal::Err($crate::internal::__anyhow!($err).into()) + }; + ($fmt:expr, $($arg:tt)*) => { + return $crate::internal::Err($crate::internal::__anyhow!($fmt, $($arg)*).into()) + }; +} + /// A panic in a component that was caught by an error boundary. /// ///
@@ -20,12 +31,9 @@ use std::{ /// WASM currently does not support caching unwinds, so this struct will not be created in WASM. /// ///
-pub struct CapturedPanic { - #[allow(dead_code)] - /// The error that was caught - pub error: Box, -} - +pub(crate) struct CapturedPanic(pub(crate) Box); +unsafe impl Sync for CapturedPanic {} +impl std::error::Error for CapturedPanic {} impl Debug for CapturedPanic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CapturedPanic").finish() @@ -34,12 +42,10 @@ impl Debug for CapturedPanic { impl Display for CapturedPanic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!("Encountered panic: {:?}", self.error)) + f.write_fmt(format_args!("Encountered panic: {:?}", self.0)) } } -impl Error for CapturedPanic {} - /// A context supplied by fullstack to create hydration compatible error boundaries. Generally, this /// is not present and the default in memory error boundary is used. If fullstack is enabled, it will /// provide its own factory that handles syncing errors to the hydration context @@ -48,7 +54,7 @@ struct CreateErrorBoundary(fn() -> ErrorContext); impl Default for CreateErrorBoundary { fn default() -> Self { - Self(|| ErrorContext::new(Vec::new())) + Self(|| ErrorContext::new(None)) } } @@ -71,241 +77,57 @@ pub fn use_error_boundary_provider() -> ErrorContext { use_hook(|| provide_context(create_error_boundary())) } -/// A trait for any type that can be downcast to a concrete type and implements Debug. This is automatically implemented for all types that implement Any + Debug. -pub trait AnyError { - fn as_any(&self) -> &dyn Any; - fn as_error(&self) -> &dyn Error; -} - -/// An wrapper error type for types that only implement Display. We use a inner type here to avoid overlapping implementations for DisplayError and impl Error -struct DisplayError(DisplayErrorInner); - -impl From for DisplayError { - fn from(e: E) -> Self { - Self(DisplayErrorInner(Box::new(e))) - } -} - -struct DisplayErrorInner(Box); -impl Display for DisplayErrorInner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl Debug for DisplayErrorInner { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.0.fmt(f) - } -} - -impl Error for DisplayErrorInner {} - -impl AnyError for DisplayError { - fn as_any(&self) -> &dyn Any { - &self.0 .0 - } - - fn as_error(&self) -> &dyn Error { - &self.0 - } -} - -/// Provides context methods to [`Result`] and [`Option`] types that are compatible with [`CapturedError`] -/// -/// This trait is sealed and cannot be implemented outside of dioxus-core -pub trait Context: private::Sealed { - /// Add a visual representation of the error that the [`ErrorBoundary`] may render - /// - /// # Example - /// ```rust - /// # use dioxus::prelude::*; - /// fn Component() -> Element { - /// // You can bubble up errors with `?` inside components, and event handlers - /// // Along with the error itself, you can provide a way to display the error by calling `show` - /// let number = "1234".parse::().show(|error| rsx! { - /// div { - /// background_color: "red", - /// color: "white", - /// "Error parsing number: {error}" - /// } - /// })?; - /// unimplemented!() - /// } - /// ``` - fn show(self, display_error: impl FnOnce(&E) -> Element) -> Result; - - /// Wrap the result additional context about the error that occurred. - /// - /// # Example - /// ```rust - /// # use dioxus::prelude::*; - /// fn NumberParser() -> Element { - /// // You can bubble up errors with `?` inside components, and event handlers - /// // Along with the error itself, you can provide a way to display the error by calling `context` - /// let number = "-1234".parse::().context("Parsing number inside of the NumberParser")?; - /// unimplemented!() - /// } - /// ``` - fn context(self, context: C) -> Result; - - /// Wrap the result with additional context about the error that occurred. The closure will only be run if the Result is an error. - /// - /// # Example - /// ```rust - /// # use dioxus::prelude::*; - /// fn NumberParser() -> Element { - /// // You can bubble up errors with `?` inside components, and event handlers - /// // Along with the error itself, you can provide a way to display the error by calling `context` - /// let number = "-1234".parse::().with_context(|| format!("Timestamp: {:?}", std::time::Instant::now()))?; - /// unimplemented!() - /// } - /// ``` - fn with_context(self, context: impl FnOnce() -> C) -> Result; -} - -impl Context for std::result::Result -where - E: Error + 'static, -{ - fn show(self, display_error: impl FnOnce(&E) -> Element) -> Result { - // We don't use result mapping to avoid extra frames - match self { - std::result::Result::Ok(value) => Ok(value), - Err(error) => { - let render = display_error(&error).unwrap_or_default(); - let mut error: CapturedError = error.into(); - error.render = render; - Err(error) - } - } - } - - fn context(self, context: C) -> Result { - self.with_context(|| context) - } - - fn with_context(self, context: impl FnOnce() -> C) -> Result { - // We don't use result mapping to avoid extra frames - match self { - std::result::Result::Ok(value) => Ok(value), - Err(error) => { - let mut error: CapturedError = error.into(); - error.context.push(Rc::new(AdditionalErrorContext { - backtrace: Backtrace::capture(), - context: Box::new(context()), - scope: current_scope_id().ok(), - })); - Err(error) - } - } - } -} - -impl Context for Option { - fn show(self, display_error: impl FnOnce(&CapturedError) -> Element) -> Result { - // We don't use result mapping to avoid extra frames - match self { - Some(value) => Ok(value), - None => { - let mut error = CapturedError::from_display("Value was none"); - let render = display_error(&error).unwrap_or_default(); - error.render = render; - Err(error) - } - } - } - - fn context(self, context: C) -> Result { - self.with_context(|| context) - } - - fn with_context(self, context: impl FnOnce() -> C) -> Result { - // We don't use result mapping to avoid extra frames - match self { - Some(value) => Ok(value), - None => { - let error = CapturedError::from_display(context()); - Err(error) - } - } - } -} - -pub(crate) mod private { - use super::*; - - pub trait Sealed {} - - impl Sealed for std::result::Result where E: Error {} - impl Sealed for Option {} -} - -impl AnyError for T { - fn as_any(&self) -> &dyn Any { - self - } - - fn as_error(&self) -> &dyn Error { - self - } -} - /// A context with information about suspended components #[derive(Clone)] pub struct ErrorContext { - errors: Rc>>, + error: Rc>>, subscribers: Subscribers, } impl Debug for ErrorContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ErrorContext") - .field("errors", &self.errors) + .field("error", &self.error) .finish() } } impl PartialEq for ErrorContext { fn eq(&self, other: &Self) -> bool { - Rc::ptr_eq(&self.errors, &other.errors) + Rc::ptr_eq(&self.error, &other.error) } } impl ErrorContext { - /// Create a new in memory error context. This will not be serialized to the hydration context if fullstack - /// is enabled - pub fn new(errors: Vec) -> Self { + /// Create a new suspense boundary in a specific scope + pub fn new(error: Option) -> Self { Self { - errors: Rc::new(RefCell::new(errors)), - subscribers: Subscribers::default(), + error: Rc::new(RefCell::new(error)), + subscribers: Subscribers::new(), } } - /// Get all errors thrown from child components - pub fn errors(&self) -> Ref<'_, [CapturedError]> { + /// Get the current error, if any. If multiple components have errored, this will return the first + /// error that made it to this boundary. + pub fn error(&self) -> Option { // Subscribe to the current reactive context if one exists. This is usually // the error boundary component that is rendering the errors if let Some(rc) = ReactiveContext::current() { self.subscribers.add(rc); } - Ref::map(self.errors.borrow(), |errors| errors.as_slice()) - } - /// Get the Element from the first error that can be shown - pub fn show(&self) -> Option { - self.errors.borrow().iter().find_map(|task| task.show()) + self.error.borrow().clone() } /// Push an error into this Error Boundary pub fn insert_error(&self, error: CapturedError) { - self.errors.borrow_mut().push(error); + self.error.borrow_mut().replace(error); self.mark_dirty() } /// Clear all errors from this Error Boundary pub fn clear_errors(&self) { - self.errors.borrow_mut().clear(); + self.error.borrow_mut().take(); self.mark_dirty(); } @@ -321,244 +143,6 @@ impl ErrorContext { } } -/// Errors can have additional context added as they bubble up the render tree -/// This context can be used to provide additional information to the user -struct AdditionalErrorContext { - backtrace: Backtrace, - context: Box, - scope: Option, -} - -impl Debug for AdditionalErrorContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ErrorContext") - .field("backtrace", &self.backtrace) - .field("context", &self.context.to_string()) - .finish() - } -} - -impl Display for AdditionalErrorContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let AdditionalErrorContext { - backtrace, - context, - scope, - } = self; - - write!(f, "{context} (from ")?; - - if let Some(scope) = scope { - write!(f, "scope {scope:?} ")?; - } - - write!(f, "at {backtrace:?})") - } -} - -/// A type alias for a result that can be either a boxed error or a value -/// This is useful to avoid having to use `Result` everywhere -pub type Result = std::result::Result; - -/// A helper function for an Ok result that can be either a boxed error or a value -/// This is useful to avoid having to use `Ok` everywhere -#[allow(non_snake_case)] -pub fn Ok(value: T) -> Result { - Result::Ok(value) -} - -#[derive(Clone)] -/// An instance of an error captured by a descendant component. -pub struct CapturedError { - /// The error captured by the error boundary - error: Rc, - - /// The backtrace of the error - backtrace: Rc, - - /// The scope that threw the error - scope: ScopeId, - - /// An error message that can be displayed to the user - pub(crate) render: VNode, - - /// Additional context that was added to the error - context: Vec>, -} - -impl FromStr for CapturedError { - type Err = std::convert::Infallible; - - fn from_str(s: &str) -> std::result::Result { - std::result::Result::Ok(Self::from_display(s.to_string())) - } -} - -#[cfg(feature = "serialize")] -#[derive(serde::Serialize, serde::Deserialize)] -struct SerializedCapturedError { - error: String, - context: Vec, -} - -#[cfg(feature = "serialize")] -impl serde::Serialize for CapturedError { - fn serialize( - &self, - serializer: S, - ) -> std::result::Result { - let serialized = SerializedCapturedError { - error: self.error.as_error().to_string(), - context: self - .context - .iter() - .map(|context| context.to_string()) - .collect(), - }; - serialized.serialize(serializer) - } -} - -#[cfg(feature = "serialize")] -impl<'de> serde::Deserialize<'de> for CapturedError { - fn deserialize>( - deserializer: D, - ) -> std::result::Result { - let serialized = SerializedCapturedError::deserialize(deserializer)?; - - let error = DisplayError::from(serialized.error); - let context = serialized - .context - .into_iter() - .map(|context| { - Rc::new(AdditionalErrorContext { - scope: None, - backtrace: Backtrace::disabled(), - context: Box::new(context), - }) - }) - .collect(); - - std::result::Result::Ok(Self { - error: Rc::new(error), - context, - backtrace: Rc::new(Backtrace::disabled()), - scope: ScopeId::ROOT, - render: VNode::placeholder(), - }) - } -} - -impl Debug for CapturedError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CapturedError") - .field("error", &self.error.as_error()) - .field("backtrace", &self.backtrace) - .field("scope", &self.scope) - .finish() - } -} - -impl From for CapturedError { - fn from(error: E) -> Self { - Self { - error: Rc::new(error), - backtrace: Rc::new(Backtrace::capture()), - scope: current_scope_id() - .expect("Cannot create an error boundary outside of a component's scope."), - render: Default::default(), - context: Default::default(), - } - } -} - -impl CapturedError { - /// Create a new captured error - pub fn new(error: impl AnyError + 'static) -> Self { - Self { - error: Rc::new(error), - backtrace: Rc::new(Backtrace::capture()), - scope: current_scope_id().unwrap_or(ScopeId::ROOT), - render: Default::default(), - context: Default::default(), - } - } - - /// Create a new error from a type that only implements [`Display`]. If your type implements [`Error`], you can use [`CapturedError::from`] instead. - pub fn from_display(error: impl Display + 'static) -> Self { - Self { - error: Rc::new(DisplayError::from(error)), - backtrace: Rc::new(Backtrace::capture()), - scope: current_scope_id().unwrap_or(ScopeId::ROOT), - render: Default::default(), - context: Default::default(), - } - } - - /// Mark the error as being thrown from a specific scope - pub fn with_origin(mut self, scope: ScopeId) -> Self { - self.scope = scope; - self - } - - /// Get a VNode representation of the error if the error provides one - pub fn show(&self) -> Option { - if self.render == VNode::placeholder() { - None - } else { - Some(std::result::Result::Ok(self.render.clone())) - } - } - - /// Create a deep clone of this error - pub(crate) fn deep_clone(&self) -> Self { - Self { - render: self.render.deep_clone(), - ..self.clone() - } - } -} - -impl PartialEq for CapturedError { - fn eq(&self, other: &Self) -> bool { - format!("{:?}", self) == format!("{:?}", other) - } -} - -impl Display for CapturedError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_fmt(format_args!( - "Encountered error: {:?}\nIn scope: {:?}\nBacktrace: {}\nContext: ", - self.error.as_error(), - self.scope, - self.backtrace - ))?; - for context in &*self.context { - f.write_fmt(format_args!("{}\n", context))?; - } - std::result::Result::Ok(()) - } -} - -impl CapturedError { - /// Downcast the error type into a concrete error type - pub fn downcast(&self) -> Option<&T> { - self.error.as_any().downcast_ref::() - } -} - -pub(crate) fn throw_into(error: impl Into, scope: ScopeId) { - let error = error.into(); - if let Some(cx) = scope.consume_context::() { - cx.insert_error(error) - } else { - tracing::error!( - "Tried to throw an error into an error boundary, but failed to locate a boundary: {:?}", - error - ) - } -} - #[allow(clippy::type_complexity)] #[derive(Clone)] pub struct ErrorHandler(Rc Element>); @@ -587,7 +171,7 @@ fn default_handler(errors: ErrorContext) -> Element { None, TEMPLATE, Box::new([errors - .errors() + .error() .iter() .map(|e| { static TEMPLATE: Template = Template { @@ -628,6 +212,7 @@ impl ErrorBoundaryProps { ErrorBoundaryPropsBuilder { fields: ((), ()) } } } + #[must_use] #[doc(hidden)] #[allow(dead_code, non_camel_case_types, non_snake_case)] @@ -850,8 +435,9 @@ impl< #[allow(non_upper_case_globals, non_snake_case)] pub fn ErrorBoundary(props: ErrorBoundaryProps) -> Element { let error_boundary = use_error_boundary_provider(); - let errors = error_boundary.errors(); - let has_errors = !errors.is_empty(); + let errors = error_boundary.error(); + let has_errors = errors.is_some(); + // Drop errors before running user code that might borrow the error lock drop(errors); diff --git a/packages/core/src/events.rs b/packages/core/src/events.rs index f788a0ab8..93f64481f 100644 --- a/packages/core/src/events.rs +++ b/packages/core/src/events.rs @@ -1,4 +1,5 @@ use crate::{current_scope_id, properties::SuperFrom, runtime::RuntimeGuard, Runtime, ScopeId}; +use futures_util::FutureExt; use generational_box::GenerationalBox; use std::{any::Any, cell::RefCell, marker::PhantomData, panic::Location, rc::Rc}; @@ -335,9 +336,15 @@ impl SpawnIfAsync<(), Ret> for Ret { pub struct AsyncMarker; impl + 'static> SpawnIfAsync for F { fn spawn(self) { - crate::spawn(async move { - self.await; - }); + // Quick poll once to deal with things like prevent_default in the same tick + let mut fut = Box::pin(self); + let res = fut.as_mut().now_or_never(); + + if res.is_none() { + crate::spawn(async move { + fut.await; + }); + } } } @@ -351,11 +358,17 @@ where { #[inline] fn spawn(self) { - crate::spawn(async move { - if let Err(err) = self.await { - crate::throw_error(err) - } - }); + // Quick poll once to deal with things like prevent_default in the same tick + let mut fut = Box::pin(self); + let res = fut.as_mut().now_or_never(); + + if res.is_none() { + crate::spawn(async move { + if let Err(err) = fut.await { + crate::throw_error(err) + } + }); + } } } diff --git a/packages/core/src/global_context.rs b/packages/core/src/global_context.rs index 7010acb07..eb6335c72 100644 --- a/packages/core/src/global_context.rs +++ b/packages/core/src/global_context.rs @@ -1,7 +1,6 @@ -use crate::runtime::RuntimeError; +use crate::{innerlude::CapturedError, runtime::RuntimeError}; use crate::{ - innerlude::SuspendedFuture, runtime::Runtime, CapturedError, Element, ScopeId, SuspenseContext, - Task, + innerlude::SuspendedFuture, runtime::Runtime, Element, ScopeId, SuspenseContext, Task, }; use std::future::Future; use std::rc::Rc; diff --git a/packages/core/src/lib.rs b/packages/core/src/lib.rs index cb5ec4892..8d3bd0176 100644 --- a/packages/core/src/lib.rs +++ b/packages/core/src/lib.rs @@ -41,6 +41,14 @@ pub mod internal { TemplateGlobalKey, }; + #[allow(non_snake_case)] + #[doc(hidden)] + pub fn Err(e: E) -> Result { + std::result::Result::Err(e) + } + + pub use anyhow::__anyhow; + #[doc(hidden)] pub use generational_box; } @@ -67,6 +75,11 @@ pub(crate) mod innerlude { pub use crate::tasks::*; pub use crate::virtual_dom::*; + pub use anyhow::anyhow; + pub use anyhow::Context as AnyhowContext; + pub use anyhow::Error; + pub use anyhow::Result; + /// An [`Element`] is a possibly-none [`VNode`] created by calling `render` on [`ScopeId`] or [`ScopeState`]. /// /// An Errored [`Element`] will propagate the error to the nearest error boundary. @@ -77,21 +90,22 @@ pub(crate) mod innerlude { } pub use crate::innerlude::{ - consume_context, consume_context_from_scope, current_owner, current_scope_id, fc_to_builder, - force_all_dirty, generation, has_context, needs_update, needs_update_any, parent_scope, - provide_context, provide_create_error_boundary, provide_root_context, queue_effect, - remove_future, schedule_update, schedule_update_any, spawn, spawn_forever, spawn_isomorphic, - suspend, suspense_context, throw_error, try_consume_context, use_after_render, - use_before_render, use_drop, use_error_boundary_provider, use_hook, use_hook_with_cleanup, - vdom_is_rendering, with_owner, AnyValue, Attribute, AttributeValue, Callback, CapturedError, - Component, ComponentFunction, Context, DynamicNode, Element, ElementId, ErrorBoundary, - ErrorContext, Event, EventHandler, Fragment, HasAttributes, IntoAttributeValue, IntoDynNode, - LaunchConfig, ListenerCallback, MarkerWrapper, Mutation, Mutations, NoOpMutations, Ok, + anyhow, consume_context, consume_context_from_scope, current_owner, current_scope_id, + fc_to_builder, force_all_dirty, generation, has_context, needs_update, needs_update_any, + parent_scope, provide_context, provide_create_error_boundary, provide_root_context, + queue_effect, remove_future, schedule_update, schedule_update_any, spawn, spawn_forever, + spawn_isomorphic, suspend, suspense_context, throw_error, try_consume_context, + use_after_render, use_before_render, use_drop, use_hook, use_hook_with_cleanup, + vdom_is_rendering, with_owner, AnyValue, AnyhowContext, Attribute, AttributeValue, Callback, + CapturedError, Component, ComponentFunction, DynamicNode, Element, ElementId, Error, + ErrorBoundary, ErrorContext, Event, EventHandler, Fragment, HasAttributes, IntoAttributeValue, + IntoDynNode, LaunchConfig, ListenerCallback, MarkerWrapper, Mutation, Mutations, NoOpMutations, OptionStringFromMarker, Properties, ReactiveContext, RenderError, Result, Runtime, RuntimeGuard, ScopeId, ScopeState, SpawnIfAsync, SubscriberList, Subscribers, SuperFrom, - SuperInto, SuspendedFuture, SuspenseBoundary, SuspenseBoundaryProps, SuspenseContext, - SuspenseExtension, Task, Template, TemplateAttribute, TemplateNode, VComponent, VNode, - VNodeInner, VPlaceholder, VText, VirtualDom, WriteMutations, + SuperInto, SuspendedFuture, SuspenseBoundary, SuspenseBoundaryProps, SuspenseContext, Task, + Template, TemplateAttribute, TemplateNode, VComponent, VNode, VNodeInner, VPlaceholder, VText, + VirtualDom, WriteMutations, }; +pub use anyhow::Ok; pub use const_format; diff --git a/packages/core/src/nodes.rs b/packages/core/src/nodes.rs index ea2bb35f8..2ea10ac32 100644 --- a/packages/core/src/nodes.rs +++ b/packages/core/src/nodes.rs @@ -4,7 +4,7 @@ use crate::{ events::ListenerCallback, innerlude::{ElementRef, MountId, ScopeState, VProps}, properties::ComponentFunction, - Element, Event, Properties, RenderError, ScopeId, VirtualDom, + Element, Event, Properties, ScopeId, VirtualDom, }; use dioxus_core_types::DioxusFormattable; use std::ops::Deref; @@ -100,55 +100,6 @@ pub struct VNode { pub(crate) mount: Cell, } -impl AsRef for Element { - fn as_ref(&self) -> &VNode { - match self { - Element::Ok(node) => node, - Element::Err(RenderError::Aborted(err)) => &err.render, - Element::Err(RenderError::Suspended(fut)) => &fut.placeholder, - } - } -} - -impl From<&Element> for VNode { - fn from(val: &Element) -> Self { - AsRef::as_ref(val).clone() - } -} - -impl From for VNode { - fn from(val: Element) -> Self { - match val { - Element::Ok(node) => node, - Element::Err(RenderError::Aborted(err)) => err.render, - Element::Err(RenderError::Suspended(fut)) => fut.placeholder, - } - } -} - -/// A tiny helper trait to get the vnode for a Element -pub(crate) trait AsVNode { - /// Get the vnode for this element - fn as_vnode(&self) -> &VNode; - - /// Create a deep clone of this VNode - fn deep_clone(&self) -> Self; -} - -impl AsVNode for Element { - fn as_vnode(&self) -> &VNode { - AsRef::as_ref(self) - } - - fn deep_clone(&self) -> Self { - match self { - Ok(node) => Ok(node.deep_clone()), - Err(RenderError::Aborted(err)) => Err(RenderError::Aborted(err.deep_clone())), - Err(RenderError::Suspended(fut)) => Err(RenderError::Suspended(fut.deep_clone())), - } - } -} - impl Default for VNode { fn default() -> Self { Self::placeholder() @@ -979,7 +930,7 @@ impl IntoVNode for Element { fn into_vnode(self) -> VNode { match self { Ok(val) => val.into_vnode(), - _ => VNode::empty().unwrap(), + _ => VNode::default(), } } } @@ -987,7 +938,7 @@ impl IntoVNode for &Element { fn into_vnode(self) -> VNode { match self { Ok(val) => val.into_vnode(), - _ => VNode::empty().unwrap(), + _ => VNode::default(), } } } @@ -995,7 +946,7 @@ impl IntoVNode for Option { fn into_vnode(self) -> VNode { match self { Some(val) => val.into_vnode(), - _ => VNode::empty().unwrap(), + _ => VNode::default(), } } } @@ -1003,7 +954,7 @@ impl IntoVNode for &Option { fn into_vnode(self) -> VNode { match self.as_ref() { Some(val) => val.clone().into_vnode(), - _ => VNode::empty().unwrap(), + _ => VNode::default(), } } } @@ -1011,7 +962,7 @@ impl IntoVNode for Option { fn into_vnode(self) -> VNode { match self { Some(val) => val.into_vnode(), - _ => VNode::empty().unwrap(), + _ => VNode::default(), } } } @@ -1019,7 +970,7 @@ impl IntoVNode for &Option { fn into_vnode(self) -> VNode { match self.as_ref() { Some(val) => val.clone().into_vnode(), - _ => VNode::empty().unwrap(), + _ => VNode::default(), } } } diff --git a/packages/core/src/reactive_context.rs b/packages/core/src/reactive_context.rs index 7006bdae5..db4a57504 100644 --- a/packages/core/src/reactive_context.rs +++ b/packages/core/src/reactive_context.rs @@ -108,8 +108,7 @@ impl ReactiveContext { let id = scope.id; let sender = runtime.sender.clone(); let update_scope = move || { - tracing::trace!("Marking scope {:?} as dirty", id); - sender.unbounded_send(SchedulerMsg::Immediate(id)).unwrap(); + _ = sender.unbounded_send(SchedulerMsg::Immediate(id)); }; // Otherwise, create a new context at the current scope diff --git a/packages/core/src/render_error.rs b/packages/core/src/render_error.rs index d4144cdf8..28231b1f7 100644 --- a/packages/core/src/render_error.rs +++ b/packages/core/src/render_error.rs @@ -1,12 +1,18 @@ -use std::fmt::{Debug, Display}; +use std::{ + fmt::{Debug, Display}, + sync::Arc, +}; use crate::innerlude::*; /// An error that can occur while rendering a component -#[derive(Clone, PartialEq, Debug)] +#[derive(Debug, Clone, PartialEq)] pub enum RenderError { - /// The render function returned early - Aborted(CapturedError), + /// The render function returned early due to an error. + /// + /// We captured the error, wrapped it in an Arc, and stored it here. You can no longer modify the error, + /// but you can cheaply pass it around. + Error(CapturedError), /// The component was suspended Suspended(SuspendedFuture), @@ -26,27 +32,103 @@ impl Default for RenderError { } } impl std::error::Error for RenderAbortedEarly {} - Self::Aborted(RenderAbortedEarly.into()) + Self::Error(CapturedError(Arc::new(RenderAbortedEarly.into()))) } } impl Display for RenderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Aborted(e) => write!(f, "Render aborted: {e}"), + Self::Error(e) => write!(f, "Render aborted: {e}"), Self::Suspended(e) => write!(f, "Component suspended: {e:?}"), } } } -impl From for RenderError { - fn from(e: E) -> Self { - Self::Aborted(CapturedError::from(e)) +impl From for RenderError { + fn from(e: CapturedError) -> Self { + Self::Error(e) } } -impl From for RenderError { - fn from(e: CapturedError) -> Self { - RenderError::Aborted(e) +impl> From for RenderError { + fn from(e: E) -> Self { + let anyhow_err = e.into(); + + if let Some(suspended) = anyhow_err.downcast_ref::() { + return Self::Suspended(suspended.clone()); + } + + if let Some(render_error) = anyhow_err.downcast_ref::() { + return render_error.clone(); + } + + Self::Error(CapturedError(Arc::new(anyhow_err))) } } + +/// An `anyhow::Error` wrapped in an `Arc` so it can be cheaply cloned and passed around. +#[derive(Debug, Clone)] +pub struct CapturedError(pub Arc); + +impl CapturedError { + /// Create a `CapturedError` from anything that implements `Display`. + pub fn from_display(t: impl Display) -> Self { + Self(Arc::new(anyhow::anyhow!(t.to_string()))) + } +} + +#[cfg(feature = "serialize")] +impl serde::Serialize for CapturedError { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.0.to_string()) + } +} + +#[cfg(feature = "serialize")] +impl<'de> serde::Deserialize<'de> for CapturedError { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::from_display(s)) + } +} + +impl std::ops::Deref for CapturedError { + type Target = Error; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl> From for CapturedError { + fn from(e: E) -> Self { + Self(Arc::new(e.into())) + } +} + +impl std::fmt::Display for CapturedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl PartialEq for CapturedError { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.0, &other.0) + } +} + +#[test] +fn assert_errs_can_downcast() { + fn assert_is_stderr_like() {} + + assert_is_stderr_like::(); + assert_is_stderr_like::(); +} diff --git a/packages/core/src/runtime.rs b/packages/core/src/runtime.rs index 82c0d9229..da613ccb9 100644 --- a/packages/core/src/runtime.rs +++ b/packages/core/src/runtime.rs @@ -250,7 +250,7 @@ impl Runtime { /// Pops a scope off the stack pub(crate) fn pop() { - RUNTIMES.with(|stack| stack.borrow_mut().pop()); + RUNTIMES.with(|stack| stack.borrow_mut().pop().unwrap()); } /// Runs a function with the current runtime @@ -273,11 +273,10 @@ impl Runtime { /// Runs a function with the current scope pub(crate) fn with_scope( scope: ScopeId, - f: impl FnOnce(&Scope) -> R, + callback: impl FnOnce(&Scope) -> R, ) -> Result { - Self::with(|rt| rt.get_state(scope).map(|sc| f(&sc))) - .ok() - .flatten() + Self::with(|rt| rt.get_state(scope).map(|scopestate| callback(&scopestate))) + .expect("Runtime should exist") .ok_or(RuntimeError::new()) } diff --git a/packages/core/src/scope_arena.rs b/packages/core/src/scope_arena.rs index a900c459c..02daa6b6b 100644 --- a/packages/core/src/scope_arena.rs +++ b/packages/core/src/scope_arena.rs @@ -1,13 +1,11 @@ use crate::{ any_props::{AnyProps, BoxedAnyProps}, innerlude::{throw_error, RenderError, ScopeOrder, ScopeState}, - nodes::AsVNode, scope_context::{Scope, SuspenseLocation}, scopes::ScopeId, virtual_dom::VirtualDom, - ReactiveContext, + Element, ReactiveContext, }; -use crate::{Element, VNode}; impl VirtualDom { pub(super) fn new_scope( @@ -39,7 +37,6 @@ impl VirtualDom { }); self.runtime.create_scope(scope_runtime); - tracing::trace!("created scope {id:?} with parent {parent_id:?}"); scope } @@ -79,7 +76,14 @@ impl VirtualDom { // the component runs, it returns a clone of the last rsx that was returned from // that resource. If we don't deep clone the VNode and the resource changes, then // we could end up diffing two different versions of the same mounted node - let mut render_return = render_return.deep_clone(); + let mut render_return = match render_return { + Ok(node) => Ok(node.deep_clone()), + Err(RenderError::Error(err)) => Err(RenderError::Error(err.clone())), + Err(RenderError::Suspended(fut)) => { + Err(RenderError::Suspended(fut.deep_clone())) + } + }; + self.handle_element_return(&mut render_return, scope_id, &scope.state()); render_return }) @@ -103,13 +107,12 @@ impl VirtualDom { /// Insert any errors, or suspended tasks from an element return into the runtime fn handle_element_return(&self, node: &mut Element, scope_id: ScopeId, scope_state: &Scope) { match node { - Err(RenderError::Aborted(e)) => { + Err(RenderError::Error(e)) => { tracing::error!( "Error while rendering component `{}`:\n{e}", scope_state.name ); throw_error(e.clone()); - e.render = VNode::placeholder(); } Err(RenderError::Suspended(e)) => { let task = e.task(); @@ -136,7 +139,6 @@ impl VirtualDom { .suspended_tasks .set(self.runtime.suspended_tasks.get() + 1); } - e.placeholder = VNode::placeholder(); } Ok(_) => { // If the render was successful, we can move the render generation forward by one diff --git a/packages/core/src/scope_context.rs b/packages/core/src/scope_context.rs index 2fc38957f..a41eee54a 100644 --- a/packages/core/src/scope_context.rs +++ b/packages/core/src/scope_context.rs @@ -1,5 +1,5 @@ use crate::{ - innerlude::{throw_into, CapturedError, SchedulerMsg, SuspenseContext}, + innerlude::{CapturedError, SchedulerMsg, SuspenseContext}, runtime::RuntimeError, Runtime, ScopeId, Task, }; @@ -167,7 +167,7 @@ impl Scope { pub fn schedule_update_any(&self) -> Arc { let chan = self.sender(); Arc::new(move |id| { - chan.unbounded_send(SchedulerMsg::Immediate(id)).unwrap(); + _ = chan.unbounded_send(SchedulerMsg::Immediate(id)); }) } @@ -545,9 +545,7 @@ impl ScopeId { /// Consume context from the current scope pub fn consume_context(self) -> Option { - Runtime::with_scope(self, |cx| cx.consume_context::()) - .ok() - .flatten() + Runtime::with_scope(self, |cx| cx.consume_context::()).expect("Runtime to exist") } /// Consume context from the current scope @@ -651,7 +649,15 @@ impl ScopeId { /// } /// ``` pub fn throw_error(self, error: impl Into + 'static) { - throw_into(error, self) + let error = error.into(); + if let Some(cx) = self.consume_context::() { + cx.insert_error(error) + } else { + tracing::error!( + "Tried to throw an error into an error boundary, but failed to locate a boundary: {:?}", + error + ) + } } /// Get the suspense context the current scope is in diff --git a/packages/core/src/scopes.rs b/packages/core/src/scopes.rs index 02e4260df..ea45252d0 100644 --- a/packages/core/src/scopes.rs +++ b/packages/core/src/scopes.rs @@ -1,6 +1,6 @@ use crate::{ - any_props::BoxedAnyProps, nodes::AsVNode, reactive_context::ReactiveContext, - scope_context::Scope, Element, Runtime, VNode, + any_props::BoxedAnyProps, reactive_context::ReactiveContext, scope_context::Scope, Element, + RenderError, Runtime, VNode, }; use std::{cell::Ref, rc::Rc}; @@ -76,11 +76,44 @@ pub struct ScopeState { pub(crate) context_id: ScopeId, /// The last node that has been rendered for this component. This node may not ben mounted /// During suspense, this component can be rendered in the background multiple times - pub(crate) last_rendered_node: Option, + pub(crate) last_rendered_node: Option, pub(crate) props: BoxedAnyProps, pub(crate) reactive_context: ReactiveContext, } +#[derive(Clone, PartialEq, Debug)] +pub enum LastRenderedNode { + Real(VNode), + Placeholder(VNode, RenderError), +} + +impl std::ops::Deref for LastRenderedNode { + type Target = VNode; + + fn deref(&self) -> &Self::Target { + match self { + LastRenderedNode::Real(vnode) => vnode, + LastRenderedNode::Placeholder(vnode, _err) => vnode, + } + } +} + +impl LastRenderedNode { + pub fn new(node: Element) -> Self { + match node { + Ok(vnode) => LastRenderedNode::Real(vnode), + Err(err) => LastRenderedNode::Placeholder(VNode::placeholder(), err), + } + } + + pub fn as_vnode(&self) -> &VNode { + match self { + LastRenderedNode::Real(vnode) => vnode, + LastRenderedNode::Placeholder(vnode, _err) => vnode, + } + } +} + impl Drop for ScopeState { fn drop(&mut self) { self.runtime.remove_scope(self.context_id); @@ -104,7 +137,11 @@ impl ScopeState { /// /// Returns [`None`] if the tree has not been built yet. pub fn try_root_node(&self) -> Option<&VNode> { - self.last_rendered_node.as_ref().map(AsVNode::as_vnode) + match &self.last_rendered_node { + Some(LastRenderedNode::Real(vnode)) => Some(vnode), + Some(LastRenderedNode::Placeholder(vnode, _)) => Some(vnode), + None => None, + } } /// Returns the scope id of this [`ScopeState`]. diff --git a/packages/core/src/suspense/component.rs b/packages/core/src/suspense/component.rs index 8e3eb62a4..5fe3ba1af 100644 --- a/packages/core/src/suspense/component.rs +++ b/packages/core/src/suspense/component.rs @@ -5,7 +5,7 @@ use crate::{innerlude::*, scope_context::SuspenseLocation}; pub struct SuspenseBoundaryProps { fallback: Callback, /// The children of the suspense boundary - children: Element, + children: LastRenderedNode, } impl Clone for SuspenseBoundaryProps { @@ -205,7 +205,10 @@ impl<__children: SuspenseBoundaryPropsBuilder_Optional> let fallback = fallback.0; let children = SuspenseBoundaryPropsBuilder_Optional::into_value(children, VNode::empty); SuspenseBoundaryPropsWithOwner { - inner: SuspenseBoundaryProps { fallback, children }, + inner: SuspenseBoundaryProps { + fallback, + children: LastRenderedNode::new(children), + }, owner: self.owner, } } @@ -229,13 +232,7 @@ impl ::core::cmp::PartialEq for SuspenseBoundaryProps { /// fn App() -> Element { /// rsx! { /// SuspenseBoundary { -/// fallback: |context: SuspenseContext| rsx! { -/// if let Some(placeholder) = context.suspense_placeholder() { -/// {placeholder} -/// } else { -/// "Loading..." -/// } -/// }, +/// fallback: |_| rsx! { "Loading..." }, /// Article {} /// } /// } @@ -310,7 +307,7 @@ impl SuspenseBoundaryProps { // First always render the children in the background. Rendering the children may cause this boundary to suspend suspense_context.under_suspense_boundary(&dom.runtime(), || { - children.as_vnode().create(dom, parent, None::<&mut M>); + children.create(dom, parent, None::<&mut M>); }); // Store the (now mounted) children back into the scope state @@ -325,6 +322,7 @@ impl SuspenseBoundaryProps { .suspense_context() .unwrap() .clone(); + // If there are suspended futures, render the fallback let nodes_created = if !suspense_context.suspended_futures().is_empty() { let (node, nodes_created) = @@ -337,9 +335,10 @@ impl SuspenseBoundaryProps { scope_id, ) .unwrap(); - suspense_context.set_suspended_nodes(children.into()); - let suspense_placeholder = props.fallback.call(suspense_context); - let nodes_created = suspense_placeholder.as_vnode().create(dom, parent, to); + suspense_context.set_suspended_nodes(children.as_vnode().clone()); + let suspense_placeholder = + LastRenderedNode::new(props.fallback.call(suspense_context)); + let nodes_created = suspense_placeholder.create(dom, parent, to); (suspense_placeholder, nodes_created) }); @@ -349,13 +348,11 @@ impl SuspenseBoundaryProps { nodes_created } else { // Otherwise just render the children in the real dom - debug_assert!(children.as_vnode().mount.get().mounted()); + debug_assert!(children.mount.get().mounted()); let nodes_created = suspense_context - .under_suspense_boundary(&dom.runtime(), || { - children.as_vnode().create(dom, parent, to) - }); + .under_suspense_boundary(&dom.runtime(), || children.create(dom, parent, to)); let scope_state = &mut dom.scopes[scope_id.0]; - scope_state.last_rendered_node = Some(children); + scope_state.last_rendered_node = children.into(); let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id) .unwrap(); @@ -395,8 +392,8 @@ impl SuspenseBoundaryProps { suspense_context.inner.suspended_tasks.borrow_mut().clear(); // Get the parent of the suspense boundary to later create children with the right parent - let currently_rendered = scope_state.last_rendered_node.as_ref().unwrap().clone(); - let mount = currently_rendered.as_vnode().mount.get(); + let currently_rendered = scope_state.last_rendered_node.clone().unwrap(); + let mount = currently_rendered.mount.get(); let parent = { let mounts = dom.runtime.mounts.borrow(); mounts @@ -412,24 +409,24 @@ impl SuspenseBoundaryProps { let suspense_context = SuspenseContext::downcast_suspense_boundary_from_scope(&dom.runtime, scope_id) .unwrap(); + // Take the suspended nodes out of the suspense boundary so the children know that the boundary is not suspended while diffing let suspended = suspense_context.take_suspended_nodes(); if let Some(node) = suspended { node.remove_node(&mut *dom, None::<&mut M>, None); } + // Replace the rendered nodes with resolved nodes - currently_rendered - .as_vnode() - .remove_node(&mut *dom, Some(to), Some(replace_with)); + currently_rendered.remove_node(&mut *dom, Some(to), Some(replace_with)); // Switch to only writing templates only_write_templates(to); - children.as_vnode().mount.take(); + children.mount.take(); // First always render the children in the background. Rendering the children may cause this boundary to suspend suspense_context.under_suspense_boundary(&dom.runtime(), || { - children.as_vnode().create(dom, parent, Some(to)); + children.create(dom, parent, Some(to)); }); // Store the (now mounted) children back into the scope state @@ -454,7 +451,7 @@ impl SuspenseBoundaryProps { .unwrap() .clone(); - let last_rendered_node = scope.last_rendered_node.as_ref().unwrap().clone(); + let last_rendered_node = scope.last_rendered_node.clone().unwrap(); let Self { fallback, children, .. @@ -467,19 +464,16 @@ impl SuspenseBoundaryProps { // We already have suspended nodes that still need to be suspended // Just diff the normal and suspended nodes (Some(suspended_nodes), true) => { - let new_suspended_nodes: VNode = children.into(); + let new_suspended_nodes: VNode = children.as_vnode().clone(); // Diff the placeholder nodes in the dom let new_placeholder = suspense_context.in_suspense_placeholder(&dom.runtime(), || { let old_placeholder = last_rendered_node; - let new_placeholder = fallback.call(suspense_context.clone()); + let new_placeholder = + LastRenderedNode::new(fallback.call(suspense_context.clone())); - old_placeholder.as_vnode().diff_node( - new_placeholder.as_vnode(), - dom, - to, - ); + old_placeholder.diff_node(&new_placeholder, dom, to); new_placeholder }); @@ -504,20 +498,19 @@ impl SuspenseBoundaryProps { let new_children = children; suspense_context.under_suspense_boundary(&dom.runtime(), || { - old_children - .as_vnode() - .diff_node(new_children.as_vnode(), dom, to); + old_children.diff_node(&new_children, dom, to); }); // Set the last rendered node to the new children - dom.scopes[scope_id.0].last_rendered_node = Some(new_children); + dom.scopes[scope_id.0].last_rendered_node = new_children.into(); } // We have no suspended nodes, but we just became suspended. Move the children to the background (None, true) => { - let old_children = last_rendered_node.as_vnode(); - let new_children: VNode = children.into(); + let old_children = last_rendered_node; + let new_children: VNode = children.as_vnode().clone(); - let new_placeholder = fallback.call(suspense_context.clone()); + let new_placeholder = + LastRenderedNode::new(fallback.call(suspense_context.clone())); // Move the children to the background let mount = old_children.mount.get(); @@ -525,7 +518,7 @@ impl SuspenseBoundaryProps { suspense_context.in_suspense_placeholder(&dom.runtime(), || { old_children.move_node_to_background( - std::slice::from_ref(new_placeholder.as_vnode()), + std::slice::from_ref(&new_placeholder), parent, dom, to, @@ -558,13 +551,13 @@ impl SuspenseBoundaryProps { // First diff the two children nodes in the background suspense_context.under_suspense_boundary(&dom.runtime(), || { - old_suspended_nodes.diff_node(new_children.as_vnode(), dom, None::<&mut M>); + old_suspended_nodes.diff_node(&new_children, dom, None::<&mut M>); // Then replace the placeholder with the new children - let mount = old_placeholder.as_vnode().mount.get(); + let mount = old_placeholder.mount.get(); let parent = dom.get_mounted_parent(mount); - old_placeholder.as_vnode().replace( - std::slice::from_ref(new_children.as_vnode()), + old_placeholder.replace( + std::slice::from_ref(&new_children), parent, dom, to, diff --git a/packages/core/src/suspense/mod.rs b/packages/core/src/suspense/mod.rs index bccb53b0c..da2d48174 100644 --- a/packages/core/src/suspense/mod.rs +++ b/packages/core/src/suspense/mod.rs @@ -37,50 +37,38 @@ use std::{ #[derive(Clone, PartialEq, Debug)] pub struct SuspendedFuture { origin: ScopeId, - task: Task, - pub(crate) placeholder: VNode, + task: TaskId, } impl SuspendedFuture { /// Create a new suspended future pub fn new(task: Task) -> Self { Self { - task, + task: task.id, origin: current_scope_id().unwrap_or_else(|e| panic!("{}", e)), - placeholder: VNode::placeholder(), } } - /// Get a placeholder to display while the future is suspended - pub fn suspense_placeholder(&self) -> Option { - if self.placeholder == VNode::placeholder() { - None - } else { - Some(self.placeholder.clone()) - } - } - - /// Set a new placeholder the SuspenseBoundary may use to display while the future is suspended - pub fn with_placeholder(mut self, placeholder: VNode) -> Self { - self.placeholder = placeholder; - self - } - /// Get the task that was suspended pub fn task(&self) -> Task { - self.task + Task::from_id(self.task) } /// Create a deep clone of this suspended future pub(crate) fn deep_clone(&self) -> Self { Self { task: self.task, - placeholder: self.placeholder.deep_clone(), origin: self.origin, } } } +impl std::fmt::Display for SuspendedFuture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "SuspendedFuture {{ task: {:?} }}", self.task) + } +} + /// A context with information about suspended components #[derive(Debug, Clone)] pub struct SuspenseContext { @@ -165,7 +153,7 @@ impl SuspenseContext { self.inner .suspended_tasks .borrow_mut() - .retain(|t| t.task != task); + .retain(|t| t.task != task.id); self.inner.id.get().needs_update(); } @@ -176,16 +164,6 @@ impl SuspenseContext { }) } - /// Get the first suspended task with a loading placeholder - pub fn suspense_placeholder(&self) -> Option { - self.inner - .suspended_tasks - .borrow() - .iter() - .find_map(|task| task.suspense_placeholder()) - .map(std::result::Result::Ok) - } - /// Run a closure after suspense is resolved pub fn after_suspense_resolved(&self, callback: impl FnOnce() + 'static) { let mut closures = self.inner.after_suspense_resolved.borrow_mut(); @@ -226,37 +204,3 @@ impl Debug for SuspenseBoundaryInner { .finish() } } - -/// Provides context methods to [`Result`] to show loading indicators for suspended results -/// -/// This trait is sealed and cannot be implemented outside of dioxus-core -pub trait SuspenseExtension: private::Sealed { - /// Add a loading indicator if the result is suspended - fn with_loading_placeholder( - self, - display_placeholder: impl FnOnce() -> Element, - ) -> std::result::Result; -} - -impl SuspenseExtension for std::result::Result { - fn with_loading_placeholder( - self, - display_placeholder: impl FnOnce() -> Element, - ) -> std::result::Result { - if let Err(RenderError::Suspended(suspense)) = self { - Err(RenderError::Suspended(suspense.with_placeholder( - display_placeholder().unwrap_or_default(), - ))) - } else { - self - } - } -} - -pub(crate) mod private { - use super::*; - - pub trait Sealed {} - - impl Sealed for std::result::Result {} -} diff --git a/packages/core/src/tasks.rs b/packages/core/src/tasks.rs index e11f3a19c..70ce6c3e6 100644 --- a/packages/core/src/tasks.rs +++ b/packages/core/src/tasks.rs @@ -20,11 +20,13 @@ use std::{pin::Pin, task::Poll}; #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] pub struct Task { - pub(crate) id: slotmap::DefaultKey, + pub(crate) id: TaskId, // We add a raw pointer to make this !Send + !Sync unsend: PhantomData<*const ()>, } +pub(crate) type TaskId = slotmap::DefaultKey; + impl Task { /// Create a task from a raw id pub(crate) const fn from_id(id: slotmap::DefaultKey) -> Self { @@ -48,8 +50,6 @@ impl Task { } /// Drop the task immediately. - /// - /// This does not abort the task, so you'll want to wrap it in an abort handle if that's important to you pub fn cancel(self) { remove_future(self); } @@ -382,6 +382,7 @@ impl TaskType { #[derive(Debug)] pub(crate) enum SchedulerMsg { /// All components have been marked as dirty, requiring a full render + #[allow(unused)] AllDirty, /// Immediate updates from Components that mark them as dirty diff --git a/packages/core/src/virtual_dom.rs b/packages/core/src/virtual_dom.rs index a7774e7db..7a64e1265 100644 --- a/packages/core/src/virtual_dom.rs +++ b/packages/core/src/virtual_dom.rs @@ -2,7 +2,6 @@ //! //! This module provides the primary mechanics to create a hook-based, concurrent VDOM for Rust. -use crate::innerlude::Work; use crate::properties::RootProps; use crate::root_wrapper::RootScopeWrapper; use crate::{ @@ -12,6 +11,7 @@ use crate::{ scopes::ScopeId, ComponentFunction, Element, Mutations, }; +use crate::{innerlude::Work, scopes::LastRenderedNode}; use crate::{Task, VComponent}; use futures_util::StreamExt; use slab::Slab; @@ -351,6 +351,11 @@ impl VirtualDom { f() } + /// Run a closure inside a specific scope + pub fn in_scope(&self, scope: ScopeId, f: impl FnOnce() -> T) -> T { + self.in_runtime(|| scope.in_runtime(f)) + } + /// Build the virtualdom with a global context inserted into the base scope /// /// This is useful for what is essentially dependency injection when building the app @@ -578,6 +583,8 @@ impl VirtualDom { .clone() .while_rendering(|| self.run_scope(ScopeId::ROOT)); + let new_nodes = LastRenderedNode::new(new_nodes); + self.scopes[ScopeId::ROOT.0].last_rendered_node = Some(new_nodes.clone()); // Rebuilding implies we append the created elements to the root diff --git a/packages/desktop/Cargo.toml b/packages/desktop/Cargo.toml index 5d3bbb828..611c26daa 100644 --- a/packages/desktop/Cargo.toml +++ b/packages/desktop/Cargo.toml @@ -12,7 +12,7 @@ rust-version = "1.76.0" [dependencies] dioxus-core = { workspace = true, features = ["serialize"] } -dioxus-html = { workspace = true, features = ["serialize", "mounted", "file_engine"] } +dioxus-html = { workspace = true, features = ["serialize"] } dioxus-document = { workspace = true } dioxus-signals = { workspace = true, optional = true } dioxus-interpreter-js = { workspace = true, features = ["binary-protocol", "serialize"] } @@ -50,6 +50,7 @@ base64 = { workspace = true } libc = "0.2.174" rand = { workspace = true, features = ["std_rng"] } subtle = { version = "2.6", features = ["const-generics"] } +bytes = { workspace = true } webbrowser = { workspace = true } [target.'cfg(unix)'.dependencies] diff --git a/packages/desktop/src/app.rs b/packages/desktop/src/app.rs index bcf36203f..95848853f 100644 --- a/packages/desktop/src/app.rs +++ b/packages/desktop/src/app.rs @@ -2,7 +2,7 @@ use crate::{ config::{Config, WindowCloseBehaviour}, edits::EditWebsocket, event_handlers::WindowEventHandlers, - file_upload::{DesktopFileUploadForm, FileDialogRequest, NativeFileEngine}, + file_upload::{DesktopFileUploadForm, FileDialogRequest}, ipc::{IpcMessage, UserWindowEvent}, query::QueryResult, shortcut::ShortcutRegistry, @@ -16,7 +16,6 @@ use std::{ cell::{Cell, RefCell}, collections::HashMap, rc::Rc, - sync::Arc, time::Duration, }; use tao::{ @@ -420,9 +419,7 @@ impl App { let event_bubbles = file_dialog.bubbles; let files = file_dialog.get_file_event(); - let as_any = Box::new(DesktopFileUploadForm { - files: Arc::new(NativeFileEngine::new(files)), - }); + let as_any = Box::new(DesktopFileUploadForm { files }); let data = Rc::new(PlatformEventData::new(as_any)); diff --git a/packages/desktop/src/desktop_context.rs b/packages/desktop/src/desktop_context.rs index 8684bb034..57d42ca8e 100644 --- a/packages/desktop/src/desktop_context.rs +++ b/packages/desktop/src/desktop_context.rs @@ -354,7 +354,7 @@ fn is_main_thread() -> bool { /// # } /// ``` pub struct PendingDesktopContext { - pub(crate) receiver: tokio::sync::oneshot::Receiver, + pub(crate) receiver: futures_channel::oneshot::Receiver, } impl PendingDesktopContext { @@ -366,9 +366,7 @@ impl PendingDesktopContext { } /// Try to resolve the pending context into a [`DesktopContext`]. - pub async fn try_resolve( - self, - ) -> Result { + pub async fn try_resolve(self) -> Result { self.receiver.await } } diff --git a/packages/desktop/src/file_upload.rs b/packages/desktop/src/file_upload.rs index fcf4cc8d1..41a874390 100644 --- a/packages/desktop/src/file_upload.rs +++ b/packages/desktop/src/file_upload.rs @@ -11,8 +11,8 @@ use dioxus_html::{ point_interaction::{ InteractionElementOffset, InteractionLocation, ModifiersInteraction, PointerInteraction, }, - FileEngine, HasDragData, HasFileData, HasFormData, HasMouseData, SerializedMouseData, - SerializedPointInteraction, + FileData, HasDragData, HasFileData, HasFormData, HasMouseData, NativeFileData, + SerializedMouseData, SerializedPointInteraction, }; use serde::Deserialize; @@ -155,12 +155,16 @@ impl FromStr for Filters { #[derive(Clone)] pub(crate) struct DesktopFileUploadForm { - pub files: Arc, + pub files: Vec, } impl HasFileData for DesktopFileUploadForm { - fn files(&self) -> Option> { - Some(self.files.clone()) + fn files(&self) -> Vec { + self.files + .iter() + .cloned() + .map(|f| FileData::new(DesktopFileData(f))) + .collect() } } @@ -187,12 +191,16 @@ impl NativeFileHover { #[derive(Clone)] pub(crate) struct DesktopFileDragEvent { pub mouse: SerializedPointInteraction, - pub files: Arc, + pub files: Vec, } impl HasFileData for DesktopFileDragEvent { - fn files(&self) -> Option> { - Some(self.files.clone()) + fn files(&self) -> Vec { + self.files + .iter() + .cloned() + .map(|f| FileData::new(DesktopFileData(f))) + .collect() } } @@ -248,78 +256,93 @@ impl PointerInteraction for DesktopFileDragEvent { } } -pub struct NativeFileEngine { - files: Vec, -} +#[derive(Clone)] +pub struct DesktopFileData(pub(crate) PathBuf); -impl NativeFileEngine { - pub fn new(files: Vec) -> Self { - Self { files } +impl NativeFileData for DesktopFileData { + fn name(&self) -> String { + self.0.file_name().unwrap().to_string_lossy().into_owned() } -} -#[async_trait::async_trait(?Send)] -impl FileEngine for NativeFileEngine { - fn files(&self) -> Vec { - self.files - .iter() - .filter_map(|f| Some(f.to_str()?.to_string())) - .collect() + fn size(&self) -> u64 { + std::fs::metadata(&self.0).map(|m| m.len()).unwrap_or(0) } - async fn file_size(&self, file: &str) -> Option { - #[cfg(feature = "tokio_runtime")] - { - let file = File::open(file).await.ok()?; - Some(file.metadata().await.ok()?.len()) - } - #[cfg(not(feature = "tokio_runtime"))] - { - None - } + fn last_modified(&self) -> u64 { + std::fs::metadata(&self.0) + .and_then(|m| m.modified()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + .unwrap_or(0) } - async fn read_file(&self, file: &str) -> Option> { - #[cfg(feature = "tokio_runtime")] - { - let mut file = File::open(file).await.ok()?; + fn read_bytes( + &self, + ) -> std::pin::Pin< + Box> + 'static>, + > { + let path = self.0.clone(); + Box::pin(async move { Ok(bytes::Bytes::from(std::fs::read(&path)?)) }) + } - let mut contents = Vec::new(); - file.read_to_end(&mut contents).await.ok()?; + fn read_string( + &self, + ) -> std::pin::Pin< + Box> + 'static>, + > { + let path = self.0.clone(); + Box::pin(async move { Ok(std::fs::read_to_string(&path)?) }) + } - Some(contents) - } - #[cfg(not(feature = "tokio_runtime"))] - { - None - } + fn inner(&self) -> &dyn std::any::Any { + &self.0 + } + + fn path(&self) -> PathBuf { + self.0.clone() } - async fn read_file_to_string(&self, file: &str) -> Option { + fn byte_stream( + &self, + ) -> std::pin::Pin< + Box< + dyn futures_util::Stream> + + 'static + + Send, + >, + > { + let path = self.0.clone(); #[cfg(feature = "tokio_runtime")] { - let mut file = File::open(file).await.ok()?; + // todo!() + // use futures_util::TryFutureExt; - let mut contents = String::new(); - file.read_to_string(&mut contents).await.ok()?; - - Some(contents) - } - #[cfg(not(feature = "tokio_runtime"))] - { - None + // futures_util::stream::try_unfold(File::open(path), |mut file| async move { + // let mut buf = vec![0; 8192]; + // let n = file + // .read(&mut buf) + // .await + // .map_err(|e| dioxus_core::Error::from(e))?; + // if n == 0 { + // Ok(None) + // } else { + // buf.truncate(n); + // Ok(Some((bytes::Bytes::from(buf), file))) + // } + // }) + // .map(|res| res.map(bytes::Bytes::from)) + // .boxed() } + todo!() } - async fn get_native_file(&self, file: &str) -> Option> { - #[cfg(feature = "tokio_runtime")] - { - let file = File::open(file).await.ok()?; - Some(Box::new(file)) - } - #[cfg(not(feature = "tokio_runtime"))] - { - None - } + fn content_type(&self) -> Option { + Some( + dioxus_asset_resolver::native::get_mime_from_ext( + self.0.extension().and_then(|ext| ext.to_str()), + ) + .to_string(), + ) } } diff --git a/packages/desktop/src/webview.rs b/packages/desktop/src/webview.rs index 14489a2bd..fdd0c33c3 100644 --- a/packages/desktop/src/webview.rs +++ b/packages/desktop/src/webview.rs @@ -4,14 +4,9 @@ use crate::menubar::DioxusMenu; use crate::PendingDesktopContext; use crate::WindowCloseBehaviour; use crate::{ - app::SharedContext, - assets::AssetHandlerRegistry, - edits::WryQueue, - file_upload::{NativeFileEngine, NativeFileHover}, - ipc::UserWindowEvent, - protocol, - waker::tao_waker, - Config, DesktopContext, DesktopService, + app::SharedContext, assets::AssetHandlerRegistry, edits::WryQueue, + file_upload::NativeFileHover, ipc::UserWindowEvent, protocol, waker::tao_waker, Config, + DesktopContext, DesktopService, }; use crate::{document::DesktopDocument, WeakDesktopContext}; use base64::prelude::BASE64_STANDARD; @@ -19,10 +14,9 @@ use dioxus_core::{Runtime, ScopeId, VirtualDom}; use dioxus_document::Document; use dioxus_history::{History, MemoryHistory}; use dioxus_hooks::to_owned; -use dioxus_html::{HasFileData, HtmlEvent, PlatformEventData}; +use dioxus_html::{HtmlEvent, PlatformEventData}; use futures_util::{pin_mut, FutureExt}; use std::sync::atomic::AtomicBool; -use std::sync::Arc; use std::{cell::OnceCell, time::Duration}; use std::{rc::Rc, task::Waker}; use wry::{DragDropEvent, RequestAsyncResponder, WebContext, WebViewBuilder, WebViewId}; @@ -90,7 +84,7 @@ impl WebviewEdits { } Err(err) => { tracing::error!( - "Error parsing user_event: {:?}.Contents: {:?}, raw: {:#?}", + "Error parsing user_event: {:?}. \n Contents: {:?}, \nraw: {:#?}", err, String::from_utf8(request.body().to_vec()), request @@ -131,20 +125,17 @@ impl WebviewEdits { } dioxus_html::EventData::Drag(ref drag) => { // we want to override this with a native file engine, provided by the most recent drag event - if drag.files().is_some() { - let file_event = recent_file.current().unwrap(); - let paths = match file_event { - wry::DragDropEvent::Enter { paths, .. } => paths, - wry::DragDropEvent::Drop { paths, .. } => paths, - _ => vec![], - }; - Rc::new(PlatformEventData::new(Box::new(DesktopFileDragEvent { - mouse: drag.mouse.clone(), - files: Arc::new(NativeFileEngine::new(paths)), - }))) - } else { - data.into_any() - } + let file_event = recent_file.current().unwrap(); + let paths = match file_event { + wry::DragDropEvent::Enter { paths, .. } => paths, + wry::DragDropEvent::Drop { paths, .. } => paths, + _ => vec![], + }; + + Rc::new(PlatformEventData::new(Box::new(DesktopFileDragEvent { + mouse: drag.mouse.clone(), + files: paths, + }))) } _ => data.into_any(), }; @@ -561,12 +552,12 @@ impl SynchronousEventResponse { pub(crate) struct PendingWebview { dom: VirtualDom, cfg: Config, - sender: tokio::sync::oneshot::Sender, + sender: futures_channel::oneshot::Sender, } impl PendingWebview { pub(crate) fn new(dom: VirtualDom, cfg: Config) -> (Self, PendingDesktopContext) { - let (sender, receiver) = tokio::sync::oneshot::channel(); + let (sender, receiver) = futures_channel::oneshot::channel(); let webview = Self { dom, cfg, sender }; let pending = PendingDesktopContext { receiver }; (webview, pending) diff --git a/packages/dioxus/Cargo.toml b/packages/dioxus/Cargo.toml index 715be9907..864488537 100644 --- a/packages/dioxus/Cargo.toml +++ b/packages/dioxus/Cargo.toml @@ -24,17 +24,17 @@ dioxus-signals = { workspace = true, optional = true } dioxus-router = { workspace = true, optional = true } dioxus-web = { workspace = true, default-features = false, optional = true } dioxus-desktop = { workspace = true, default-features = true, optional = true } -dioxus-fullstack = { workspace = true, default-features = true, optional = true } dioxus-liveview = { workspace = true, optional = true } -dioxus-server = { workspace = true, optional = true } dioxus-ssr = { workspace = true, optional = true } dioxus-native = { workspace = true, optional = true } -dioxus_server_macro = { workspace = true, optional = true } +dioxus-server = { workspace = true, default-features = true, optional = true } +dioxus-fullstack = { workspace = true, default-features = true, optional = true } +dioxus-fullstack-macro = { workspace = true, optional = true } dioxus-asset-resolver = { workspace = true, optional = true } manganis = { workspace = true, features = ["dioxus"], optional = true } dioxus-logger = { workspace = true, optional = true } warnings = { workspace = true, optional = true } -wasm-splitter = { workspace = true, package = "wasm-split", optional = true } +wasm-splitter = { workspace = true, optional = true } subsecond = { workspace = true } serde = { workspace = true, optional = true } @@ -55,7 +55,6 @@ lib = [ "signals", "hooks", "mounted", - "file_engine", "document", "asset", "warnings", @@ -67,9 +66,8 @@ signals = ["dep:dioxus-signals", "dep:dioxus-stores"] macro = ["dep:dioxus-core-macro"] html = ["dep:dioxus-html"] hooks = ["dep:dioxus-hooks"] -devtools = ["dep:dioxus-devtools", "dioxus-web?/devtools", "dioxus-fullstack?/devtools"] +devtools = ["dep:dioxus-devtools", "dioxus-web?/devtools"] mounted = ["dioxus-web?/mounted"] -file_engine = ["dioxus-web?/file_engine"] asset = ["dep:manganis", "dep:dioxus-asset-resolver"] document = ["dioxus-web?/document", "dep:dioxus-document", "dep:dioxus-history"] logger = ["dep:dioxus-logger"] @@ -84,9 +82,19 @@ launch = ["dep:dioxus-config-macro"] router = ["dep:dioxus-router"] # Platforms -fullstack = ["dep:dioxus-fullstack", "dioxus-config-macro/fullstack", "dep:serde"] -desktop = ["dep:dioxus-desktop", "dioxus-fullstack?/desktop", "dioxus-config-macro/desktop"] -mobile = ["dep:dioxus-desktop", "dioxus-fullstack?/mobile", "dioxus-config-macro/mobile"] +fullstack = [ + "dep:dioxus-fullstack", + "dioxus-config-macro/fullstack", + "dep:serde", + "dioxus-web?/document", + "dioxus-web?/hydrate", + "dioxus-server?/document", + "dioxus-web?/devtools", + "dioxus-web?/mounted", + "dioxus-web?/document" +] +desktop = ["dep:dioxus-desktop", "dioxus-config-macro/desktop"] +mobile = ["dep:dioxus-desktop", "dioxus-config-macro/mobile"] web = [ "dep:dioxus-web", "dioxus-fullstack?/web", @@ -100,12 +108,10 @@ liveview = ["dep:dioxus-liveview", "dioxus-config-macro/liveview"] native = ["dep:dioxus-native"] # todo(jon): decompose the desktop crate such that "webview" is the default and native is opt-in server = [ "dep:dioxus-server", - "dep:dioxus_server_macro", - "dioxus_server_macro/server", - "dioxus_server_macro/axum", + "dioxus-fullstack?/server", + "dep:dioxus-fullstack-macro", "ssr", "dioxus-liveview?/axum", - "dioxus-fullstack?/server", ] # This feature just disables the no-renderer-enabled warning diff --git a/packages/dioxus/src/launch.rs b/packages/dioxus/src/launch.rs index 6eb24641d..60949880c 100644 --- a/packages/dioxus/src/launch.rs +++ b/packages/dioxus/src/launch.rs @@ -40,9 +40,12 @@ use crate::prelude::*; /// /// # Example /// ```rust, no_run -/// # use dioxus::prelude::*; +/// use dioxus::prelude::*; +/// +/// fn main() { +/// dioxus::launch(app); +/// } /// -/// dioxus::launch(app); /// fn app() -> Element { /// rsx! { /// div { "Hello, world!" } @@ -299,7 +302,7 @@ impl LaunchBuilder { // Set any flags if we're running under fullstack #[cfg(feature = "fullstack")] { - use dioxus_fullstack::server_fn::client::{get_server_url, set_server_url}; + use dioxus_fullstack::{get_server_url, set_server_url}; // Make sure to set the server_fn endpoint if the user specified the fullstack feature // We only set this on native targets diff --git a/packages/dioxus/src/lib.rs b/packages/dioxus/src/lib.rs index 392d2598d..a00bae076 100644 --- a/packages/dioxus/src/lib.rs +++ b/packages/dioxus/src/lib.rs @@ -27,7 +27,7 @@ pub use dioxus_core; #[doc(inline)] -pub use dioxus_core::{CapturedError, Ok, Result}; +pub use dioxus_core::{CapturedError, Error, Ok, Result}; #[cfg(feature = "launch")] #[cfg_attr(docsrs, doc(cfg(feature = "launch")))] @@ -85,6 +85,9 @@ pub use dioxus_cli_config as cli_config; #[cfg_attr(docsrs, doc(cfg(feature = "server")))] pub use dioxus_server as server; +#[cfg(feature = "server")] +pub use dioxus_server::serve; + #[cfg(feature = "devtools")] #[cfg_attr(docsrs, doc(cfg(feature = "devtools")))] pub use dioxus_devtools as devtools; @@ -158,7 +161,7 @@ pub mod prelude { #[cfg(feature = "signals")] #[cfg_attr(docsrs, doc(cfg(feature = "signals")))] #[doc(inline)] - pub use dioxus_signals::*; + pub use dioxus_signals::{self, *}; #[cfg(feature = "signals")] pub use dioxus_stores::{self, store, use_store, GlobalStore, ReadStore, Store, WriteStore}; @@ -200,14 +203,16 @@ pub mod prelude { #[cfg_attr(docsrs, doc(cfg(feature = "fullstack")))] #[doc(inline)] pub use dioxus_fullstack::{ - server, server_fn, use_server_cached, use_server_future, ServerFnError, ServerFnResult, + self as dioxus_fullstack, delete, get, patch, post, put, server, use_loader, + use_server_cached, use_server_future, HttpError, OrHttpError, ServerFnError, + ServerFnResult, }; #[cfg(feature = "server")] #[cfg_attr(docsrs, doc(cfg(feature = "server")))] #[doc(inline)] pub use dioxus_server::{ - extract, DioxusRouterExt, DioxusRouterFnExt, FromContext, ServeConfig, + self, serve, DioxusRouterExt, DioxusRouterFnExt, ServeConfig, ServerFunction, }; #[cfg(feature = "router")] @@ -233,9 +238,12 @@ pub mod prelude { #[doc(inline)] pub use dioxus_core::{ - consume_context, provide_context, spawn, suspend, try_consume_context, use_hook, Attribute, - Callback, Component, Context, Element, ErrorBoundary, ErrorContext, Event, EventHandler, - Fragment, HasAttributes, IntoDynNode, RenderError, ScopeId, SuspenseBoundary, - SuspenseContext, SuspenseExtension, VNode, VirtualDom, + consume_context, provide_context, spawn, suspend, try_consume_context, use_hook, + AnyhowContext, Attribute, Callback, Component, Element, ErrorBoundary, ErrorContext, Event, + EventHandler, Fragment, HasAttributes, IntoDynNode, RenderError, Result, ScopeId, + SuspenseBoundary, SuspenseContext, VNode, VirtualDom, }; + + #[cfg(feature = "logger")] + pub use dioxus_logger::tracing::{debug, error, info, trace, warn}; } diff --git a/packages/document/src/elements/stylesheet.rs b/packages/document/src/elements/stylesheet.rs index 9099b022d..7860407fd 100644 --- a/packages/document/src/elements/stylesheet.rs +++ b/packages/document/src/elements/stylesheet.rs @@ -1,7 +1,7 @@ use super::*; /// Render a [``](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/link) tag into the head of the page with the stylesheet rel. -/// This is equivalent to the [`Link`](crate::Link) component with a slightly more ergonomic API. +/// This is equivalent to the [`Link`](Link) component with a slightly more ergonomic API. /// /// /// # Example diff --git a/packages/fullstack-hooks/Cargo.toml b/packages/fullstack-core/Cargo.toml similarity index 58% rename from packages/fullstack-hooks/Cargo.toml rename to packages/fullstack-core/Cargo.toml index 483f58b48..9fc6fae63 100644 --- a/packages/fullstack-hooks/Cargo.toml +++ b/packages/fullstack-core/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "dioxus-fullstack-hooks" +name = "dioxus-fullstack-core" authors = ["Jonathan Kelley", "Evan Almloff"] version = { workspace = true } edition = "2021" @@ -11,23 +11,32 @@ keywords = ["web", "gui", "server"] resolver = "2" [dependencies] -dioxus-core = { workspace = true } +dioxus-core = { workspace = true, features = ["serialize"]} dioxus-signals = { workspace = true } dioxus-hooks = { workspace = true } -dioxus-fullstack-protocol = { workspace = true } futures-channel = { workspace = true } serde = { workspace = true } -dioxus-history.workspace = true -dioxus-document.workspace = true -futures-util.workspace = true - -[dev-dependencies] -dioxus-fullstack = { workspace = true } -dioxus = { workspace = true, features = ["fullstack"] } +dioxus-history = { workspace = true } +dioxus-document = { workspace = true } +ciborium = { workspace = true } +base64 = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +axum-core = { workspace = true } +http = { workspace = true } +anyhow = { workspace = true } +inventory = { workspace = true } +serde_json = { workspace = true } +generational-box = { workspace = true } +futures-util = { workspace = true, features = ["std"] } [features] web = [] server = [] +[dev-dependencies] +dioxus-fullstack = { workspace = true } +dioxus = { workspace = true, features = ["fullstack"] } + [package.metadata.docs.rs] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] diff --git a/packages/fullstack-hooks/README.md b/packages/fullstack-core/README.md similarity index 71% rename from packages/fullstack-hooks/README.md rename to packages/fullstack-core/README.md index f025fd32c..6961d1735 100644 --- a/packages/fullstack-hooks/README.md +++ b/packages/fullstack-core/README.md @@ -1,6 +1,6 @@ -# Dioxus Fullstack Hooks +# Dioxus Fullstack Core -Dioxus fullstack hooks provides hooks and contexts for [`dioxus-fullstack`](https://crates.io/crates/dioxus-fullstack). Libraries that need to integrate with dioxus-fullstack should rely on this crate instead of the renderer for quicker build times. +Dioxus-fullstack-core provides types, traits, hooks, contexts for [`dioxus-fullstack`](https://crates.io/crates/dioxus-fullstack). Libraries that need to integrate with dioxus-fullstack should rely on this crate instead of the full-fledged renderer for quicker build times. ## Usage @@ -14,12 +14,11 @@ Then you can use hooks like `use_server_future` in your components: ```rust use dioxus::prelude::*; -async fn fetch_article(id: u32) -> String { - format!("Article {}", id) -} + fn App() -> Element { let mut article_id = use_signal(|| 0); + // `use_server_future` will spawn a task that runs on the server and serializes the result to send to the client. // The future will rerun any time the // Since we bubble up the suspense with `?`, the server will wait for the future to resolve before rendering @@ -29,4 +28,8 @@ fn App() -> Element { "{article().unwrap()}" } } + +async fn fetch_article(id: u32) -> String { + format!("Article {}", id) +} ``` diff --git a/packages/fullstack-hooks/src/document.rs b/packages/fullstack-core/src/document.rs similarity index 95% rename from packages/fullstack-hooks/src/document.rs rename to packages/fullstack-core/src/document.rs index 570877b12..12fb42377 100644 --- a/packages/fullstack-hooks/src/document.rs +++ b/packages/fullstack-core/src/document.rs @@ -3,7 +3,7 @@ use dioxus_document::*; fn head_element_written_on_server() -> bool { - dioxus_fullstack_protocol::head_element_hydration_entry() + crate::transport::head_element_hydration_entry() .get() .ok() .unwrap_or_default() diff --git a/packages/fullstack-core/src/error.rs b/packages/fullstack-core/src/error.rs new file mode 100644 index 000000000..7660c3516 --- /dev/null +++ b/packages/fullstack-core/src/error.rs @@ -0,0 +1,223 @@ +use axum_core::response::IntoResponse; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use std::fmt::Debug; + +use crate::HttpError; + +/// A default result type for server functions, which can either be successful or contain an error. The [`ServerFnResult`] type +/// is a convenient alias for a `Result` type that uses [`ServerFnError`] as the error type. +/// +/// # Example +/// ```rust +/// use dioxus::prelude::*; +/// +/// #[server] +/// async fn parse_number(number: String) -> Result { +/// let parsed_number: f32 = number.parse()?; +/// Ok(parsed_number) +/// } +/// ``` +pub type ServerFnResult = std::result::Result; + +/// The error type for the server function system. This enum encompasses all possible errors that can occur +/// during the registration, invocation, and processing of server functions. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ServerFnError { + /// Occurs when there is an error while actually running the function on the server. + /// + /// The `details` field can optionally contain additional structured information about the error. + /// When passing typed errors from the server to the client, the `details` field contains the serialized + /// representation of the error. + #[error("error running server function: {message} (details: {details:#?})")] + ServerError { + message: String, + + /// Optional HTTP status code associated with the error. + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, + }, + + /// Occurs on the client if there is a network error while trying to run function on server. + #[error("error reaching server to call server function: {0} ")] + Request(RequestError), + + /// Occurs on the client if there is an error while trying to read the response body as a stream. + #[error("error reading response body stream: {0}")] + StreamError(String), + + /// Error while trying to register the server function (only occurs in case of poisoned RwLock). + #[error("error while trying to register the server function: {0}")] + Registration(String), + + /// Occurs on the client if trying to use an unsupported `HTTP` method when building a request. + #[error("error trying to build `HTTP` method request: {0}")] + UnsupportedRequestMethod(String), + + /// Occurs when there is an error while actually running the middleware on the server. + #[error("error running middleware: {0}")] + MiddlewareError(String), + + /// Occurs on the client if there is an error deserializing the server's response. + #[error("error deserializing server function results: {0}")] + Deserialization(String), + + /// Occurs on the client if there is an error serializing the server function arguments. + #[error("error serializing server function arguments: {0}")] + Serialization(String), + + /// Occurs on the server if there is an error deserializing one of the arguments that's been sent. + #[error("error deserializing server function arguments: {0}")] + Args(String), + + /// Occurs on the server if there's a missing argument. + #[error("missing argument {0}")] + MissingArg(String), + + /// Occurs on the server if there is an error creating an HTTP response. + #[error("error creating response {0}")] + Response(String), +} + +impl ServerFnError { + /// Create a new server error (status code 500) with a message. + pub fn new(f: impl ToString) -> Self { + ServerFnError::ServerError { + message: f.to_string(), + details: None, + code: None, + } + } +} + +impl From for ServerFnError { + fn from(value: anyhow::Error) -> Self { + ServerFnError::ServerError { + message: value.to_string(), + details: None, + code: None, + } + } +} + +impl From for ServerFnError { + fn from(value: serde_json::Error) -> Self { + ServerFnError::Deserialization(value.to_string()) + } +} + +impl From for http::StatusCode { + fn from(value: ServerFnError) -> Self { + match value { + ServerFnError::ServerError { code, .. } => match code { + Some(code) => http::StatusCode::from_u16(code) + .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), + None => http::StatusCode::INTERNAL_SERVER_ERROR, + }, + ServerFnError::Request(err) => match err { + RequestError::Status(_, code) => http::StatusCode::from_u16(code) + .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR), + _ => http::StatusCode::INTERNAL_SERVER_ERROR, + }, + ServerFnError::StreamError(_) + | ServerFnError::Registration(_) + | ServerFnError::UnsupportedRequestMethod(_) + | ServerFnError::MiddlewareError(_) + | ServerFnError::Deserialization(_) + | ServerFnError::Serialization(_) + | ServerFnError::Args(_) + | ServerFnError::MissingArg(_) + | ServerFnError::Response(_) => http::StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +impl From for ServerFnError { + fn from(value: RequestError) -> Self { + ServerFnError::Request(value) + } +} + +impl From for ServerFnError { + fn from(value: HttpError) -> Self { + ServerFnError::ServerError { + message: value.message.unwrap_or_else(|| { + value + .status + .canonical_reason() + .unwrap_or("Unknown error") + .to_string() + }), + code: Some(value.status.as_u16()), + details: None, + } + } +} + +impl IntoResponse for ServerFnError { + fn into_response(self) -> axum_core::response::Response { + todo!() + } +} + +/// An error type representing issues that can occur while making requests. +/// +/// This is made to paper over the reqwest::Error type which we don't want to export here and +/// is limited in many ways. +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RequestError { + /// An error occurred when building the request. + #[error("error building request: {0}")] + Builder(String), + + /// An error occurred when serializing the request body. + #[error("error serializing request body: {0}")] + Serialization(String), + + /// An error occurred when following a redirect. + #[error("error following redirect: {0}")] + Redirect(String), + + /// An error occurred when receiving a non-2xx status code. + #[error("error receiving status code: {0} ({1})")] + Status(String, u16), + + /// An error occurred when a request times out. + #[error("error timing out: {0}")] + Timeout(String), + + /// An error occurred when sending a request. + #[error("error sending request: {0}")] + Request(String), + + /// An error occurred when upgrading a connection. + #[error("error upgrading connection: {0}")] + Connect(String), + + /// An error occurred when there is a request or response body error. + #[error("request or response body error: {0}")] + Body(String), + + /// An error occurred when decoding the response body. + #[error("error decoding response body: {0}")] + Decode(String), +} + +impl RequestError { + pub fn status(&self) -> Option { + match self { + RequestError::Status(_, code) => Some(StatusCode::from_u16(*code).ok()?), + _ => None, + } + } + + pub fn status_code(&self) -> Option { + match self { + RequestError::Status(_, code) => Some(*code), + _ => None, + } + } +} diff --git a/packages/fullstack-hooks/src/errors.rs b/packages/fullstack-core/src/errors.rs similarity index 60% rename from packages/fullstack-hooks/src/errors.rs rename to packages/fullstack-core/src/errors.rs index 033b066b0..8b781fa64 100644 --- a/packages/fullstack-hooks/src/errors.rs +++ b/packages/fullstack-core/src/errors.rs @@ -1,26 +1,26 @@ //! Contains a hydration compatible error boundary context. +use crate::serialize_context; use dioxus_core::{spawn_isomorphic, CapturedError, ErrorContext, ReactiveContext}; -use dioxus_fullstack_protocol::serialize_context; use futures_util::StreamExt; /// Initializes an error boundary context that is compatible with hydration. pub fn init_error_boundary() -> ErrorContext { - let initial_errors = serialize_context().create_entry::>(); + let initial_error = serialize_context().create_entry::>(); let (rx_context, mut rx) = ReactiveContext::new(); - let errors = ErrorContext::new(Vec::new()); - for error in initial_errors.get().iter().flatten() { - errors.insert_error(error.clone()); + let errors = ErrorContext::new(None); + if let Ok(Some(err)) = initial_error.get() { + errors.insert_error(err); } rx_context.run_in(|| { - errors.errors(); + errors.error(); }); spawn_isomorphic({ let errors = errors.clone(); async move { if rx.next().await.is_some() { rx_context.run_in(|| { - initial_errors.insert(&errors.errors().to_vec(), std::panic::Location::caller()) + initial_error.insert(&errors.error(), std::panic::Location::caller()) }); } } diff --git a/packages/fullstack-hooks/src/history.rs b/packages/fullstack-core/src/history.rs similarity index 91% rename from packages/fullstack-hooks/src/history.rs rename to packages/fullstack-core/src/history.rs index 4354094ed..8e15c1617 100644 --- a/packages/fullstack-hooks/src/history.rs +++ b/packages/fullstack-core/src/history.rs @@ -2,8 +2,8 @@ use std::{cell::RefCell, rc::Rc}; +use crate::transport::{is_hydrating, SerializeContextEntry}; use dioxus_core::{provide_context, queue_effect, schedule_update, try_consume_context}; -use dioxus_fullstack_protocol::{is_hydrating, SerializeContextEntry}; use dioxus_history::{history, provide_history_context, History}; // If we are currently in a scope and this is the first run then queue a rerender @@ -32,18 +32,18 @@ pub(crate) fn finalize_route() { let Some(entry) = try_consume_context::() else { return; }; + let entry = entry .entry .borrow_mut() .take() .expect("Failed to get initial route from hydration context"); + if cfg!(feature = "server") { let history = history(); - let initial_route = history.current_route(); - entry.insert(&initial_route, std::panic::Location::caller()); - provide_context(ResolvedRouteContext { - route: initial_route, - }); + let route = history.current_route(); + entry.insert(&route, std::panic::Location::caller()); + provide_context(ResolvedRouteContext { route }); } else if cfg!(feature = "web") { let initial_route = entry .get() @@ -57,7 +57,7 @@ pub(crate) fn finalize_route() { /// Provide the fullstack history context. This interacts with the hydration context so it must /// be called in the same order on the client and server after the hydration context is created pub fn provide_fullstack_history_context(history: H) { - let entry = dioxus_fullstack_protocol::serialize_context().create_entry(); + let entry = crate::transport::serialize_context().create_entry(); provide_context(RouteEntry { entry: Rc::new(RefCell::new(Some(entry.clone()))), }); diff --git a/packages/fullstack-core/src/httperror.rs b/packages/fullstack-core/src/httperror.rs new file mode 100644 index 000000000..70826db25 --- /dev/null +++ b/packages/fullstack-core/src/httperror.rs @@ -0,0 +1,217 @@ +use axum_core::response::IntoResponse; +use http::StatusCode; +use std::fmt; + +/// An error type that wraps an HTTP status code and optional message. +#[derive(Debug, Clone, PartialEq)] +pub struct HttpError { + pub status: StatusCode, + pub message: Option, +} + +impl HttpError { + pub fn new>(status: StatusCode, message: M) -> Self { + HttpError { + status, + message: Some(message.into()), + } + } + + pub fn err(status: StatusCode, message: impl Into) -> Result { + Err(HttpError::new(status, message)) + } + + // --- 4xx Client Errors --- + pub fn bad_request(message: impl Into) -> Result { + Self::err(StatusCode::BAD_REQUEST, message) + } + pub fn unauthorized(message: impl Into) -> Result { + Self::err(StatusCode::UNAUTHORIZED, message) + } + pub fn payment_required(message: impl Into) -> Result { + Self::err(StatusCode::PAYMENT_REQUIRED, message) + } + pub fn forbidden(message: impl Into) -> Result { + Self::err(StatusCode::FORBIDDEN, message) + } + pub fn not_found(message: impl Into) -> Result { + Self::err(StatusCode::NOT_FOUND, message) + } + pub fn method_not_allowed(message: impl Into) -> Result { + Self::err(StatusCode::METHOD_NOT_ALLOWED, message) + } + pub fn not_acceptable(message: impl Into) -> Result { + Self::err(StatusCode::NOT_ACCEPTABLE, message) + } + pub fn proxy_auth_required(message: impl Into) -> Result { + Self::err(StatusCode::PROXY_AUTHENTICATION_REQUIRED, message) + } + pub fn request_timeout(message: impl Into) -> Result { + Self::err(StatusCode::REQUEST_TIMEOUT, message) + } + pub fn conflict(message: impl Into) -> Result { + Self::err(StatusCode::CONFLICT, message) + } + pub fn gone(message: impl Into) -> Result { + Self::err(StatusCode::GONE, message) + } + pub fn length_required(message: impl Into) -> Result { + Self::err(StatusCode::LENGTH_REQUIRED, message) + } + pub fn precondition_failed(message: impl Into) -> Result { + Self::err(StatusCode::PRECONDITION_FAILED, message) + } + pub fn payload_too_large(message: impl Into) -> Result { + Self::err(StatusCode::PAYLOAD_TOO_LARGE, message) + } + pub fn uri_too_long(message: impl Into) -> Result { + Self::err(StatusCode::URI_TOO_LONG, message) + } + pub fn unsupported_media_type(message: impl Into) -> Result { + Self::err(StatusCode::UNSUPPORTED_MEDIA_TYPE, message) + } + pub fn im_a_teapot(message: impl Into) -> Result { + Self::err(StatusCode::IM_A_TEAPOT, message) + } + pub fn too_many_requests(message: impl Into) -> Result { + Self::err(StatusCode::TOO_MANY_REQUESTS, message) + } + + // --- 5xx Server Errors --- + pub fn internal_server_error(message: impl Into) -> Result { + Self::err(StatusCode::INTERNAL_SERVER_ERROR, message) + } + pub fn not_implemented(message: impl Into) -> Result { + Self::err(StatusCode::NOT_IMPLEMENTED, message) + } + pub fn bad_gateway(message: impl Into) -> Result { + Self::err(StatusCode::BAD_GATEWAY, message) + } + pub fn service_unavailable(message: impl Into) -> Result { + Self::err(StatusCode::SERVICE_UNAVAILABLE, message) + } + pub fn gateway_timeout(message: impl Into) -> Result { + Self::err(StatusCode::GATEWAY_TIMEOUT, message) + } + pub fn http_version_not_supported(message: impl Into) -> Result { + Self::err(StatusCode::HTTP_VERSION_NOT_SUPPORTED, message) + } + + // --- 2xx/3xx (rare, but for completeness) --- + pub fn ok(message: impl Into) -> Result { + Self::err(StatusCode::OK, message) + } + pub fn created(message: impl Into) -> Result { + Self::err(StatusCode::CREATED, message) + } + pub fn accepted(message: impl Into) -> Result { + Self::err(StatusCode::ACCEPTED, message) + } + pub fn moved_permanently(message: impl Into) -> Result { + Self::err(StatusCode::MOVED_PERMANENTLY, message) + } + pub fn found(message: impl Into) -> Result { + Self::err(StatusCode::FOUND, message) + } + pub fn see_other(message: impl Into) -> Result { + Self::err(StatusCode::SEE_OTHER, message) + } + pub fn not_modified(message: impl Into) -> Result { + Self::err(StatusCode::NOT_MODIFIED, message) + } +} + +impl fmt::Display for HttpError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.message { + Some(msg) => write!(f, "{}: {}", self.status, msg), + None => write!(f, "{}", self.status), + } + } +} + +impl std::error::Error for HttpError {} + +/// Trait to convert errors into HttpError with a given status code. +pub trait OrHttpError: Sized { + fn or_http_error(self, status: StatusCode, message: impl Into) -> Result; + + // --- Most common user-facing status codes --- + fn or_bad_request(self, message: impl Into) -> Result { + self.or_http_error(StatusCode::BAD_REQUEST, message) + } + fn or_unauthorized(self, message: impl Into) -> Result { + self.or_http_error(StatusCode::UNAUTHORIZED, message) + } + fn or_forbidden(self, message: impl Into) -> Result { + self.or_http_error(StatusCode::FORBIDDEN, message) + } + fn or_not_found(self, message: impl Into) -> Result { + self.or_http_error(StatusCode::NOT_FOUND, message) + } + fn or_internal_server_error(self, message: impl Into) -> Result { + self.or_http_error(StatusCode::INTERNAL_SERVER_ERROR, message) + } +} + +impl OrHttpError for Result +where + E: std::error::Error + Send + Sync + 'static, +{ + fn or_http_error(self, status: StatusCode, message: impl Into) -> Result { + self.map_err(|_| HttpError { + status, + message: Some(message.into()), + }) + } +} + +impl OrHttpError for Option { + fn or_http_error(self, status: StatusCode, message: impl Into) -> Result { + self.ok_or_else(|| HttpError { + status, + message: Some(message.into()), + }) + } +} + +impl OrHttpError<(), ()> for bool { + fn or_http_error( + self, + status: StatusCode, + message: impl Into, + ) -> Result<(), HttpError> { + if self { + Ok(()) + } else { + Err(HttpError { + status, + message: Some(message.into()), + }) + } + } +} + +pub struct AnyhowMarker; +impl OrHttpError for Result { + fn or_http_error(self, status: StatusCode, message: impl Into) -> Result { + self.map_err(|_| HttpError { + status, + message: Some(message.into()), + }) + } +} + +impl IntoResponse for HttpError { + fn into_response(self) -> axum_core::response::Response { + let body = match &self.message { + Some(msg) => msg.clone(), + None => self + .status + .canonical_reason() + .unwrap_or("Unknown error") + .to_string(), + }; + (self.status, body).into_response() + } +} diff --git a/packages/fullstack-core/src/lib.rs b/packages/fullstack-core/src/lib.rs new file mode 100644 index 000000000..e408a7bb3 --- /dev/null +++ b/packages/fullstack-core/src/lib.rs @@ -0,0 +1,30 @@ +// #![warn(missing_docs)] +#![doc = include_str!("../README.md")] + +pub mod document; +pub mod history; + +mod errors; +mod loader; +mod server_cached; +mod server_future; +mod streaming; +mod transport; + +pub use crate::errors::*; +pub use crate::loader::*; +pub use crate::server_cached::*; +pub use crate::server_future::*; +pub use crate::streaming::*; +pub use crate::transport::*; + +/// Error types and utilities. +#[macro_use] +pub mod error; +pub use error::*; + +pub mod httperror; +pub use httperror::*; + +#[derive(Clone, Default)] +pub struct DioxusServerState {} diff --git a/packages/fullstack-core/src/loader.rs b/packages/fullstack-core/src/loader.rs new file mode 100644 index 000000000..89c0e9ea2 --- /dev/null +++ b/packages/fullstack-core/src/loader.rs @@ -0,0 +1,365 @@ +use dioxus_core::{ + spawn, use_hook, Callback, IntoAttributeValue, IntoDynNode, ReactiveContext, Subscribers, Task, +}; +use dioxus_core::{CapturedError, RenderError, Result, SuspendedFuture}; +use dioxus_hooks::{use_callback, use_signal}; +use dioxus_signals::{ + read_impls, ReadSignal, Readable, ReadableBoxExt, ReadableExt, ReadableRef, Signal, Writable, + WritableExt, WriteLock, +}; +use futures_util::{future, pin_mut, FutureExt, StreamExt}; +use generational_box::{BorrowResult, UnsyncStorage}; +use serde::{de::DeserializeOwned, Serialize}; +use std::future::Future; +use std::{cell::Cell, ops::Deref, rc::Rc}; + +/// A hook to create a resource that loads data asynchronously. +/// +/// This hook takes a closure that returns a future. This future will be executed on both the client +/// and the server. The loader will return `Loading` until the future resolves, at which point it will +/// return a `Loader`. If the future fails, it will return `Loading::Failed`. +/// +/// After the loader has successfully loaded once, it will never suspend the component again, but will +/// instead re-load the value in the background whenever any of its dependencies change. +/// +/// If an error occurs while re-loading, `use_loader` will once again emit a `Loading::Failed` value. +/// The `use_loader` hook will never return a suspended state after the initial load. +/// +/// # On the server +/// +/// On the server, this hook will block the rendering of the component (and therefore, the page) until +/// the future resolves. Any server futures called by `use_loader` will receive the same request context +/// as the component that called `use_loader`. +#[allow(clippy::result_large_err)] +#[track_caller] +pub fn use_loader(mut future: impl FnMut() -> F + 'static) -> Result, Loading> +where + F: Future> + 'static, + T: 'static + std::cmp::PartialEq + Serialize + DeserializeOwned, + E: Into + 'static, +{ + let location = std::panic::Location::caller(); + + // todo: wire up serialize context into loader + // let serialize_context = use_hook(crate::transport::serialize_context); + // // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client + // let storage_entry: SerializeContextEntry = use_hook(|| serialize_context.create_entry()); + // use crate::{transport::SerializeContextEntry, use_server_future, use_server_future_unsuspended}; + + let mut err = use_signal(|| None as Option); + let mut value = use_signal(|| None as Option); + let mut state = use_signal(|| LoaderState::Pending); + let (rc, changed) = use_hook(|| { + let (rc, changed) = ReactiveContext::new_with_origin(location); + (rc, Rc::new(Cell::new(Some(changed)))) + }); + + let callback = use_callback(move |_| { + // Set the state to Pending when the task is restarted + state.set(LoaderState::Pending); + + // Create the user's task + let fut = rc.reset_and_run_in(&mut future); + + // Spawn a wrapper task that polls the inner future and watches its dependencies + spawn(async move { + // Move the future here and pin it so we can poll it + let fut = fut; + pin_mut!(fut); + + // Run each poll in the context of the reactive scope + // This ensures the scope is properly subscribed to the future's dependencies + let res = future::poll_fn(|cx| { + rc.run_in(|| { + tracing::trace_span!("polling resource", location = %location) + .in_scope(|| fut.poll_unpin(cx)) + }) + }) + .await; + + // Map the error to the captured error type so it's cheap to clone and pass out + // We go through the anyhow::Error first since it's more useful. + let res: Result = res.map_err(|e| { + let res: dioxus_core::Error = e.into(); + res.into() + }); + + match res { + Ok(v) => { + err.set(None); + value.set(Some(v)); + state.set(LoaderState::Ready); + } + Err(e) => { + err.set(Some(e)); + state.set(LoaderState::Failed); + } + } + }) + }); + + let mut task = use_hook(|| Signal::new(callback(()))); + + use_hook(|| { + let mut changed = changed.take().unwrap(); + spawn(async move { + loop { + // Wait for the dependencies to change + let _ = changed.next().await; + + // Stop the old task + task.write().cancel(); + + // Start a new task + task.set(callback(())); + } + }) + }); + + let read_value = use_hook(|| value.map(|f| f.as_ref().unwrap()).boxed()); + + let handle = LoaderHandle { + task, + err, + callback, + state, + }; + + match &*state.read_unchecked() { + LoaderState::Pending => Err(Loading::Pending(handle)), + LoaderState::Failed => Err(Loading::Failed(handle)), + LoaderState::Ready => Ok(Loader { + real_value: value, + read_value, + error: err, + state, + task, + handle, + }), + } +} + +/// A Loader is a signal that represents a value that is loaded asynchronously. +/// +/// Once a `Loader` has been successfully created from `use_loader`, it can be use like a normal signal of type `T`. +/// +/// When the loader is re-reloading its values, it will no longer suspend its component, making it +/// very useful for server-side-rendering. +pub struct Loader { + /// This is a signal that unwraps the inner value. We can't give it out unless we know the inner value is Some(T)! + read_value: ReadSignal, + + /// This is the actual signal. We let the user set this value if they want to, but we can't let them set it to `None`. + real_value: Signal>, + error: Signal>, + state: Signal, + task: Signal, + handle: LoaderHandle, +} + +impl Loader { + /// Get the error that occurred during loading, if any. + /// + /// After initial load, this will return `None` until the next reload fails. + pub fn error(&self) -> Option { + self.error.read().as_ref().cloned() + } + + /// Restart the loading task. + /// + /// After initial load, this won't suspend the component, but will reload in the background. + pub fn restart(&mut self) { + self.handle.restart(); + } + + /// Check if the loader has failed. + pub fn is_error(&self) -> bool { + self.error.read().is_some() && matches!(*self.state.read(), LoaderState::Failed) + } + + /// Cancel the current loading task. + pub fn cancel(&self) { + self.task.read().cancel(); + } +} + +impl Writable for Loader { + type WriteMetadata = > as Writable>::WriteMetadata; + + fn try_write_unchecked( + &self, + ) -> std::result::Result< + dioxus_signals::WritableRef<'static, Self>, + generational_box::BorrowMutError, + > + where + Self::Target: 'static, + { + let writer = self.real_value.try_write_unchecked()?; + Ok(WriteLock::map(writer, |f: &mut Option| { + f.as_mut() + .expect("Loader value should be set if the `Loader` exists") + })) + } +} + +impl Readable for Loader { + type Target = T; + type Storage = UnsyncStorage; + + #[track_caller] + fn try_read_unchecked( + &self, + ) -> Result, generational_box::BorrowError> + where + T: 'static, + { + Ok(self.read_value.read_unchecked()) + } + + /// Get the current value of the signal. **Unlike read, this will not subscribe the current scope to the signal which can cause parts of your UI to not update.** + /// + /// If the signal has been dropped, this will panic. + #[track_caller] + fn try_peek_unchecked(&self) -> BorrowResult> + where + T: 'static, + { + Ok(self.peek_unchecked()) + } + + fn subscribers(&self) -> Subscribers + where + T: 'static, + { + self.read_value.subscribers() + } +} + +impl IntoAttributeValue for Loader +where + T: Clone + IntoAttributeValue + PartialEq + 'static, +{ + fn into_value(self) -> dioxus_core::AttributeValue { + self.with(|f| f.clone().into_value()) + } +} + +impl IntoDynNode for Loader +where + T: Clone + IntoDynNode + PartialEq + 'static, +{ + fn into_dyn_node(self) -> dioxus_core::DynamicNode { + self().into_dyn_node() + } +} + +impl PartialEq for Loader { + fn eq(&self, other: &Self) -> bool { + self.read_value == other.read_value + } +} + +impl Deref for Loader +where + T: PartialEq + 'static, +{ + type Target = dyn Fn() -> T; + + fn deref(&self) -> &Self::Target { + unsafe { ReadableExt::deref_impl(self) } + } +} + +read_impls!(Loader where T: PartialEq); + +impl Clone for Loader { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for Loader {} + +#[derive(Clone, Copy, PartialEq, Hash, Eq, Debug)] +pub enum LoaderState { + /// The loader's future is still running + Pending, + + /// The loader's future has completed successfully + Ready, + + /// The loader's future has failed and now the loader is in an error state. + Failed, +} + +#[derive(PartialEq)] +pub struct LoaderHandle { + callback: Callback<(), Task>, + task: Signal, + err: Signal>, + state: Signal, +} + +impl LoaderHandle { + /// Restart the loading task. + pub fn restart(&mut self) { + self.task.write().cancel(); + let new_task = self.callback.call(()); + self.task.set(new_task); + } + + /// Get the current state of the loader. + pub fn state(&self) -> LoaderState { + *self.state.read() + } +} + +impl Clone for LoaderHandle { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for LoaderHandle {} + +#[derive(PartialEq)] +pub enum Loading { + /// The loader is still pending and the component should suspend. + Pending(LoaderHandle), + + /// The loader has failed and an error will be returned up the tree. + Failed(LoaderHandle), +} + +impl std::fmt::Debug for Loading { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Loading::Pending(_) => write!(f, "Loading::Pending"), + Loading::Failed(_) => write!(f, "Loading::Failed"), + } + } +} + +impl std::fmt::Display for Loading { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Loading::Pending(_) => write!(f, "Loading is still pending"), + Loading::Failed(_) => write!(f, "Loading has failed"), + } + } +} + +/// Convert a Loading into a RenderError for use with the `?` operator in components +impl From for RenderError { + fn from(val: Loading) -> Self { + match val { + Loading::Pending(t) => RenderError::Suspended(SuspendedFuture::new(t.task.cloned())), + Loading::Failed(err) => RenderError::Error( + err.err + .cloned() + .expect("LoaderHandle in Failed state should always have an error"), + ), + } + } +} diff --git a/packages/fullstack-hooks/src/hooks/server_cached.rs b/packages/fullstack-core/src/server_cached.rs similarity index 61% rename from packages/fullstack-hooks/src/hooks/server_cached.rs rename to packages/fullstack-core/src/server_cached.rs index fdf394c02..59f58888f 100644 --- a/packages/fullstack-hooks/src/hooks/server_cached.rs +++ b/packages/fullstack-core/src/server_cached.rs @@ -1,5 +1,5 @@ +use crate::transport::SerializeContextEntry; use dioxus_core::use_hook; -use dioxus_fullstack_protocol::SerializeContextEntry; use serde::{de::DeserializeOwned, Serialize}; /// This allows you to send data from the server to the client *during hydration*. @@ -35,7 +35,7 @@ pub(crate) fn server_cached( value: impl FnOnce() -> O, #[allow(unused)] location: &'static std::panic::Location<'static>, ) -> O { - let serialize = dioxus_fullstack_protocol::serialize_context(); + let serialize = crate::serialize_context(); #[allow(unused)] let entry: SerializeContextEntry = serialize.create_entry(); #[cfg(feature = "server")] @@ -56,3 +56,46 @@ pub(crate) fn server_cached( value() } } + +// pub fn use_server_cached(server_fn: impl Fn() -> O) -> O +// where +// O: Transportable + Clone, +// M: 'static, +// { +// let location = std::panic::Location::caller(); +// use_hook(|| server_cached(server_fn, location)) +// } + +// pub(crate) fn server_cached( +// value: impl FnOnce() -> O, +// #[allow(unused)] location: &'static std::panic::Location<'static>, +// ) -> O +// where +// O: Transportable + Clone, +// M: 'static, +// { +// let serialize = crate::transport::serialize_context(); + +// #[allow(unused)] +// let entry: SerializeContextEntry = serialize.create_entry(); + +// #[cfg(feature = "server")] +// { +// let data = value(); +// entry.insert(&data, location); +// data +// } + +// #[cfg(all(not(feature = "server"), feature = "web"))] +// { +// match entry.get() { +// Ok(value) => value, +// Err(_) => value(), +// } +// } + +// #[cfg(not(any(feature = "server", feature = "web")))] +// { +// value() +// } +// } diff --git a/packages/fullstack-core/src/server_future.rs b/packages/fullstack-core/src/server_future.rs new file mode 100644 index 000000000..f4b4ec220 --- /dev/null +++ b/packages/fullstack-core/src/server_future.rs @@ -0,0 +1,429 @@ +use dioxus_core::{suspend, use_hook, RenderError}; +use dioxus_hooks::*; +use dioxus_signals::ReadableExt; +use serde::{de::DeserializeOwned, Serialize}; +use std::future::Future; + +/// Runs a future with a manual list of dependencies and returns a resource with the result if the future is finished or a suspended error if it is still running. +/// +/// +/// On the server, this will wait until the future is resolved before continuing to render. When the future is resolved, the result will be serialized into the page and hydrated on the client without rerunning the future. +/// +/// +///
+/// +/// Unlike [`use_resource`] dependencies are only tracked inside the function that spawns the async block, not the async block itself. +/// +/// ```rust, no_run +/// # use dioxus::prelude::*; +/// // ❌ The future inside of use_server_future is not reactive +/// let id = use_signal(|| 0); +/// use_server_future(move || { +/// async move { +/// // But the future is not reactive which means that the future will not subscribe to any reads here +/// println!("{id}"); +/// } +/// }); +/// // ✅ The closure that creates the future for use_server_future is reactive +/// let id = use_signal(|| 0); +/// use_server_future(move || { +/// // The closure itself is reactive which means the future will subscribe to any signals you read here +/// let cloned_id = id(); +/// async move { +/// // But the future is not reactive which means that the future will not subscribe to any reads here +/// println!("{cloned_id}"); +/// } +/// }); +/// ``` +/// +///
+/// +/// # Example +/// +/// ```rust, no_run +/// # use dioxus::prelude::*; +/// # async fn fetch_article(id: u32) -> String { unimplemented!() } +/// use dioxus::prelude::*; +/// +/// fn App() -> Element { +/// let mut article_id = use_signal(|| 0); +/// // `use_server_future` will spawn a task that runs on the server and serializes the result to send to the client. +/// // The future will rerun any time the +/// // Since we bubble up the suspense with `?`, the server will wait for the future to resolve before rendering +/// let article = use_server_future(move || fetch_article(article_id()))?; +/// +/// rsx! { +/// "{article().unwrap()}" +/// } +/// } +/// ``` +#[must_use = "Consider using `cx.spawn` to run a future without reading its value"] +#[track_caller] +pub fn use_server_future( + mut future: impl FnMut() -> F + 'static, +) -> Result, RenderError> +where + T: Serialize + DeserializeOwned + 'static, + F: Future + 'static, +{ + let serialize_context = use_hook(crate::serialize_context); + + // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client + #[allow(unused)] + let storage_entry: crate::SerializeContextEntry = + use_hook(|| serialize_context.create_entry()); + + #[cfg(feature = "server")] + let caller = std::panic::Location::caller(); + + // If this is the first run and we are on the web client, the data might be cached + #[cfg(feature = "web")] + let initial_web_result = + use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(Some(storage_entry.get())))); + + let resource = use_resource(move || { + #[cfg(feature = "server")] + let storage_entry = storage_entry.clone(); + + let user_fut = future(); + + #[cfg(feature = "web")] + let initial_web_result = initial_web_result.clone(); + + #[allow(clippy::let_and_return)] + async move { + // If this is the first run and we are on the web client, the data might be cached + #[cfg(feature = "web")] + match initial_web_result.take() { + // The data was deserialized successfully from the server + Some(Ok(o)) => return o, + + // The data is still pending from the server. Don't try to resolve it on the client + Some(Err(crate::TakeDataError::DataPending)) => std::future::pending::<()>().await, + + // The data was not available on the server, rerun the future + Some(Err(_)) => {} + + // This isn't the first run, so we don't need do anything + None => {} + } + + // Otherwise just run the future itself + let out = user_fut.await; + + // If this is the first run and we are on the server, cache the data in the slot we reserved for it + #[cfg(feature = "server")] + storage_entry.insert(&out, caller); + + out + } + }); + + // On the first run, force this task to be polled right away in case its value is ready + use_hook(|| { + let _ = resource.task().poll_now(); + }); + + // Suspend if the value isn't ready + if resource.state().cloned() == UseResourceState::Pending { + let task = resource.task(); + if !task.paused() { + return Err(suspend(task).unwrap_err()); + } + } + + Ok(resource) +} + +// use dioxus_core::{suspend, use_hook, RenderError}; +// use dioxus_hooks::*; +// use dioxus_signals::ReadableExt; +// use serde::{de::DeserializeOwned, Serialize}; +// use std::future::Future; + +// /// Runs a future with a manual list of dependencies and returns a resource with the result if the future is finished or a suspended error if it is still running. +// /// +// /// +// /// On the server, this will wait until the future is resolved before continuing to render. When the future is resolved, the result will be serialized into the page and hydrated on the client without rerunning the future. +// /// +// /// +// ///
+// /// +// /// Unlike [`use_resource`] dependencies are only tracked inside the function that spawns the async block, not the async block itself. +// /// +// /// ```rust, no_run +// /// # use dioxus::prelude::*; +// /// // ❌ The future inside of use_server_future is not reactive +// /// let id = use_signal(|| 0); +// /// use_server_future(move || { +// /// async move { +// /// // But the future is not reactive which means that the future will not subscribe to any reads here +// /// println!("{id}"); +// /// } +// /// }); +// /// // ✅ The closure that creates the future for use_server_future is reactive +// /// let id = use_signal(|| 0); +// /// use_server_future(move || { +// /// // The closure itself is reactive which means the future will subscribe to any signals you read here +// /// let cloned_id = id(); +// /// async move { +// /// // But the future is not reactive which means that the future will not subscribe to any reads here +// /// println!("{cloned_id}"); +// /// } +// /// }); +// /// ``` +// /// +// ///
+// /// +// /// # Example +// /// +// /// ```rust, no_run +// /// # use dioxus::prelude::*; +// /// # async fn fetch_article(id: u32) -> String { unimplemented!() } +// /// use dioxus::prelude::*; +// /// +// /// fn App() -> Element { +// /// let mut article_id = use_signal(|| 0); +// /// // `use_server_future` will spawn a task that runs on the server and serializes the result to send to the client. +// /// // The future will rerun any time the +// /// // Since we bubble up the suspense with `?`, the server will wait for the future to resolve before rendering +// /// let article = use_server_future(move || fetch_article(article_id()))?; +// /// +// /// rsx! { +// /// "{article().unwrap()}" +// /// } +// /// } +// /// ``` +// #[must_use = "Consider using `cx.spawn` to run a future without reading its value"] +// #[track_caller] +// pub fn use_server_future( +// mut future: impl FnMut() -> F + 'static, +// ) -> Result, RenderError> +// where +// T: Serialize + DeserializeOwned + 'static, +// F: Future + 'static, +// { +// let serialize_context = use_hook(crate::serialize_context); + +// // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client +// #[allow(unused)] +// let storage_entry: crate::SerializeContextEntry = +// use_hook(|| serialize_context.create_entry()); + +// #[cfg(feature = "server")] +// let caller = std::panic::Location::caller(); + +// // If this is the first run and we are on the web client, the data might be cached +// #[cfg(feature = "web")] +// let initial_web_result = +// use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(Some(storage_entry.get())))); + +// let resource = use_resource(move || { +// #[cfg(feature = "server")] +// let storage_entry = storage_entry.clone(); + +// let user_fut = future(); + +// #[cfg(feature = "web")] +// let initial_web_result = initial_web_result.clone(); + +// #[allow(clippy::let_and_return)] +// async move { +// // If this is the first run and we are on the web client, the data might be cached +// #[cfg(feature = "web")] +// match initial_web_result.take() { +// // The data was deserialized successfully from the server +// Some(Ok(o)) => return o, + +// // The data is still pending from the server. Don't try to resolve it on the client +// Some(Err(crate::TakeDataError::DataPending)) => std::future::pending::<()>().await, + +// // The data was not available on the server, rerun the future +// Some(Err(_)) => {} + +// // This isn't the first run, so we don't need do anything +// None => {} +// } + +// // Otherwise just run the future itself +// let out = user_fut.await; + +// // If this is the first run and we are on the server, cache the data in the slot we reserved for it +// #[cfg(feature = "server")] +// storage_entry.insert(&out, caller); + +// out +// } +// }); + +// // On the first run, force this task to be polled right away in case its value is ready +// use_hook(|| { +// let _ = resource.task().poll_now(); +// }); + +// // Suspend if the value isn't ready +// if resource.state().cloned() == UseResourceState::Pending { +// let task = resource.task(); +// if !task.paused() { +// return Err(suspend(task).unwrap_err()); +// } +// } + +// Ok(resource) +// } + +// // use dioxus_core::{suspend, use_hook, RenderError}; +// // use dioxus_hooks::*; +// // use dioxus_signals::ReadableExt; +// // use std::future::Future; +// // // use crate::Transportable; + +// // /// Runs a future with a manual list of dependencies and returns a resource with the result if the future is finished or a suspended error if it is still running. +// // /// +// // /// +// // /// On the server, this will wait until the future is resolved before continuing to render. When the future is resolved, the result will be serialized into the page and hydrated on the client without rerunning the future. +// // /// +// // /// +// // ///
+// // /// +// // /// Unlike [`use_resource`] dependencies are only tracked inside the function that spawns the async block, not the async block itself. +// // /// +// // /// ```rust, no_run +// // /// # use dioxus::prelude::*; +// // /// // ❌ The future inside of use_server_future is not reactive +// // /// let id = use_signal(|| 0); +// // /// use_server_future(move || { +// // /// async move { +// // /// // But the future is not reactive which means that the future will not subscribe to any reads here +// // /// println!("{id}"); +// // /// } +// // /// }); +// // /// // ✅ The closure that creates the future for use_server_future is reactive +// // /// let id = use_signal(|| 0); +// // /// use_server_future(move || { +// // /// // The closure itself is reactive which means the future will subscribe to any signals you read here +// // /// let cloned_id = id(); +// // /// async move { +// // /// // But the future is not reactive which means that the future will not subscribe to any reads here +// // /// println!("{cloned_id}"); +// // /// } +// // /// }); +// // /// ``` +// // /// +// // ///
+// // /// +// // /// # Example +// // /// +// // /// ```rust, no_run +// // /// # use dioxus::prelude::*; +// // /// # async fn fetch_article(id: u32) -> String { unimplemented!() } +// // /// use dioxus::prelude::*; +// // /// +// // /// fn App() -> Element { +// // /// let mut article_id = use_signal(|| 0); +// // /// // `use_server_future` will spawn a task that runs on the server and serializes the result to send to the client. +// // /// // The future will rerun any time the +// // /// // Since we bubble up the suspense with `?`, the server will wait for the future to resolve before rendering +// // /// let article = use_server_future(move || fetch_article(article_id()))?; +// // /// +// // /// rsx! { +// // /// "{article().unwrap()}" +// // /// } +// // /// } +// // /// ``` +// // #[must_use = "Consider using `cx.spawn` to run a future without reading its value"] +// // #[track_caller] +// // pub fn use_server_future( +// // future: impl FnMut() -> F + 'static, +// // ) -> Result, RenderError> +// // where +// // F: Future + 'static, +// // T: Transportable, +// // M: 'static, +// // { +// // let resource = use_server_future_unsuspended(future); + +// // // Suspend if the value isn't ready +// // if resource.state().cloned() == UseResourceState::Pending { +// // let task = resource.task(); +// // if !task.paused() { +// // return Err(suspend(task).unwrap_err()); +// // } +// // } + +// // Ok(resource) +// // } + +// // /// Like [`use_server_future`] but does not suspend if the future is still running. +// // /// +// // /// You need to manage suspending yourself by checking the resource state. +// // pub fn use_server_future_unsuspended( +// // mut future: impl FnMut() -> F + 'static, +// // ) -> Resource +// // where +// // F: Future + 'static, +// // T: Transportable, +// // M: 'static, +// // { +// // let serialize_context = use_hook(crate::transport::serialize_context); + +// // // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client +// // #[allow(unused)] +// // let storage_entry: crate::transport::SerializeContextEntry = +// // use_hook(|| serialize_context.create_entry()); + +// // #[cfg(feature = "server")] +// // let caller = std::panic::Location::caller(); + +// // // If this is the first run and we are on the web client, the data might be cached +// // #[cfg(feature = "web")] +// // let initial_web_result = +// // use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(Some(storage_entry.get())))); + +// // let resource = use_resource(move || { +// // #[cfg(feature = "server")] +// // let storage_entry = storage_entry.clone(); + +// // let user_fut = future(); + +// // #[cfg(feature = "web")] +// // let initial_web_result = initial_web_result.clone(); + +// // #[allow(clippy::let_and_return)] +// // async move { +// // // If this is the first run and we are on the web client, the data might be cached +// // #[cfg(feature = "web")] +// // match initial_web_result.take() { +// // // The data was deserialized successfully from the server +// // Some(Ok(o)) => return o, + +// // // The data is still pending from the server. Don't try to resolve it on the client +// // Some(Err(crate::transport::TakeDataError::DataPending)) => { +// // std::future::pending::<()>().await +// // } + +// // // The data was not available on the server, rerun the future +// // Some(Err(_)) => {} + +// // // This isn't the first run, so we don't need do anything +// // None => {} +// // } + +// // // Otherwise just run the future itself +// // let out = user_fut.await; + +// // // If this is the first run and we are on the server, cache the data in the slot we reserved for it +// // #[cfg(feature = "server")] +// // storage_entry.insert(&out, caller); + +// // out +// // } +// // }); + +// // // On the first run, force this task to be polled right away in case its value is ready +// // use_hook(|| { +// // let _ = resource.task().poll_now(); +// // }); + +// // resource +// // } diff --git a/packages/fullstack-hooks/src/streaming.rs b/packages/fullstack-core/src/streaming.rs similarity index 93% rename from packages/fullstack-hooks/src/streaming.rs rename to packages/fullstack-core/src/streaming.rs index 147569ed4..eebcaee57 100644 --- a/packages/fullstack-hooks/src/streaming.rs +++ b/packages/fullstack-core/src/streaming.rs @@ -4,9 +4,9 @@ use dioxus_signals::{ReadableExt, Signal, WritableExt}; /// The status of the streaming response #[derive(Clone, Copy, Debug, PartialEq)] pub enum StreamingStatus { - /// The initial chunk is still being rendered. The http response parts can still be modified with - /// [DioxusServerContext::response_parts_mut](https://docs.rs/dioxus-fullstack/0.6.3/dioxus_fullstack/prelude/struct.DioxusServerContext.html#method.response_parts_mut). + /// The initial chunk is still being rendered. The http response parts can still be modified at this point. RenderingInitialChunk, + /// The initial chunk has been committed and the response is now streaming. The http response parts /// have already been sent to the client and can no longer be modified. InitialChunkCommitted, @@ -59,7 +59,7 @@ impl StreamingContext { /// # Example /// ```rust, no_run /// # use dioxus::prelude::*; -/// # use dioxus_fullstack_hooks::*; +/// # use dioxus_fullstack_core::*; /// # fn Children() -> Element { unimplemented!() } /// fn App() -> Element { /// // This will start streaming immediately after the current render is complete. @@ -83,7 +83,7 @@ pub fn commit_initial_chunk() { /// # Example /// ```rust, no_run /// # use dioxus::prelude::*; -/// # use dioxus_fullstack_hooks::*; +/// # use dioxus_fullstack_core::*; /// #[component] /// fn MetaTitle(title: String) -> Element { /// // If streaming has already started, warn the user that the meta tag will not show diff --git a/packages/fullstack-protocol/src/lib.rs b/packages/fullstack-core/src/transport.rs similarity index 73% rename from packages/fullstack-protocol/src/lib.rs rename to packages/fullstack-core/src/transport.rs index 5c9d95229..c8043e454 100644 --- a/packages/fullstack-protocol/src/lib.rs +++ b/packages/fullstack-core/src/transport.rs @@ -5,6 +5,10 @@ use base64::Engine; use dioxus_core::CapturedError; use serde::Serialize; use std::{cell::RefCell, io::Cursor, rc::Rc}; +// use base64::Engine; +// use dioxus_core::{CapturedError, Error}; +// use serde::{de::DeserializeOwned, Deserialize, Serialize}; +// use std::{cell::RefCell, io::Cursor, rc::Rc, sync::Arc}; #[cfg(feature = "web")] thread_local! { @@ -84,6 +88,7 @@ impl HydrationContext { }) } + // pub(crate) fn insert, M: 'static>( pub(crate) fn insert( &self, id: usize, @@ -93,6 +98,7 @@ impl HydrationContext { self.data.borrow_mut().insert(id, value, location); } + // pub(crate) fn get, M: 'static>( pub(crate) fn get( &self, id: usize, @@ -142,6 +148,21 @@ impl SerializeContextEntry { { self.context.get(self.index) } + // /// Insert data into an entry that was created with [`HydrationContext::create_entry`] + // pub fn insert(self, value: &T, location: &'static std::panic::Location<'static>) + // where + // T: Transportable, + // { + // self.context.insert(self.index, value, location); + // } + + // /// Grab the data from the serialize context + // pub fn get(&self) -> Result + // where + // T: Transportable, + // { + // self.context.get(self.index) + // } } /// Check if the client is currently rendering a component for hydration. Always returns true on the server. @@ -210,6 +231,7 @@ impl Default for HTMLData { } impl HTMLData { + #[allow(unused)] fn from_serialized( data: &[u8], debug_types: Option>, @@ -254,12 +276,14 @@ impl HTMLData { } /// Insert data into an entry that was created with [`Self::create_entry`] + // fn insert, M: 'static>( fn insert( &mut self, id: usize, value: &T, - location: &'static std::panic::Location<'static>, + #[allow(unused)] location: &'static std::panic::Location<'static>, ) { + // let serialized = value.transport_to_bytes(); let mut serialized = Vec::new(); ciborium::into_writer(value, &mut serialized).unwrap(); self.data[id] = Some(serialized); @@ -271,6 +295,7 @@ impl HTMLData { } /// Get the data from the serialize context + // fn get, M: 'static>(&self, index: usize) -> Result { fn get(&self, index: usize) -> Result { if index >= self.data.len() { tracing::trace!( @@ -283,6 +308,7 @@ impl HTMLData { let bytes = self.data[index].as_ref(); match bytes { Some(bytes) => match ciborium::from_reader(Cursor::new(bytes)) { + // Some(bytes) => match T::transport_from_bytes(bytes) { Ok(x) => Ok(x), Err(err) => { #[cfg(debug_assertions)] @@ -355,6 +381,7 @@ impl HTMLData { let data = base64::engine::general_purpose::STANDARD.encode(serialized); + #[cfg(debug_assertions)] let format_js_list_of_strings = |list: &[Option]| { let body = list .iter() @@ -425,3 +452,120 @@ impl std::error::Error for TakeDataError {} pub fn head_element_hydration_entry() -> SerializeContextEntry { serialize_context().create_entry() } + +// /// An error that can occur when trying to take data from the server +// #[derive(thiserror::Error, Debug)] +// pub enum TakeDataError { +// /// Deserializing the data failed +// #[error("DeserializationError: {0}")] +// DeserializationError(ciborium::de::Error), + +// /// No data was available +// #[error("DataNotAvailable")] +// DataNotAvailable, + +// /// The server serialized a placeholder for the data, but it isn't available yet +// #[error("DataPending")] +// DataPending, +// } + +// /// A `Transportable` type can be safely transported from the server to the client, and be used for +// /// hydration. Not all types can sensibly be transported, but many can. This trait makes it possible +// /// to customize how types are transported which helps for non-serializable types like `dioxus_core::Error`. +// /// +// /// By default, all types that implement `Serialize` and `DeserializeOwned` are transportable. +// /// +// /// You can also implement `Transportable` for `Result` where `T` is +// /// `Serialize` and `DeserializeOwned` to allow transporting results that may contain errors. +// /// +// /// Note that transporting a `Result` will lose various aspects of the original +// /// `dioxus_core::Error` such as backtraces and source errors, but will preserve the error message. +// pub trait Transportable: 'static { +// /// Serialize the type to a byte vector for transport +// fn transport_to_bytes(&self) -> Vec; + +// /// Deserialize the type from a byte slice +// fn transport_from_bytes(bytes: &[u8]) -> Result> +// where +// Self: Sized; +// } + +// impl Transportable<()> for T +// where +// T: Serialize + DeserializeOwned + 'static, +// { +// fn transport_to_bytes(&self) -> Vec { +// let mut serialized = Vec::new(); +// ciborium::into_writer(self, &mut serialized).unwrap(); +// serialized +// } + +// fn transport_from_bytes(bytes: &[u8]) -> Result> +// where +// Self: Sized, +// { +// ciborium::from_reader(Cursor::new(bytes)) +// } +// } + +// #[derive(Serialize, Deserialize)] +// struct TransportResultErr { +// error: Result, +// } + +// #[doc(hidden)] +// pub struct TransportViaErrMarker; + +// impl Transportable for Result +// where +// T: Serialize + DeserializeOwned + 'static, +// { +// fn transport_to_bytes(&self) -> Vec { +// let err = TransportResultErr { +// error: self +// .as_ref() +// .map_err(|e| CapturedError::from_display(e.to_string())), +// }; + +// let mut serialized = Vec::new(); +// ciborium::into_writer(&err, &mut serialized).unwrap(); +// serialized +// } + +// fn transport_from_bytes(bytes: &[u8]) -> Result> +// where +// Self: Sized, +// { +// let err: TransportResultErr = ciborium::from_reader(Cursor::new(bytes))?; +// match err.error { +// Ok(value) => Ok(Ok(value)), +// Err(captured) => Ok(Err(dioxus_core::Error::msg(captured.to_string()))), +// } +// } +// } + +// #[doc(hidden)] +// pub struct TransportCapturedError; +// #[derive(Serialize, Deserialize)] +// struct TransportError { +// error: String, +// } +// impl Transportable for CapturedError { +// fn transport_to_bytes(&self) -> Vec { +// let err = TransportError { +// error: self.to_string(), +// }; + +// let mut serialized = Vec::new(); +// ciborium::into_writer(&err, &mut serialized).unwrap(); +// serialized +// } + +// fn transport_from_bytes(bytes: &[u8]) -> Result> +// where +// Self: Sized, +// { +// let err: TransportError = ciborium::from_reader(Cursor::new(bytes))?; +// Ok(CapturedError(Arc::new(Error::msg::(err.error)))) +// } +// } diff --git a/packages/fullstack-hooks/src/hooks/mod.rs b/packages/fullstack-hooks/src/hooks/mod.rs deleted file mode 100644 index 4de7ac967..000000000 --- a/packages/fullstack-hooks/src/hooks/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -mod server_cached; -pub use server_cached::*; -mod server_future; -pub use server_future::*; diff --git a/packages/fullstack-hooks/src/hooks/server_future.rs b/packages/fullstack-hooks/src/hooks/server_future.rs deleted file mode 100644 index 6937341e0..000000000 --- a/packages/fullstack-hooks/src/hooks/server_future.rs +++ /dev/null @@ -1,138 +0,0 @@ -use dioxus_core::{suspend, use_hook, RenderError}; -use dioxus_hooks::*; -use dioxus_signals::ReadableExt; -use serde::{de::DeserializeOwned, Serialize}; -use std::future::Future; - -/// Runs a future with a manual list of dependencies and returns a resource with the result if the future is finished or a suspended error if it is still running. -/// -/// -/// On the server, this will wait until the future is resolved before continuing to render. When the future is resolved, the result will be serialized into the page and hydrated on the client without rerunning the future. -/// -/// -///
-/// -/// Unlike [`use_resource`] dependencies are only tracked inside the function that spawns the async block, not the async block itself. -/// -/// ```rust, no_run -/// # use dioxus::prelude::*; -/// // ❌ The future inside of use_server_future is not reactive -/// let id = use_signal(|| 0); -/// use_server_future(move || { -/// async move { -/// // But the future is not reactive which means that the future will not subscribe to any reads here -/// println!("{id}"); -/// } -/// }); -/// // ✅ The closure that creates the future for use_server_future is reactive -/// let id = use_signal(|| 0); -/// use_server_future(move || { -/// // The closure itself is reactive which means the future will subscribe to any signals you read here -/// let cloned_id = id(); -/// async move { -/// // But the future is not reactive which means that the future will not subscribe to any reads here -/// println!("{cloned_id}"); -/// } -/// }); -/// ``` -/// -///
-/// -/// # Example -/// -/// ```rust, no_run -/// # use dioxus::prelude::*; -/// # async fn fetch_article(id: u32) -> String { unimplemented!() } -/// use dioxus::prelude::*; -/// -/// fn App() -> Element { -/// let mut article_id = use_signal(|| 0); -/// // `use_server_future` will spawn a task that runs on the server and serializes the result to send to the client. -/// // The future will rerun any time the -/// // Since we bubble up the suspense with `?`, the server will wait for the future to resolve before rendering -/// let article = use_server_future(move || fetch_article(article_id()))?; -/// -/// rsx! { -/// "{article().unwrap()}" -/// } -/// } -/// ``` -#[must_use = "Consider using `cx.spawn` to run a future without reading its value"] -#[track_caller] -pub fn use_server_future( - mut future: impl FnMut() -> F + 'static, -) -> Result, RenderError> -where - T: Serialize + DeserializeOwned + 'static, - F: Future + 'static, -{ - let serialize_context = use_hook(dioxus_fullstack_protocol::serialize_context); - - // We always create a storage entry, even if the data isn't ready yet to make it possible to deserialize pending server futures on the client - #[allow(unused)] - let storage_entry: dioxus_fullstack_protocol::SerializeContextEntry = - use_hook(|| serialize_context.create_entry()); - - #[cfg(feature = "server")] - let caller = std::panic::Location::caller(); - - // If this is the first run and we are on the web client, the data might be cached - #[cfg(feature = "web")] - let initial_web_result = - use_hook(|| std::rc::Rc::new(std::cell::RefCell::new(Some(storage_entry.get())))); - - let resource = use_resource(move || { - #[cfg(feature = "server")] - let storage_entry = storage_entry.clone(); - - let user_fut = future(); - - #[cfg(feature = "web")] - let initial_web_result = initial_web_result.clone(); - - #[allow(clippy::let_and_return)] - async move { - // If this is the first run and we are on the web client, the data might be cached - #[cfg(feature = "web")] - match initial_web_result.take() { - // The data was deserialized successfully from the server - Some(Ok(o)) => return o, - - // The data is still pending from the server. Don't try to resolve it on the client - Some(Err(dioxus_fullstack_protocol::TakeDataError::DataPending)) => { - std::future::pending::<()>().await - } - - // The data was not available on the server, rerun the future - Some(Err(_)) => {} - - // This isn't the first run, so we don't need do anything - None => {} - } - - // Otherwise just run the future itself - let out = user_fut.await; - - // If this is the first run and we are on the server, cache the data in the slot we reserved for it - #[cfg(feature = "server")] - storage_entry.insert(&out, caller); - - out - } - }); - - // On the first run, force this task to be polled right away in case its value is ready - use_hook(|| { - let _ = resource.task().poll_now(); - }); - - // Suspend if the value isn't ready - if resource.state().cloned() == UseResourceState::Pending { - let task = resource.task(); - if !task.paused() { - return Err(suspend(task).unwrap_err()); - } - } - - Ok(resource) -} diff --git a/packages/fullstack-hooks/src/lib.rs b/packages/fullstack-hooks/src/lib.rs deleted file mode 100644 index 8eb696498..000000000 --- a/packages/fullstack-hooks/src/lib.rs +++ /dev/null @@ -1,11 +0,0 @@ -#![warn(missing_docs)] -#![doc = include_str!("../README.md")] - -pub mod document; -pub mod errors; -pub mod history; -mod hooks; -mod streaming; - -pub use crate::hooks::*; -pub use crate::streaming::*; diff --git a/packages/server-macro/Cargo.toml b/packages/fullstack-macro/Cargo.toml similarity index 50% rename from packages/server-macro/Cargo.toml rename to packages/fullstack-macro/Cargo.toml index e1174e0b7..410c09c81 100644 --- a/packages/server-macro/Cargo.toml +++ b/packages/fullstack-macro/Cargo.toml @@ -1,20 +1,24 @@ [package] -name = "dioxus_server_macro" +name = "dioxus-fullstack-macro" version = { workspace = true } edition = "2021" repository = "https://github.com/DioxusLabs/dioxus/" -homepage = "https://dioxuslabs.com/docs/0.5/guide/en/getting_started/fullstack.html" keywords = ["dom", "ui", "gui", "react", "liveview"] authors = ["Jonathan Kelley", "Evan Almloff"] license = "MIT OR Apache-2.0" description = "Server function macros for Dioxus" [dependencies] -proc-macro2 = { workspace = true } -quote = { workspace = true } -syn = { workspace = true, features = ["full"] } -server_fn_macro = { workspace = true } - +quote = { workspace = true, default-features = true } +syn = { features = [ + "full", + "parsing", + "extra-traits", +], workspace = true, default-features = true } +proc-macro2 = { workspace = true, default-features = true } +xxhash-rust = { features = ["const_xxh64" ], workspace = true, default-features = true } +const_format = { workspace = true, default-features = true } +convert_case = { workspace = true, default-features = true } [dev-dependencies] dioxus = { workspace = true, features = ["fullstack"] } @@ -24,13 +28,3 @@ axum = { workspace = true } [lib] proc-macro = true - -[features] -axum = ["server_fn_macro/axum"] -server = ["server_fn_macro/ssr"] -browser = [] -reqwest = ["server_fn_macro/reqwest"] -generic = ["server_fn_macro/generic"] - -[package.metadata.docs.rs] -cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] diff --git a/packages/fullstack-macro/src/lib.rs b/packages/fullstack-macro/src/lib.rs new file mode 100644 index 000000000..855e8a039 --- /dev/null +++ b/packages/fullstack-macro/src/lib.rs @@ -0,0 +1,1980 @@ +// TODO: Create README, uncomment this: #![doc = include_str!("../README.md")] +#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")] +#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] + +use core::panic; +use proc_macro::TokenStream; +use proc_macro2::{Span, TokenStream as TokenStream2}; +use quote::ToTokens; +use quote::{format_ident, quote}; +use std::collections::HashMap; +use syn::{ + braced, bracketed, + parse::ParseStream, + punctuated::Punctuated, + token::{Comma, Slash}, + Error, ExprTuple, FnArg, GenericArgument, Meta, PathArguments, PathSegment, Signature, Token, + Type, TypePath, +}; +use syn::{parse::Parse, parse_quote, Ident, ItemFn, LitStr, Path}; +use syn::{spanned::Spanned, LitBool, LitInt, Pat, PatType}; +use syn::{ + token::{Brace, Star}, + Attribute, Expr, ExprClosure, Lit, Result, +}; + +/// ## Usage +/// +/// ```rust,ignore +/// # use dioxus::prelude::*; +/// # #[derive(serde::Deserialize, serde::Serialize)] +/// # struct BlogPost; +/// # async fn load_posts(category: &str) -> Result> { unimplemented!() } +/// +/// #[server] +/// async fn blog_posts(category: String) -> Result> { +/// let posts = load_posts(&category).await?; +/// // maybe do some other work +/// Ok(posts) +/// } +/// ``` +/// +/// ## Named Arguments +/// +/// You can use any combination of the following named arguments: +/// - `endpoint`: a prefix at which the server function handler will be mounted (defaults to `/api`). +/// Example: `endpoint = "/my_api/my_serverfn"`. +/// - `input`: the encoding for the arguments, defaults to `Json` +/// - You may customize the encoding of the arguments by specifying a different type for `input`. +/// - Any axum `IntoRequest` extractor can be used here, and dioxus provides +/// - `Json`: The default axum `Json` extractor that decodes JSON-encoded request bodies. +/// - `Cbor`: A custom axum `Cbor` extractor that decodes CBOR-encoded request bodies. +/// - `MessagePack`: A custom axum `MessagePack` extractor that decodes MessagePack-encoded request bodies. +/// - `output`: the encoding for the response (defaults to `Json`). +/// - The `output` argument specifies how the server should encode the response data. +/// - Acceptable values include: +/// - `Json`: A response encoded as JSON (default). This is ideal for most web applications. +/// - `Cbor`: A response encoded in the CBOR format for efficient, binary-encoded data. +/// - `client`: a custom `Client` implementation that will be used for this server function. This allows +/// customization of the client-side behavior if needed. +/// +/// ## Advanced Usage of `input` and `output` Fields +/// +/// The `input` and `output` fields allow you to customize how arguments and responses are encoded and decoded. +/// These fields impose specific trait bounds on the types you use. Here are detailed examples for different scenarios: +/// +/// ## Adding layers to server functions +/// +/// Layers allow you to transform the request and response of a server function. You can use layers +/// to add authentication, logging, or other functionality to your server functions. Server functions integrate +/// with the tower ecosystem, so you can use any layer that is compatible with tower. +/// +/// Common layers include: +/// - [`tower_http::trace::TraceLayer`](https://docs.rs/tower-http/latest/tower_http/trace/struct.TraceLayer.html) for tracing requests and responses +/// - [`tower_http::compression::CompressionLayer`](https://docs.rs/tower-http/latest/tower_http/compression/struct.CompressionLayer.html) for compressing large responses +/// - [`tower_http::cors::CorsLayer`](https://docs.rs/tower-http/latest/tower_http/cors/struct.CorsLayer.html) for adding CORS headers to responses +/// - [`tower_http::timeout::TimeoutLayer`](https://docs.rs/tower-http/latest/tower_http/timeout/struct.TimeoutLayer.html) for adding timeouts to requests +/// - [`tower_sessions::service::SessionManagerLayer`](https://docs.rs/tower-sessions/0.13.0/tower_sessions/service/struct.SessionManagerLayer.html) for adding session management to requests +/// +/// You can add a tower [`Layer`](https://docs.rs/tower/latest/tower/trait.Layer.html) to your server function with the middleware attribute: +/// +/// ```rust,ignore +/// # use dioxus::prelude::*; +/// #[server] +/// // The TraceLayer will log all requests to the console +/// #[middleware(tower_http::timeout::TimeoutLayer::new(std::time::Duration::from_secs(5)))] +/// pub async fn my_wacky_server_fn(input: Vec) -> ServerFnResult { +/// unimplemented!() +/// } +/// ``` +#[proc_macro_attribute] +pub fn server(_attr: proc_macro::TokenStream, mut item: TokenStream) -> TokenStream { + // Parse the attribute list using the old server_fn arg parser. + let args = match syn::parse::(_attr) { + Ok(args) => args, + Err(err) => { + let err: TokenStream = err.to_compile_error().into(); + item.extend(err); + return item; + } + }; + + let method = Method::Post(Ident::new("POST", proc_macro2::Span::call_site())); + let route: Route = Route { + method: None, + path_params: vec![], + query_params: vec![], + state: None, + route_lit: args.fn_path, + oapi_options: None, + server_args: Default::default(), + prefix: Some( + args.prefix + .unwrap_or_else(|| LitStr::new("/api", Span::call_site())), + ), + _input_encoding: args.input, + _output_encoding: args.output, + }; + + match route_impl_with_route(route, item.clone(), Some(method)) { + Ok(mut tokens) => { + // Let's add some deprecated warnings to the various fields from `args` if the user is using them... + // We don't generate structs anymore, don't use various protocols, etc + if let Some(name) = args.struct_name { + tokens.extend(quote! { + const _: () = { + #[deprecated(note = "Dioxus server functions no longer generate a struct for the server function. The function itself is used directly.")] + struct #name; + fn ___assert_deprecated() { + let _ = #name; + } + + () + }; + }); + } + + // + tokens.into() + } + + // Retain the original function item and append the error to it. Better for autocomplete. + Err(err) => { + let err: TokenStream = err.to_compile_error().into(); + item.extend(err); + item + } + } +} + +#[proc_macro_attribute] +pub fn get(args: proc_macro::TokenStream, body: TokenStream) -> TokenStream { + wrapped_route_impl(args, body, Some(Method::new_from_string("GET"))) +} + +#[proc_macro_attribute] +pub fn post(args: proc_macro::TokenStream, body: TokenStream) -> TokenStream { + wrapped_route_impl(args, body, Some(Method::new_from_string("POST"))) +} + +#[proc_macro_attribute] +pub fn put(args: proc_macro::TokenStream, body: TokenStream) -> TokenStream { + wrapped_route_impl(args, body, Some(Method::new_from_string("PUT"))) +} + +#[proc_macro_attribute] +pub fn delete(args: proc_macro::TokenStream, body: TokenStream) -> TokenStream { + wrapped_route_impl(args, body, Some(Method::new_from_string("DELETE"))) +} + +#[proc_macro_attribute] +pub fn patch(args: proc_macro::TokenStream, body: TokenStream) -> TokenStream { + wrapped_route_impl(args, body, Some(Method::new_from_string("PATCH"))) +} + +fn wrapped_route_impl( + attr: TokenStream, + mut item: TokenStream, + method: Option, +) -> TokenStream { + match route_impl(attr, item.clone(), method) { + Ok(tokens) => tokens.into(), + Err(err) => { + let err: TokenStream = err.to_compile_error().into(); + item.extend(err); + item + } + } +} + +fn route_impl( + attr: TokenStream, + item: TokenStream, + method_from_macro: Option, +) -> syn::Result { + let route = syn::parse::(attr)?; + route_impl_with_route(route, item, method_from_macro) +} + +fn route_impl_with_route( + route: Route, + item: TokenStream, + method_from_macro: Option, +) -> syn::Result { + // Parse the route and function + let function = syn::parse::(item)?; + + let server_args = route.server_args.clone(); + let mut function_on_server = function.clone(); + function_on_server.sig.inputs.extend(server_args.clone()); + + // Now we can compile the route + let original_inputs = function + .sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Receiver(_receiver) => panic!("Self type is not supported"), + FnArg::Typed(pat_type) => { + quote! { + #[allow(unused_mut)] + #pat_type + } + } + }) + .collect::>(); + + let route = CompiledRoute::from_route(route, &function, false, method_from_macro)?; + let path_extractor = route.path_extractor(); + let query_extractor = route.query_extractor(); + let query_params_struct = route.query_params_struct(false); + let _state_type = &route.state; + let method_ident = &route.method; + let http_method = route.method.to_axum_method_name(); + let _remaining_numbered_pats = route.remaining_pattypes_numbered(&function.sig.inputs); + let body_json_args = route.remaining_pattypes_named(&function.sig.inputs); + let body_json_names = body_json_args + .iter() + .enumerate() + .map(|(i, pat_type)| match &*pat_type.pat { + Pat::Ident(ref pat_ident) => pat_ident.ident.clone(), + _ => format_ident!("___arg{}", i), + }) + .collect::>(); + let body_json_types = body_json_args + .iter() + .map(|pat_type| &pat_type.ty) + .collect::>(); + let extracted_idents = route.extracted_idents(); + let route_docs = route.to_doc_comments(); + + // Get the variables we need for code generation + let fn_name = &function.sig.ident; + let vis = &function.vis; + let asyncness = &function.sig.asyncness; + let (impl_generics, ty_generics, where_clause) = &function.sig.generics.split_for_impl(); + let ty_generics = ty_generics.as_turbofish(); + let fn_docs = function + .attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")); + + let __axum = quote! { dioxus_fullstack::axum }; + + let (aide_ident_docs, _inner_fn_call, _method_router_ty) = { + ( + quote!(), + quote! { #__axum::routing::#http_method(__inner__function__ #ty_generics) }, + quote! { #__axum::routing::MethodRouter }, + ) + }; + + let output_type = match &function.sig.output { + syn::ReturnType::Default => parse_quote! { () }, + syn::ReturnType::Type(_, ty) => (*ty).clone(), + }; + + let query_param_names = route.query_params.iter().map(|(ident, _)| ident); + + let path_param_args = route.path_params.iter().map(|(_slash, param)| match param { + PathParam::Capture(_lit, _brace_1, ident, _ty, _brace_2) => { + Some(quote! { #ident = #ident, }) + } + PathParam::WildCard(_lit, _brace_1, _star, ident, _ty, _brace_2) => { + Some(quote! { #ident = #ident, }) + } + PathParam::Static(_lit) => None, + }); + + let out_ty = match output_type.as_ref() { + Type::Tuple(tuple) if tuple.elems.is_empty() => parse_quote! { () }, + _ => output_type.clone(), + }; + + let server_names = server_args + .iter() + .map(|pat_type| match pat_type { + FnArg::Receiver(_) => quote! { () }, + FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { + Pat::Ident(pat_ident) => { + let name = &pat_ident.ident; + quote! { #name } + } + _ => panic!("Expected Pat::Ident"), + }, + }) + .collect::>(); + + let body_struct_impl = { + let tys = body_json_types + .iter() + .enumerate() + .map(|(idx, _)| format_ident!("__Ty{}", idx)); + + let names = body_json_names.iter().enumerate().map(|(idx, name)| { + let ty_name = format_ident!("__Ty{}", idx); + quote! { #name: #ty_name } + }); + + quote! { + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(crate = "serde")] + struct ___Body_Serialize___< #(#tys,)* > { + #(#names,)* + } + } + }; + + // This unpacks the body struct into the individual variables that get scoped + let unpack = { + let unpack_args = body_json_names.iter().map(|name| quote! { data.#name }); + quote! { + |data| { ( #(#unpack_args,)* ) } + } + }; + + // there's no active request on the server, so we just create a dummy one + let server_defaults = server_args.iter().map(|arg| { + let name = match arg { + FnArg::Receiver(_) => panic!("Server args cannot be receiver"), + FnArg::Typed(pat_type) => match pat_type.pat.as_ref() { + Pat::Ident(pat_ident) => &pat_ident.ident, + _ => panic!("Expected Pat::Ident"), + }, + }; + + let ty = match arg { + FnArg::Receiver(_) => panic!("Server args cannot be receiver"), + FnArg::Typed(pat_type) => (*pat_type.ty).clone(), + }; + + quote! { + let #name = { + use dioxus_fullstack::axum::extract::FromRequest; + let __request = dioxus_fullstack::axum::extract::Request::new(dioxus_fullstack::axum::body::Body::empty()); + <#ty as FromRequest<_, _>>::from_request(__request, &()).await.unwrap() + }; + } + }); + + let as_axum_path = route.to_axum_path_string(); + + let query_endpoint = if let Some(route_lit) = route.route_lit.as_ref() { + let prefix = route + .prefix + .as_ref() + .cloned() + .unwrap_or_else(|| LitStr::new("", Span::call_site())) + .value(); + let url_without_queries = route_lit.value().split('?').next().unwrap().to_string(); + let full_url = format!( + "{}{}{}", + prefix, + if url_without_queries.starts_with("/") { + "" + } else { + "/" + }, + url_without_queries + ); + quote! { format!(#full_url, #( #path_param_args)*) } + } else { + quote! { __ENDPOINT_PATH.to_string() } + }; + + let endpoint_path = { + let prefix = route + .prefix + .as_ref() + .cloned() + .unwrap_or_else(|| LitStr::new("", Span::call_site())); + + let route_lit = if !as_axum_path.is_empty() { + quote! { #as_axum_path } + } else { + quote! { + concat!( + "/", + stringify!(#fn_name) + ) + } + }; + + let hash = match route.route_lit.as_ref() { + // Explicit route lit, no need to hash + Some(_) => quote! { "" }, + + // Implicit route lit, we need to hash the function signature to avoid collisions + None => { + // let enable_hash = option_env!("DISABLE_SERVER_FN_HASH").is_none(); + let key_env_var = match option_env!("SERVER_FN_OVERRIDE_KEY") { + Some(_) => "SERVER_FN_OVERRIDE_KEY", + None => "CARGO_MANIFEST_DIR", + }; + quote! { + dioxus_fullstack::xxhash_rust::const_xxh64::xxh64( + concat!(env!(#key_env_var), ":", module_path!()).as_bytes(), + 0 + ) + } + } + }; + + quote! { + dioxus_fullstack::const_format::concatcp!(#prefix, #route_lit, #hash) + } + }; + + Ok(quote! { + #(#fn_docs)* + #route_docs + #vis async fn #fn_name #impl_generics( + #original_inputs + ) -> #out_ty #where_clause { + use dioxus_fullstack::serde as serde; + use dioxus_fullstack::{ + // concrete types + ServerFnEncoder, ServerFnDecoder, DioxusServerState, + + // "magic" traits for encoding/decoding on the client + ExtractRequest, EncodeRequest, RequestDecodeResult, RequestDecodeErr, + + // "magic" traits for encoding/decoding on the server + MakeAxumResponse, MakeAxumError, + }; + + _ = dioxus_fullstack::assert_is_result::<#out_ty>(); + + #query_params_struct + + #body_struct_impl + + const __ENDPOINT_PATH: &str = #endpoint_path; + + // On the client, we make the request to the server + // We want to support extremely flexible error types and return types, making this more complex than it should + #[allow(clippy::unused_unit)] + #[cfg(not(feature = "server"))] + { + let client = dioxus_fullstack::ClientRequest::new( + dioxus_fullstack::http::Method::#method_ident, + #query_endpoint, + &__QueryParams__ { #(#query_param_names,)* }, + ); + + let verify_token = (&&&&&&&&&&&&&&ServerFnEncoder::<___Body_Serialize___<#(#body_json_types,)*>, (#(#body_json_types,)*)>::new()) + .verify_can_serialize(); + + dioxus_fullstack::assert_can_encode(verify_token); + + let response = (&&&&&&&&&&&&&&ServerFnEncoder::<___Body_Serialize___<#(#body_json_types,)*>, (#(#body_json_types,)*)>::new()) + .fetch_client(client, ___Body_Serialize___ { #(#body_json_names,)* }, #unpack) + .await; + + let decoded = (&&&&&ServerFnDecoder::<#out_ty>::new()) + .decode_client_response(response) + .await; + + let result = (&&&&&ServerFnDecoder::<#out_ty>::new()) + .decode_client_err(decoded) + .await; + + return result; + } + + // On the server, we expand the tokens and submit the function to inventory + #[cfg(feature = "server")] { + use #__axum::response::IntoResponse; + use dioxus_server::ServerFunction; + + #function_on_server + + #[allow(clippy::unused_unit)] + #aide_ident_docs + #asyncness fn __inner__function__ #impl_generics( + ___state: #__axum::extract::State, + #path_extractor + #query_extractor + request: #__axum::extract::Request, + ) -> Result<#__axum::response::Response, #__axum::response::Response> #where_clause { + let ((#(#server_names,)*), ( #(#body_json_names,)* )) = (&&&&&&&&&&&&&&ServerFnEncoder::<___Body_Serialize___<#(#body_json_types,)*>, (#(#body_json_types,)*)>::new()) + .extract_axum(___state.0, request, #unpack).await?; + + let encoded = (&&&&&&ServerFnDecoder::<#out_ty>::new()) + .make_axum_response( + #fn_name #ty_generics(#(#extracted_idents,)* #(#body_json_names,)* #(#server_names,)*).await + ); + + let response = (&&&&&ServerFnDecoder::<#out_ty>::new()) + .make_axum_error(encoded); + + return response; + } + + dioxus_fullstack::inventory::submit! { + ServerFunction::new( + dioxus_fullstack::http::Method::#method_ident, + __ENDPOINT_PATH, + || #__axum::routing::#http_method(__inner__function__ #ty_generics) + ) + } + + #(#server_defaults)* + + return #fn_name #ty_generics( + #(#extracted_idents,)* + #(#body_json_names,)* + #(#server_names,)* + ).await; + } + + #[allow(unreachable_code)] + { + unreachable!() + } + } + }) +} + +struct CompiledRoute { + method: Method, + #[allow(clippy::type_complexity)] + path_params: Vec<(Slash, PathParam)>, + query_params: Vec<(Ident, Box)>, + state: Type, + route_lit: Option, + prefix: Option, + oapi_options: Option, +} + +impl CompiledRoute { + fn to_axum_path_string(&self) -> String { + let mut path = String::new(); + + for (_slash, param) in &self.path_params { + path.push('/'); + match param { + PathParam::Capture(lit, _brace_1, _, _, _brace_2) => { + path.push('{'); + path.push_str(&lit.value()); + path.push('}'); + } + PathParam::WildCard(lit, _brace_1, _, _, _, _brace_2) => { + path.push('{'); + path.push('*'); + path.push_str(&lit.value()); + path.push('}'); + } + PathParam::Static(lit) => path.push_str(&lit.value()), + } + // if colon.is_some() { + // path.push(':'); + // } + // path.push_str(&ident.value()); + } + + path + } + + /// Removes the arguments in `route` from `args`, and merges them in the output. + pub fn from_route( + mut route: Route, + function: &ItemFn, + with_aide: bool, + method_from_macro: Option, + ) -> syn::Result { + if !with_aide && route.oapi_options.is_some() { + return Err(syn::Error::new( + Span::call_site(), + "Use `api_route` instead of `route` to use OpenAPI options", + )); + } else if with_aide && route.oapi_options.is_none() { + route.oapi_options = Some(OapiOptions { + summary: None, + description: None, + id: None, + hidden: None, + tags: None, + security: None, + responses: None, + transform: None, + }); + } + + let sig = &function.sig; + let mut arg_map = sig + .inputs + .iter() + .filter_map(|item| match item { + syn::FnArg::Receiver(_) => None, + syn::FnArg::Typed(pat_type) => Some(pat_type), + }) + .filter_map(|pat_type| match &*pat_type.pat { + syn::Pat::Ident(ident) => Some((ident.ident.clone(), pat_type.ty.clone())), + _ => None, + }) + .collect::>(); + + for (_slash, path_param) in &mut route.path_params { + match path_param { + PathParam::Capture(_lit, _, ident, ty, _) => { + let (new_ident, new_ty) = arg_map.remove_entry(ident).ok_or_else(|| { + syn::Error::new( + ident.span(), + format!("path parameter `{}` not found in function arguments", ident), + ) + })?; + *ident = new_ident; + *ty = new_ty; + } + PathParam::WildCard(_lit, _, _star, ident, ty, _) => { + let (new_ident, new_ty) = arg_map.remove_entry(ident).ok_or_else(|| { + syn::Error::new( + ident.span(), + format!("path parameter `{}` not found in function arguments", ident), + ) + })?; + *ident = new_ident; + *ty = new_ty; + } + PathParam::Static(_lit) => {} + } + } + + let mut query_params = Vec::new(); + for ident in route.query_params { + let (ident, ty) = arg_map.remove_entry(&ident).ok_or_else(|| { + syn::Error::new( + ident.span(), + format!( + "query parameter `{}` not found in function arguments", + ident + ), + ) + })?; + query_params.push((ident, ty)); + } + + if let Some(options) = route.oapi_options.as_mut() { + options.merge_with_fn(function) + } + + let method = match (method_from_macro, route.method) { + (Some(method), None) => method, + (None, Some(method)) => method, + (Some(_), Some(_)) => { + return Err(syn::Error::new( + Span::call_site(), + "HTTP method specified both in macro and in attribute", + )) + } + (None, None) => { + return Err(syn::Error::new( + Span::call_site(), + "HTTP method not specified in macro or in attribute", + )) + } + }; + + Ok(Self { + method, + route_lit: route.route_lit, + path_params: route.path_params, + query_params, + state: route.state.unwrap_or_else(|| guess_state_type(sig)), + oapi_options: route.oapi_options, + prefix: route.prefix, + }) + } + + pub fn path_extractor(&self) -> TokenStream2 { + let path_iter = self + .path_params + .iter() + .filter_map(|(_slash, path_param)| path_param.capture()); + let idents = path_iter.clone().map(|item| item.0); + let types = path_iter.clone().map(|item| item.1); + quote! { + dioxus_fullstack::axum::extract::Path((#(#idents,)*)): dioxus_fullstack::axum::extract::Path<(#(#types,)*)>, + } + } + + pub fn query_extractor(&self) -> TokenStream2 { + let idents = self.query_params.iter().map(|item| &item.0); + quote! { + dioxus_fullstack::axum::extract::Query(__QueryParams__ { #(#idents,)* }): dioxus_fullstack::axum::extract::Query<__QueryParams__>, + } + } + + pub fn query_params_struct(&self, with_aide: bool) -> TokenStream2 { + let idents = self.query_params.iter().map(|item| &item.0); + let types = self.query_params.iter().map(|item| &item.1); + let derive = match with_aide { + true => quote! { + #[derive(serde::Deserialize, serde::Serialize, ::schemars::JsonSchema)] + #[serde(crate = "serde")] + }, + false => quote! { + #[derive(serde::Deserialize, serde::Serialize)] + #[serde(crate = "serde")] + }, + }; + quote! { + #derive + struct __QueryParams__ { + #(#idents: #types,)* + } + } + } + + pub fn extracted_idents(&self) -> Vec { + let mut idents = Vec::new(); + for (_slash, path_param) in &self.path_params { + if let Some((ident, _ty)) = path_param.capture() { + idents.push(ident.clone()); + } + } + for (ident, _ty) in &self.query_params { + idents.push(ident.clone()); + } + idents + } + + fn remaining_pattypes_named( + &self, + args: &Punctuated, + ) -> Punctuated { + args.iter() + .filter_map(|item| { + if let FnArg::Typed(pat_type) = item { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + if self.path_params.iter().any(|(_slash, path_param)| { + if let Some((path_ident, _ty)) = path_param.capture() { + path_ident == &pat_ident.ident + } else { + false + } + }) || self + .query_params + .iter() + .any(|(query_ident, _)| query_ident == &pat_ident.ident) + { + return None; + } + } + + Some(pat_type.clone()) + } else { + unimplemented!("Self type is not supported") + } + }) + .collect() + } + + /// The arguments not used in the route. + /// Map the identifier to `___arg___{i}: Type`. + pub fn remaining_pattypes_numbered( + &self, + args: &Punctuated, + ) -> Punctuated { + args.iter() + .enumerate() + .filter_map(|(i, item)| { + if let FnArg::Typed(pat_type) = item { + if let syn::Pat::Ident(pat_ident) = &*pat_type.pat { + if self.path_params.iter().any(|(_slash, path_param)| { + if let Some((path_ident, _ty)) = path_param.capture() { + path_ident == &pat_ident.ident + } else { + false + } + }) || self + .query_params + .iter() + .any(|(query_ident, _)| query_ident == &pat_ident.ident) + { + return None; + } + } + + let mut new_pat_type = pat_type.clone(); + let ident = format_ident!("___arg___{}", i); + new_pat_type.pat = Box::new(parse_quote!(#ident)); + Some(new_pat_type) + } else { + unimplemented!("Self type is not supported") + } + }) + .collect() + } + + #[allow(dead_code)] + fn aide() { + // let http_method = format_ident!("{}_with", http_method); + // let summary = route + // .get_oapi_summary() + // .map(|summary| quote! { .summary(#summary) }); + // let description = route + // .get_oapi_description() + // .map(|description| quote! { .description(#description) }); + // let hidden = route + // .get_oapi_hidden() + // .map(|hidden| quote! { .hidden(#hidden) }); + // let tags = route.get_oapi_tags(); + // let id = route + // .get_oapi_id(&function.sig) + // .map(|id| quote! { .id(#id) }); + // let transform = route.get_oapi_transform()?; + // let responses = route.get_oapi_responses(); + // let response_code = responses.iter().map(|response| &response.0); + // let response_type = responses.iter().map(|response| &response.1); + // let security = route.get_oapi_security(); + // let schemes = security.iter().map(|sec| &sec.0); + // let scopes = security.iter().map(|sec| &sec.1); + + // ( + // route.ide_documentation_for_aide_methods(), + // quote! { + // ::aide::axum::routing::#http_method( + // __inner__function__ #ty_generics, + // |__op__| { + // let __op__ = __op__ + // #summary + // #description + // #hidden + // #id + // #(.tag(#tags))* + // #(.security_requirement_scopes::, _>(#schemes, vec![#(#scopes),*]))* + // #(.response::<#response_code, #response_type>())* + // ; + // #transform + // __op__ + // } + // ) + // }, + // quote! { ::aide::axum::routing::ApiMethodRouter }, + // ) + } + + #[allow(dead_code)] + pub fn ide_documentation_for_aide_methods(&self) -> TokenStream2 { + let Some(options) = &self.oapi_options else { + return quote! {}; + }; + let summary = options.summary.as_ref().map(|(ident, _)| { + let method = Ident::new("summary", ident.span()); + quote!( let x = x.#method(""); ) + }); + let description = options.description.as_ref().map(|(ident, _)| { + let method = Ident::new("description", ident.span()); + quote!( let x = x.#method(""); ) + }); + let id = options.id.as_ref().map(|(ident, _)| { + let method = Ident::new("id", ident.span()); + quote!( let x = x.#method(""); ) + }); + let hidden = options.hidden.as_ref().map(|(ident, _)| { + let method = Ident::new("hidden", ident.span()); + quote!( let x = x.#method(false); ) + }); + let tags = options.tags.as_ref().map(|(ident, _)| { + let method = Ident::new("tag", ident.span()); + quote!( let x = x.#method(""); ) + }); + let security = options.security.as_ref().map(|(ident, _)| { + let method = Ident::new("security_requirement_scopes", ident.span()); + quote!( let x = x.#method("", [""]); ) + }); + let responses = options.responses.as_ref().map(|(ident, _)| { + let method = Ident::new("response", ident.span()); + quote!( let x = x.#method::<0, String>(); ) + }); + let transform = options.transform.as_ref().map(|(ident, _)| { + let method = Ident::new("with", ident.span()); + quote!( let x = x.#method(|x|x); ) + }); + + quote! { + #[allow(unused)] + #[allow(clippy::no_effect)] + fn ____ide_documentation_for_aide____(x: ::aide::transform::TransformOperation) { + #summary + #description + #id + #hidden + #tags + #security + #responses + #transform + } + } + } + + #[allow(dead_code)] + pub fn get_oapi_summary(&self) -> Option { + if let Some(oapi_options) = &self.oapi_options { + if let Some(summary) = &oapi_options.summary { + return Some(summary.1.clone()); + } + } + None + } + + #[allow(dead_code)] + pub fn get_oapi_description(&self) -> Option { + if let Some(oapi_options) = &self.oapi_options { + if let Some(description) = &oapi_options.description { + return Some(description.1.clone()); + } + } + None + } + + #[allow(dead_code)] + pub fn get_oapi_hidden(&self) -> Option { + if let Some(oapi_options) = &self.oapi_options { + if let Some(hidden) = &oapi_options.hidden { + return Some(hidden.1.clone()); + } + } + None + } + + #[allow(dead_code)] + pub fn get_oapi_tags(&self) -> Vec { + if let Some(oapi_options) = &self.oapi_options { + if let Some(tags) = &oapi_options.tags { + return tags.1 .0.clone(); + } + } + Vec::new() + } + + #[allow(dead_code)] + pub fn get_oapi_id(&self, sig: &Signature) -> Option { + if let Some(oapi_options) = &self.oapi_options { + if let Some(id) = &oapi_options.id { + return Some(id.1.clone()); + } + } + Some(LitStr::new(&sig.ident.to_string(), sig.ident.span())) + } + + #[allow(dead_code)] + pub fn get_oapi_transform(&self) -> syn::Result> { + if let Some(oapi_options) = &self.oapi_options { + if let Some(transform) = &oapi_options.transform { + if transform.1.inputs.len() != 1 { + return Err(syn::Error::new( + transform.1.span(), + "expected a single identifier", + )); + } + + let pat = transform.1.inputs.first().unwrap(); + let body = &transform.1.body; + + if let Pat::Ident(pat_ident) = pat { + let ident = &pat_ident.ident; + return Ok(Some(quote! { + let #ident = __op__; + let __op__ = #body; + })); + } else { + return Err(syn::Error::new( + pat.span(), + "expected a single identifier without type", + )); + } + } + } + Ok(None) + } + + #[allow(dead_code)] + pub fn get_oapi_responses(&self) -> Vec<(LitInt, Type)> { + if let Some(oapi_options) = &self.oapi_options { + if let Some((_ident, Responses(responses))) = &oapi_options.responses { + return responses.clone(); + } + } + Default::default() + } + + #[allow(dead_code)] + pub fn get_oapi_security(&self) -> Vec<(LitStr, Vec)> { + if let Some(oapi_options) = &self.oapi_options { + if let Some((_ident, Security(security))) = &oapi_options.security { + return security + .iter() + .map(|(scheme, StrArray(scopes))| (scheme.clone(), scopes.clone())) + .collect(); + } + } + Default::default() + } + + pub(crate) fn to_doc_comments(&self) -> TokenStream2 { + let mut doc = format!( + "# Handler information +- Method: `{}` +- Path: `{}` +- State: `{}`", + self.method.to_axum_method_name(), + self.route_lit + .as_ref() + .map(|lit| lit.value()) + .unwrap_or_else(|| "".into()), + self.state.to_token_stream(), + ); + + if let Some(options) = &self.oapi_options { + let summary = options + .summary + .as_ref() + .map(|(_, summary)| format!("\"{}\"", summary.value())) + .unwrap_or("None".to_string()); + let description = options + .description + .as_ref() + .map(|(_, description)| format!("\"{}\"", description.value())) + .unwrap_or("None".to_string()); + let id = options + .id + .as_ref() + .map(|(_, id)| format!("\"{}\"", id.value())) + .unwrap_or("None".to_string()); + let hidden = options + .hidden + .as_ref() + .map(|(_, hidden)| hidden.value().to_string()) + .unwrap_or("None".to_string()); + let tags = options + .tags + .as_ref() + .map(|(_, tags)| tags.to_string()) + .unwrap_or("[]".to_string()); + let security = options + .security + .as_ref() + .map(|(_, security)| security.to_string()) + .unwrap_or("{}".to_string()); + + doc = format!( + "{doc} + +## OpenAPI +- Summary: `{summary}` +- Description: `{description}` +- Operation id: `{id}` +- Tags: `{tags}` +- Security: `{security}` +- Hidden: `{hidden}` +" + ); + } + + quote!( + #[doc = #doc] + ) + } +} + +fn guess_state_type(sig: &syn::Signature) -> Type { + for arg in &sig.inputs { + if let FnArg::Typed(pat_type) = arg { + // Returns `T` if the type of the last segment is exactly `State`. + if let Type::Path(ty) = &*pat_type.ty { + let last_segment = ty.path.segments.last().unwrap(); + if last_segment.ident == "State" { + if let PathArguments::AngleBracketed(args) = &last_segment.arguments { + if args.args.len() == 1 { + if let GenericArgument::Type(ty) = args.args.first().unwrap() { + return ty.clone(); + } + } + } + } + } + } + } + + parse_quote! { () } +} + +struct RouteParser { + path_params: Vec<(Slash, PathParam)>, + query_params: Vec, +} + +impl RouteParser { + fn new(lit: LitStr) -> syn::Result { + let val = lit.value(); + let span = lit.span(); + let split_route = val.split('?').collect::>(); + if split_route.len() > 2 { + return Err(syn::Error::new(span, "expected at most one '?'")); + } + + let path = split_route[0]; + if !path.starts_with('/') { + return Err(syn::Error::new(span, "expected path to start with '/'")); + } + let path = path.strip_prefix('/').unwrap(); + + let mut path_params = Vec::new(); + + for path_param in path.split('/') { + path_params.push(( + Slash(span), + PathParam::new(path_param, span, Box::new(parse_quote!(())))?, + )); + } + + let path_param_len = path_params.len(); + for (i, (_slash, path_param)) in path_params.iter().enumerate() { + match path_param { + PathParam::WildCard(_, _, _, _, _, _) => { + if i != path_param_len - 1 { + return Err(syn::Error::new( + span, + "wildcard path param must be the last path param", + )); + } + } + PathParam::Capture(_, _, _, _, _) => (), + PathParam::Static(lit) => { + if lit.value() == "*" && i != path_param_len - 1 { + return Err(syn::Error::new( + span, + "wildcard path param must be the last path param", + )); + } + } + } + } + + let mut query_params = Vec::new(); + if split_route.len() == 2 { + let query = split_route[1]; + for query_param in query.split('&') { + query_params.push(Ident::new(query_param, span)); + } + } + + Ok(Self { + path_params, + query_params, + }) + } +} + +enum PathParam { + WildCard(LitStr, Brace, Star, Ident, Box, Brace), + Capture(LitStr, Brace, Ident, Box, Brace), + Static(LitStr), +} + +impl PathParam { + fn _captures(&self) -> bool { + matches!(self, Self::Capture(..) | Self::WildCard(..)) + } + + fn capture(&self) -> Option<(&Ident, &Type)> { + match self { + Self::Capture(_, _, ident, ty, _) => Some((ident, ty)), + Self::WildCard(_, _, _, ident, ty, _) => Some((ident, ty)), + _ => None, + } + } + + fn new(str: &str, span: Span, ty: Box) -> syn::Result { + let ok = if str.starts_with('{') { + let str = str + .strip_prefix('{') + .unwrap() + .strip_suffix('}') + .ok_or_else(|| { + syn::Error::new(span, "expected path param to be wrapped in curly braces") + })?; + Self::Capture( + LitStr::new(str, span), + Brace(span), + Ident::new(str, span), + ty, + Brace(span), + ) + } else if str.starts_with('*') && str.len() > 1 { + let str = str.strip_prefix('*').unwrap(); + Self::WildCard( + LitStr::new(str, span), + Brace(span), + Star(span), + Ident::new(str, span), + ty, + Brace(span), + ) + } else { + Self::Static(LitStr::new(str, span)) + }; + + Ok(ok) + } +} + +struct OapiOptions { + summary: Option<(Ident, LitStr)>, + description: Option<(Ident, LitStr)>, + id: Option<(Ident, LitStr)>, + hidden: Option<(Ident, LitBool)>, + tags: Option<(Ident, StrArray)>, + security: Option<(Ident, Security)>, + responses: Option<(Ident, Responses)>, + transform: Option<(Ident, ExprClosure)>, +} + +struct Security(Vec<(LitStr, StrArray)>); +impl Parse for Security { + fn parse(input: ParseStream) -> syn::Result { + let inner; + braced!(inner in input); + + let mut arr = Vec::new(); + while !inner.is_empty() { + let scheme = inner.parse::()?; + let _ = inner.parse::()?; + let scopes = inner.parse::()?; + let _ = inner.parse::().ok(); + arr.push((scheme, scopes)); + } + + Ok(Self(arr)) + } +} + +impl std::fmt::Display for Security { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{{")?; + for (i, (scheme, scopes)) in self.0.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}: {}", scheme.value(), scopes)?; + } + write!(f, "}}") + } +} + +struct Responses(Vec<(LitInt, Type)>); +impl Parse for Responses { + fn parse(input: ParseStream) -> syn::Result { + let inner; + braced!(inner in input); + + let mut arr = Vec::new(); + while !inner.is_empty() { + let status = inner.parse::()?; + let _ = inner.parse::()?; + let ty = inner.parse::()?; + let _ = inner.parse::().ok(); + arr.push((status, ty)); + } + + Ok(Self(arr)) + } +} + +impl std::fmt::Display for Responses { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{{")?; + for (i, (status, ty)) in self.0.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}: {}", status, ty.to_token_stream())?; + } + write!(f, "}}") + } +} + +#[derive(Clone)] +struct StrArray(Vec); +impl Parse for StrArray { + fn parse(input: ParseStream) -> syn::Result { + let inner; + bracketed!(inner in input); + let mut arr = Vec::new(); + while !inner.is_empty() { + arr.push(inner.parse::()?); + inner.parse::().ok(); + } + Ok(Self(arr)) + } +} + +impl std::fmt::Display for StrArray { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[")?; + for (i, lit) in self.0.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "\"{}\"", lit.value())?; + } + write!(f, "]") + } +} + +impl Parse for OapiOptions { + fn parse(input: ParseStream) -> syn::Result { + let mut this = Self { + summary: None, + description: None, + id: None, + hidden: None, + tags: None, + security: None, + responses: None, + transform: None, + }; + + while !input.is_empty() { + let ident = input.parse::()?; + let _ = input.parse::()?; + match ident.to_string().as_str() { + "summary" => this.summary = Some((ident, input.parse()?)), + "description" => this.description = Some((ident, input.parse()?)), + "id" => this.id = Some((ident, input.parse()?)), + "hidden" => this.hidden = Some((ident, input.parse()?)), + "tags" => this.tags = Some((ident, input.parse()?)), + "security" => this.security = Some((ident, input.parse()?)), + "responses" => this.responses = Some((ident, input.parse()?)), + "transform" => this.transform = Some((ident, input.parse()?)), + _ => { + return Err(syn::Error::new( + ident.span(), + "unexpected field, expected one of (summary, description, id, hidden, tags, security, responses, transform)", + )) + } + } + let _ = input.parse::().ok(); + } + + Ok(this) + } +} + +impl OapiOptions { + fn merge_with_fn(&mut self, function: &ItemFn) { + if self.description.is_none() { + self.description = doc_iter(&function.attrs) + .skip(2) + .map(|item| item.value()) + .reduce(|mut acc, item| { + acc.push('\n'); + acc.push_str(&item); + acc + }) + .map(|item| (parse_quote!(description), parse_quote!(#item))) + } + if self.summary.is_none() { + self.summary = doc_iter(&function.attrs) + .next() + .map(|item| (parse_quote!(summary), item.clone())) + } + if self.id.is_none() { + let id = &function.sig.ident; + self.id = Some((parse_quote!(id), LitStr::new(&id.to_string(), id.span()))); + } + } +} + +fn doc_iter(attrs: &[Attribute]) -> impl Iterator + '_ { + attrs + .iter() + .filter(|attr| attr.path().is_ident("doc")) + .map(|attr| { + let Meta::NameValue(meta) = &attr.meta else { + panic!("doc attribute is not a name-value attribute"); + }; + let Expr::Lit(lit) = &meta.value else { + panic!("doc attribute is not a string literal"); + }; + let Lit::Str(lit_str) = &lit.lit else { + panic!("doc attribute is not a string literal"); + }; + lit_str + }) +} + +struct Route { + method: Option, + path_params: Vec<(Slash, PathParam)>, + query_params: Vec, + state: Option, + route_lit: Option, + prefix: Option, + oapi_options: Option, + server_args: Punctuated, + + // todo: support these since `server_fn` had them + _input_encoding: Option, + _output_encoding: Option, +} + +impl Parse for Route { + fn parse(input: ParseStream) -> syn::Result { + let method = if input.peek(Ident) { + Some(input.parse::()?) + } else { + None + }; + + let route_lit = input.parse::()?; + let RouteParser { + path_params, + query_params, + } = RouteParser::new(route_lit.clone())?; + + // todo: maybe let the user include `State` here, eventually? + // let state = match input.parse::() { + // Ok(_) => Some(input.parse::()?), + // Err(_) => None, + // }; + + let state = None; + let oapi_options = input + .peek(Brace) + .then(|| { + let inner; + braced!(inner in input); + inner.parse::() + }) + .transpose()?; + + let server_args = if input.peek(Comma) { + let _ = input.parse::()?; + input.parse_terminated(FnArg::parse, Comma)? + } else { + Punctuated::new() + }; + + Ok(Route { + method, + path_params, + query_params, + state, + route_lit: Some(route_lit), + oapi_options, + server_args, + prefix: None, + _input_encoding: None, + _output_encoding: None, + }) + } +} + +#[derive(Clone)] +enum Method { + Get(Ident), + Post(Ident), + Put(Ident), + Delete(Ident), + Head(Ident), + Connect(Ident), + Options(Ident), + Trace(Ident), +} + +impl ToTokens for Method { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + Self::Get(ident) + | Self::Post(ident) + | Self::Put(ident) + | Self::Delete(ident) + | Self::Head(ident) + | Self::Connect(ident) + | Self::Options(ident) + | Self::Trace(ident) => { + ident.to_tokens(tokens); + } + } + } +} + +impl Parse for Method { + fn parse(input: ParseStream) -> syn::Result { + let ident = input.parse::()?; + match ident.to_string().to_uppercase().as_str() { + "GET" => Ok(Self::Get(ident)), + "POST" => Ok(Self::Post(ident)), + "PUT" => Ok(Self::Put(ident)), + "DELETE" => Ok(Self::Delete(ident)), + "HEAD" => Ok(Self::Head(ident)), + "CONNECT" => Ok(Self::Connect(ident)), + "OPTIONS" => Ok(Self::Options(ident)), + "TRACE" => Ok(Self::Trace(ident)), + _ => Err(input + .error("expected one of (GET, POST, PUT, DELETE, HEAD, CONNECT, OPTIONS, TRACE)")), + } + } +} + +impl Method { + fn to_axum_method_name(&self) -> Ident { + match self { + Self::Get(span) => Ident::new("get", span.span()), + Self::Post(span) => Ident::new("post", span.span()), + Self::Put(span) => Ident::new("put", span.span()), + Self::Delete(span) => Ident::new("delete", span.span()), + Self::Head(span) => Ident::new("head", span.span()), + Self::Connect(span) => Ident::new("connect", span.span()), + Self::Options(span) => Ident::new("options", span.span()), + Self::Trace(span) => Ident::new("trace", span.span()), + } + } + + fn new_from_string(s: &str) -> Self { + match s.to_uppercase().as_str() { + "GET" => Self::Get(Ident::new("GET", Span::call_site())), + "POST" => Self::Post(Ident::new("POST", Span::call_site())), + "PUT" => Self::Put(Ident::new("PUT", Span::call_site())), + "DELETE" => Self::Delete(Ident::new("DELETE", Span::call_site())), + "HEAD" => Self::Head(Ident::new("HEAD", Span::call_site())), + "CONNECT" => Self::Connect(Ident::new("CONNECT", Span::call_site())), + "OPTIONS" => Self::Options(Ident::new("OPTIONS", Span::call_site())), + "TRACE" => Self::Trace(Ident::new("TRACE", Span::call_site())), + _ => panic!("expected one of (GET, POST, PUT, DELETE, HEAD, CONNECT, OPTIONS, TRACE)"), + } + } +} + +mod kw { + syn::custom_keyword!(with); +} + +/// The arguments to the `server` macro. +/// +/// These originally came from the `server_fn` crate, but many no longer apply after the 0.7 fullstack +/// overhaul. We keep the parser here for temporary backwards compatibility with existing code, but +/// these arguments will be removed in a future release. +#[derive(Debug)] +#[non_exhaustive] +#[allow(unused)] +struct ServerFnArgs { + /// The name of the struct that will implement the server function trait + /// and be submitted to inventory. + struct_name: Option, + /// The prefix to use for the server function URL. + prefix: Option, + /// The input http encoding to use for the server function. + input: Option, + /// Additional traits to derive on the input struct for the server function. + input_derive: Option, + /// The output http encoding to use for the server function. + output: Option, + /// The path to the server function crate. + fn_path: Option, + /// The server type to use for the server function. + server: Option, + /// The client type to use for the server function. + client: Option, + /// The custom wrapper to use for the server function struct. + custom_wrapper: Option, + /// If the generated input type should implement `From` the only field in the input + impl_from: Option, + /// If the generated input type should implement `Deref` to the only field in the input + impl_deref: Option, + /// The protocol to use for the server function implementation. + protocol: Option, + builtin_encoding: bool, +} + +impl Parse for ServerFnArgs { + fn parse(stream: ParseStream) -> syn::Result { + // legacy 4-part arguments + let mut struct_name: Option = None; + let mut prefix: Option = None; + let mut encoding: Option = None; + let mut fn_path: Option = None; + + // new arguments: can only be keyed by name + let mut input: Option = None; + let mut input_derive: Option = None; + let mut output: Option = None; + let mut server: Option = None; + let mut client: Option = None; + let mut custom_wrapper: Option = None; + let mut impl_from: Option = None; + let mut impl_deref: Option = None; + let mut protocol: Option = None; + + let mut use_key_and_value = false; + let mut arg_pos = 0; + + while !stream.is_empty() { + arg_pos += 1; + let lookahead = stream.lookahead1(); + if lookahead.peek(Ident) { + let key_or_value: Ident = stream.parse()?; + + let lookahead = stream.lookahead1(); + if lookahead.peek(Token![=]) { + stream.parse::()?; + let key = key_or_value; + use_key_and_value = true; + if key == "name" { + if struct_name.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `name`", + )); + } + struct_name = Some(stream.parse()?); + } else if key == "prefix" { + if prefix.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `prefix`", + )); + } + prefix = Some(stream.parse()?); + } else if key == "encoding" { + if encoding.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `encoding`", + )); + } + encoding = Some(stream.parse()?); + } else if key == "endpoint" { + if fn_path.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `endpoint`", + )); + } + fn_path = Some(stream.parse()?); + } else if key == "input" { + if encoding.is_some() { + return Err(syn::Error::new( + key.span(), + "`encoding` and `input` should not both be \ + specified", + )); + } else if input.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `input`", + )); + } + input = Some(stream.parse()?); + } else if key == "input_derive" { + if input_derive.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `input_derive`", + )); + } + input_derive = Some(stream.parse()?); + } else if key == "output" { + if encoding.is_some() { + return Err(syn::Error::new( + key.span(), + "`encoding` and `output` should not both be \ + specified", + )); + } else if output.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `output`", + )); + } + output = Some(stream.parse()?); + } else if key == "server" { + if server.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `server`", + )); + } + server = Some(stream.parse()?); + } else if key == "client" { + if client.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `client`", + )); + } + client = Some(stream.parse()?); + } else if key == "custom" { + if custom_wrapper.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `custom`", + )); + } + custom_wrapper = Some(stream.parse()?); + } else if key == "impl_from" { + if impl_from.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `impl_from`", + )); + } + impl_from = Some(stream.parse()?); + } else if key == "impl_deref" { + if impl_deref.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `impl_deref`", + )); + } + impl_deref = Some(stream.parse()?); + } else if key == "protocol" { + if protocol.is_some() { + return Err(syn::Error::new( + key.span(), + "keyword argument repeated: `protocol`", + )); + } + protocol = Some(stream.parse()?); + } else { + return Err(lookahead.error()); + } + } else { + let value = key_or_value; + if use_key_and_value { + return Err(syn::Error::new( + value.span(), + "positional argument follows keyword argument", + )); + } + if arg_pos == 1 { + struct_name = Some(value) + } else { + return Err(syn::Error::new(value.span(), "expected string literal")); + } + } + } else if lookahead.peek(LitStr) { + if use_key_and_value { + return Err(syn::Error::new( + stream.span(), + "If you use keyword arguments (e.g., `name` = \ + Something), then you can no longer use arguments \ + without a keyword.", + )); + } + match arg_pos { + 1 => return Err(lookahead.error()), + 2 => prefix = Some(stream.parse()?), + 3 => encoding = Some(stream.parse()?), + 4 => fn_path = Some(stream.parse()?), + _ => return Err(syn::Error::new(stream.span(), "unexpected extra argument")), + } + } else { + return Err(lookahead.error()); + } + + if !stream.is_empty() { + stream.parse::()?; + } + } + + // parse legacy encoding into input/output + let mut builtin_encoding = false; + if let Some(encoding) = encoding { + match encoding.value().to_lowercase().as_str() { + "url" => { + input = Some(type_from_ident(syn::parse_quote!(Url))); + output = Some(type_from_ident(syn::parse_quote!(Json))); + builtin_encoding = true; + } + "cbor" => { + input = Some(type_from_ident(syn::parse_quote!(Cbor))); + output = Some(type_from_ident(syn::parse_quote!(Cbor))); + builtin_encoding = true; + } + "getcbor" => { + input = Some(type_from_ident(syn::parse_quote!(GetUrl))); + output = Some(type_from_ident(syn::parse_quote!(Cbor))); + builtin_encoding = true; + } + "getjson" => { + input = Some(type_from_ident(syn::parse_quote!(GetUrl))); + output = Some(syn::parse_quote!(Json)); + builtin_encoding = true; + } + _ => return Err(syn::Error::new(encoding.span(), "Encoding not found.")), + } + } + + Ok(Self { + struct_name, + prefix, + input, + input_derive, + output, + fn_path, + builtin_encoding, + server, + client, + custom_wrapper, + impl_from, + impl_deref, + protocol, + }) + } +} + +/// An argument type in a server function. +#[allow(unused)] +// todo - we used to support a number of these attributes and pass them along to serde. bring them back. +#[derive(Debug, Clone)] +struct ServerFnArg { + /// The attributes on the server function argument. + server_fn_attributes: Vec, + /// The type of the server function argument. + arg: syn::PatType, +} + +impl ToTokens for ServerFnArg { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let ServerFnArg { arg, .. } = self; + tokens.extend(quote! { + #arg + }); + } +} + +impl Parse for ServerFnArg { + fn parse(input: ParseStream) -> Result { + let arg: syn::FnArg = input.parse()?; + let mut arg = match arg { + FnArg::Receiver(_) => { + return Err(syn::Error::new( + arg.span(), + "cannot use receiver types in server function macro", + )) + } + FnArg::Typed(t) => t, + }; + + fn rename_path(path: Path, from_ident: Ident, to_ident: Ident) -> Path { + if path.is_ident(&from_ident) { + Path { + leading_colon: None, + segments: Punctuated::from_iter([PathSegment { + ident: to_ident, + arguments: PathArguments::None, + }]), + } + } else { + path + } + } + + let server_fn_attributes = arg + .attrs + .iter() + .cloned() + .map(|attr| { + if attr.path().is_ident("server") { + // Allow the following attributes: + // - #[server(default)] + // - #[server(rename = "fieldName")] + + // Rename `server` to `serde` + let attr = Attribute { + meta: match attr.meta { + Meta::Path(path) => Meta::Path(rename_path( + path, + format_ident!("server"), + format_ident!("serde"), + )), + Meta::List(mut list) => { + list.path = rename_path( + list.path, + format_ident!("server"), + format_ident!("serde"), + ); + Meta::List(list) + } + Meta::NameValue(mut name_value) => { + name_value.path = rename_path( + name_value.path, + format_ident!("server"), + format_ident!("serde"), + ); + Meta::NameValue(name_value) + } + }, + ..attr + }; + + let args = attr.parse_args::()?; + match args { + // #[server(default)] + Meta::Path(path) if path.is_ident("default") => Ok(attr.clone()), + // #[server(flatten)] + Meta::Path(path) if path.is_ident("flatten") => Ok(attr.clone()), + // #[server(default = "value")] + Meta::NameValue(name_value) if name_value.path.is_ident("default") => { + Ok(attr.clone()) + } + // #[server(skip)] + Meta::Path(path) if path.is_ident("skip") => Ok(attr.clone()), + // #[server(rename = "value")] + Meta::NameValue(name_value) if name_value.path.is_ident("rename") => { + Ok(attr.clone()) + } + _ => Err(Error::new( + attr.span(), + "Unrecognized #[server] attribute, expected \ + #[server(default)] or #[server(rename = \ + \"fieldName\")]", + )), + } + } else if attr.path().is_ident("doc") { + // Allow #[doc = "documentation"] + Ok(attr.clone()) + } else if attr.path().is_ident("allow") { + // Allow #[allow(...)] + Ok(attr.clone()) + } else if attr.path().is_ident("deny") { + // Allow #[deny(...)] + Ok(attr.clone()) + } else if attr.path().is_ident("ignore") { + // Allow #[ignore] + Ok(attr.clone()) + } else { + Err(Error::new( + attr.span(), + "Unrecognized attribute, expected #[server(...)]", + )) + } + }) + .collect::>>()?; + arg.attrs = vec![]; + Ok(ServerFnArg { + arg, + server_fn_attributes, + }) + } +} + +fn type_from_ident(ident: Ident) -> Type { + let mut segments = Punctuated::new(); + segments.push(PathSegment { + ident, + arguments: PathArguments::None, + }); + Type::Path(TypePath { + qself: None, + path: Path { + leading_colon: None, + segments, + }, + }) +} diff --git a/packages/fullstack-protocol/Cargo.toml b/packages/fullstack-protocol/Cargo.toml deleted file mode 100644 index b12f2a34b..000000000 --- a/packages/fullstack-protocol/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "dioxus-fullstack-protocol" -edition = "2021" -version.workspace = true -authors = ["Jonathan Kelley", "Evan Almloff"] -description = "Fullstack protocol for Dioxus: Build fullstack web, desktop, and mobile apps with a single codebase." -license = "MIT OR Apache-2.0" -repository = "https://github.com/DioxusLabs/dioxus/" -homepage = "https://dioxuslabs.com" -keywords = ["web", "desktop", "mobile", "gui", "server"] -resolver = "2" - -[dependencies] -ciborium = { workspace = true } -dioxus-core = { workspace = true } -base64 = { workspace = true } -serde = { workspace = true } -tracing = { workspace = true } - -[features] -web = [] diff --git a/packages/fullstack-protocol/README.md b/packages/fullstack-protocol/README.md deleted file mode 100644 index cd0f68c0c..000000000 --- a/packages/fullstack-protocol/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Fullstack Protocol - -Dioxus-fullstack-protocol is the internal protocol the dioxus web and server renderers use to communicate with each other in dioxus fullstack. It is used to send futures and values from the server to the client during fullstack rendering. diff --git a/packages/fullstack/.gitignore b/packages/fullstack-server/.gitignore similarity index 100% rename from packages/fullstack/.gitignore rename to packages/fullstack-server/.gitignore diff --git a/packages/fullstack-server/Cargo.toml b/packages/fullstack-server/Cargo.toml new file mode 100644 index 000000000..47b638e98 --- /dev/null +++ b/packages/fullstack-server/Cargo.toml @@ -0,0 +1,104 @@ +[package] +name = "dioxus-server" +authors = ["Jonathan Kelley", "Evan Almloff"] +version = { workspace = true } +edition = "2021" +description = "Fullstack utilities for Dioxus: Build fullstack web, desktop, and mobile apps with a single codebase." +license = "MIT OR Apache-2.0" +repository = "https://github.com/DioxusLabs/dioxus/" +homepage = "https://dioxuslabs.com" +keywords = ["web", "desktop", "mobile", "gui", "server"] +resolver = "2" + +[dependencies] + +# Dioxus + SSR +subsecond = { workspace = true } +dioxus-cli-config = { workspace = true } +dioxus-devtools = { workspace = true, features = ["serve"] } +dioxus-history = { workspace = true } +dioxus-signals = { workspace = true } +dioxus-hooks = { workspace = true } +dioxus-logger = { workspace = true } +dioxus-router = { workspace = true, features = ["streaming"], optional = true } +dioxus-fullstack-core = { workspace = true, features = ["server"] } +dioxus-core = { workspace = true } +dioxus-core-macro = { workspace = true } +dioxus-document = { workspace = true } +dioxus-html = { workspace = true } +generational-box = { workspace = true } + + +axum = { workspace = true, features = ["multipart", "ws", "json", "form", "tokio", "http1", "http2", "macros"]} +anyhow = { workspace = true } +dashmap = "6.1.0" +inventory = { workspace = true } +dioxus-ssr = { workspace = true } + +hyper-util = { workspace = true, features = ["full"] } +hyper = { workspace = true } + +# Web Integration +dioxus-interpreter-js = { workspace = true } + +tracing = { workspace = true } +tracing-futures = { workspace = true } +tokio-util = { workspace = true, features = ["rt"] } +async-trait = { workspace = true } + +serde = { workspace = true } +futures-util = { workspace = true } +futures-channel = { workspace = true } +ciborium = { workspace = true } +base64 = { workspace = true } +rustls = { workspace = true, optional = true } +hyper-rustls = { workspace = true, optional = true } + +url = { workspace = true, default-features = true } +serde_json = { workspace = true } + +serde_qs = { workspace = true, default-features = true } +multer = { optional = true, workspace = true, default-features = true } +rkyv = { optional = true, default-features = true, version = "0.8" } + +futures = { workspace = true, default-features = true } +pin-project = { workspace = true } +thiserror = { workspace = true } +bytes = {version = "1.10.1", features = ["serde"]} +tower-http = { workspace = true, features = ["fs"] } +tower = { workspace = true, features = ["util"] } +tower-layer = { version = "0.3.3", optional = true } +parking_lot = { workspace = true, features = ["send_guard"] } + +tokio-tungstenite = { workspace = true } +http.workspace = true +enumset = "1.1.6" +http-body-util = "0.1.3" + +chrono = { workspace = true } +rustc-hash = { workspace = true } +lru = { workspace = true } +walkdir = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { workspace = true, features = ["rt", "sync", "macros"], optional = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { workspace = true, features = ["rt", "sync", "rt-multi-thread", "macros", "net"] } + +[dev-dependencies] +dioxus = { workspace = true, features = ["fullstack"] } +tokio = { workspace = true, features = ["full"] } + +[features] +default = ["router", "document"] +document = [] +router = ["dep:dioxus-router"] +default-tls = [] +rustls = ["dep:rustls", "dep:hyper-rustls"] +axum-no-default = [] +rkyv = ["dep:rkyv"] + +[package.metadata.docs.rs] +cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] +features = ["axum", "web", "aws-lc-rs"] diff --git a/packages/fullstack-server/README.md b/packages/fullstack-server/README.md new file mode 100644 index 000000000..dabda771c --- /dev/null +++ b/packages/fullstack-server/README.md @@ -0,0 +1,144 @@ +# Dioxus Fullstack + +[![Crates.io][crates-badge]][crates-url] +[![MIT licensed][mit-badge]][mit-url] +[![Build Status][actions-badge]][actions-url] +[![Discord chat][discord-badge]][discord-url] + +[crates-badge]: https://img.shields.io/crates/v/dioxus-fullstack.svg +[crates-url]: https://crates.io/crates/dioxus-fullstack +[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg +[mit-url]: https://github.com/dioxuslabs/dioxus/blob/main/LICENSE-MIT +[actions-badge]: https://github.com/dioxuslabs/dioxus/actions/workflows/main.yml/badge.svg +[actions-url]: https://github.com/dioxuslabs/dioxus/actions?query=workflow%3ACI+branch%3Amaster +[discord-badge]: https://img.shields.io/discord/899851952891002890.svg?logo=discord&style=flat-square +[discord-url]: https://discord.gg/XgGxMSkvUM + +[Website](https://dioxuslabs.com) | +[Guides](https://dioxuslabs.com/learn/0.6/) | +[API Docs](https://docs.rs/dioxus-fullstack/latest/dioxus_fullstack/) | +[Chat](https://discord.gg/XgGxMSkvUM) + +Fullstack utilities for the [`Dioxus`](https://dioxuslabs.com) framework. + +# Features + +- Integrates with the [Axum](./examples/axum-hello-world/src/main.rs) server framework with utilities for serving and rendering Dioxus applications. +- [Server functions](https://docs.rs/dioxus-fullstack/latest/dioxus_fullstack/prelude/attr.server.html) allow you to call code on the server from the client as if it were a normal function. +- Instant RSX Hot reloading with [`dioxus-hot-reload`](https://crates.io/crates/dioxus-hot-reload). +- Passing root props from the server to the client. + +# Example +Quickly build fullstack Rust apps with axum and dioxus. + +```rust, no_run +use dioxus::prelude::*; + +fn main() { + dioxus::launch(|| { + let mut meaning = use_action(|| get_meaning("life the universe and everything".into())); + + rsx! { + h1 { "Meaning of life: {meaning:?}" } + button { onclick: move |_| meaning.call(), "Run a server function" } + } + }); +} + +#[get("/meaning/?of")] +async fn get_meaning(of: String) -> Result> { + Ok(of.contains("life").then(|| 42)) +} +``` + +## Axum Integration + +If you have an existing Axum router or you need more control over the server, you can use the [`DioxusRouterExt`](https://docs.rs/dioxus-fullstack/0.6.0-alpha.2/dioxus_fullstack/prelude/trait.DioxusRouterExt.html) trait to integrate with your existing Axum router. + +First, make sure your `axum` dependency is optional and enabled by the server feature flag. Axum cannot be compiled to wasm, so if it is enabled by default, it will cause a compile error: + +```toml +[dependencies] +dioxus = { version = "*", features = ["fullstack"] } +axum = { version = "0.8.0", optional = true } +tokio = { version = "1.0", features = ["full"], optional = true } +dioxus-cli-config = { version = "*", optional = true } + +[features] +server = ["dioxus/server", "dep:axum", "dep:tokio", "dioxus-cli-config"] +web = ["dioxus/web"] +``` + +Then we can set up dioxus with the axum server: + +```rust, no_run +#![allow(non_snake_case)] +use dioxus::prelude::*; + +// The entry point for the server +#[cfg(feature = "server")] +#[tokio::main] +async fn main() { + // Get the address the server should run on. If the CLI is running, the CLI proxies fullstack into the main address + // and we use the generated address the CLI gives us + let address = dioxus::cli_config::fullstack_address_or_localhost(); + + // Set up the axum router + let router = axum::Router::new() + // You can add a dioxus application to the router with the `serve_dioxus_application` method + // This will add a fallback route to the router that will serve your component and server functions + .serve_dioxus_application(dioxus::server::ServeConfig::new().unwrap(), App); + + // Finally, we can launch the server + let router = router.into_make_service(); + let listener = tokio::net::TcpListener::bind(address).await.unwrap(); + axum::serve(listener, router).await.unwrap(); +} + +// For any other platform, we just launch the app +#[cfg(not(feature = "server"))] +fn main() { + dioxus::launch(App); +} + +#[component] +fn App() -> Element { + let mut meaning = use_signal(|| None); + + rsx! { + h1 { "Meaning of life: {meaning:?}" } + button { + onclick: move |_| async move { + if let Ok(data) = get_meaning("life the universe and everything".into()).await { + meaning.set(data); + } + }, + "Run a server function" + } + } +} + +#[server] +async fn get_meaning(of: String) -> ServerFnResult> { + Ok(of.contains("life").then(|| 42)) +} +``` + +## Getting Started + +To get started with full stack Dioxus, check out our [getting started guide](https://dioxuslabs.com/learn/0.6/getting_started), or the [full stack examples](https://github.com/DioxusLabs/dioxus/tree/master/examples). + +## Contributing + +- Report issues on our [issue tracker](https://github.com/dioxuslabs/dioxus/issues). +- Join the discord and ask questions! + +## License + +This project is licensed under the [MIT license]. + +[mit license]: https://github.com/dioxuslabs/dioxus/blob/main/LICENSE-MIT + +Unless you explicitly state otherwise, any contribution intentionally submitted +for inclusion in Dioxus by you shall be licensed as MIT without any additional +terms or conditions. diff --git a/packages/fullstack/docs/request_origin.md b/packages/fullstack-server/docs/request_origin.md similarity index 100% rename from packages/fullstack/docs/request_origin.md rename to packages/fullstack-server/docs/request_origin.md diff --git a/packages/server/src/config.rs b/packages/fullstack-server/src/config.rs similarity index 92% rename from packages/server/src/config.rs rename to packages/fullstack-server/src/config.rs index e7b209825..5dfbea9c5 100644 --- a/packages/server/src/config.rs +++ b/packages/fullstack-server/src/config.rs @@ -8,6 +8,8 @@ use std::io::Read; use std::path::PathBuf; use std::sync::Arc; +use crate::IncrementalRendererConfig; + #[allow(unused)] pub(crate) type ContextProviders = Arc Box + Send + Sync + 'static>>>; @@ -18,7 +20,7 @@ pub struct ServeConfigBuilder { pub(crate) root_id: Option<&'static str>, pub(crate) index_html: Option, pub(crate) index_path: Option, - pub(crate) incremental: Option, + pub(crate) incremental: Option, pub(crate) context_providers: Vec Box + Send + Sync + 'static>>, pub(crate) streaming_mode: StreamingMode, @@ -43,7 +45,7 @@ impl ServeConfigBuilder { /// rendered html in memory and/or the file system. It can be used to improve performance of heavy routes. /// /// ```rust, no_run - /// # fn app() -> Element { todo!() } + /// # fn app() -> Element { unimplemented!() } /// use dioxus::prelude::*; /// /// // Finally, launch the app with the config @@ -52,7 +54,7 @@ impl ServeConfigBuilder { /// .with_cfg(server_only!(dioxus_server::ServeConfigBuilder::default().incremental(dioxus_server::IncrementalRendererConfig::default()))) /// .launch(app); /// ``` - pub fn incremental(mut self, cfg: dioxus_isrg::IncrementalRendererConfig) -> Self { + pub fn incremental(mut self, cfg: IncrementalRendererConfig) -> Self { self.incremental = Some(cfg); self } @@ -89,7 +91,7 @@ impl ServeConfigBuilder { /// You can set the root id to `"my-custom-root"` to render the app into that element: /// /// ```rust, no_run - /// # fn app() -> Element { todo!() } + /// # fn app() -> Element { unimplemented!() } /// use dioxus::prelude::*; /// /// // Finally, launch the app with the config @@ -113,8 +115,7 @@ impl ServeConfigBuilder { self } - /// Provide context to the root and server functions. You can use this context while rendering with [`consume_context`](dioxus_core::consume_context) - /// or in server functions with [`FromContext`](crate::FromContext). + /// Provide context to the root and server functions. You can use this context while rendering with [`consume_context`](dioxus_core::consume_context). /// /// /// The context providers passed into this method will be called when the context type is requested which may happen many times in the lifecycle of the application. @@ -158,9 +159,7 @@ impl ServeConfigBuilder { /// /// #[server] /// async fn read_context() -> ServerFnResult { - /// // You can extract values from the server context with the `extract` function - /// let FromContext(value) = extract().await?; - /// Ok(value) + /// Ok(123) /// } /// /// fn app() -> Element { @@ -183,7 +182,7 @@ impl ServeConfigBuilder { } /// Provide context to the root and server functions. You can use this context - /// while rendering with [`consume_context`](dioxus_core::consume_context) or in server functions with [`FromContext`](crate::FromContext). + /// while rendering with [`consume_context`](dioxus_core::consume_context). /// /// /// The context providers passed into this method will be called when the context type is requested which may happen many times in the lifecycle of the application. @@ -225,9 +224,7 @@ impl ServeConfigBuilder { /// /// #[server] /// async fn read_context() -> ServerFnResult { - /// // You can extract values from the server context with the `extract` function - /// let FromContext(value) = extract().await?; - /// Ok(value) + /// Ok(123) /// } /// /// fn app() -> Element { @@ -246,8 +243,7 @@ impl ServeConfigBuilder { self } - /// Provide context to the root and server functions. You can use this context while rendering with [`consume_context`](dioxus_core::consume_context) - /// or in server functions with [`FromContext`](crate::FromContext). + /// Provide context to the root and server functions. You can use this context while rendering with [`consume_context`](dioxus_core::consume_context). /// /// Context will be forwarded from the LaunchBuilder if it is provided. /// @@ -285,9 +281,7 @@ impl ServeConfigBuilder { /// /// #[server] /// async fn read_context() -> ServerFnResult { - /// // You can extract values from the server context with the `extract` function - /// let FromContext(value) = extract().await?; - /// Ok(value) + /// Ok(123) /// } /// /// fn app() -> Element { @@ -307,10 +301,10 @@ impl ServeConfigBuilder { /// /// ```rust, no_run /// # use dioxus::prelude::*; - /// # fn app() -> Element { todo!() } + /// # fn app() -> Element { unimplemented!() } /// dioxus::LaunchBuilder::new() /// .with_context(server_only! { - /// dioxus::fullstack::ServeConfig::builder().streaming_mode(dioxus::fullstack::StreamingMode::OutOfOrder) + /// dioxus::server::ServeConfig::builder().streaming_mode(dioxus::server::StreamingMode::OutOfOrder) /// }) /// .launch(app); /// ``` @@ -325,10 +319,10 @@ impl ServeConfigBuilder { /// /// ```rust, no_run /// # use dioxus::prelude::*; - /// # fn app() -> Element { todo!() } + /// # fn app() -> Element { unimplemented!() } /// dioxus::LaunchBuilder::new() /// .with_context(server_only! { - /// dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + /// dioxus::server::ServeConfig::builder().enable_out_of_order_streaming() /// }) /// .launch(app); /// ``` @@ -502,9 +496,7 @@ pub enum StreamingMode { #[derive(Clone)] pub struct ServeConfig { pub(crate) index: IndexHtml, - pub(crate) incremental: Option, - // This is used in the axum integration - #[allow(unused)] + pub(crate) incremental: Option, pub(crate) context_providers: ContextProviders, pub(crate) streaming_mode: StreamingMode, } diff --git a/packages/server/src/document.rs b/packages/fullstack-server/src/document.rs similarity index 97% rename from packages/server/src/document.rs rename to packages/fullstack-server/src/document.rs index ee404fe41..0533d01ea 100644 --- a/packages/server/src/document.rs +++ b/packages/fullstack-server/src/document.rs @@ -10,7 +10,6 @@ use dioxus_document::{ Document, Eval, LinkProps, MetaProps, NoOpDocument, ScriptProps, StyleProps, }; use dioxus_html as dioxus_elements; -use dioxus_signals::{GlobalSignal, ReadableExt}; use dioxus_ssr::Renderer; use parking_lot::RwLock; use std::sync::LazyLock; @@ -75,7 +74,7 @@ impl ServerDocument { // We only serialize the head elements if the web document feature is enabled #[cfg(feature = "document")] { - dioxus_fullstack_protocol::head_element_hydration_entry() + dioxus_fullstack_core::head_element_hydration_entry() .insert(&!self.0.borrow().streaming, std::panic::Location::caller()); } } diff --git a/packages/isrg/src/config.rs b/packages/fullstack-server/src/isrg/config.rs similarity index 95% rename from packages/isrg/src/config.rs rename to packages/fullstack-server/src/isrg/config.rs index 82d91c425..67990d7c6 100644 --- a/packages/isrg/src/config.rs +++ b/packages/fullstack-server/src/isrg/config.rs @@ -1,9 +1,9 @@ #![allow(non_snake_case)] #[cfg(not(target_arch = "wasm32"))] -use crate::fs_cache::PathMapFn; +use crate::isrg::fs_cache::PathMapFn; -use crate::memory_cache::InMemoryCache; +use crate::isrg::memory_cache::InMemoryCache; use crate::IncrementalRenderer; use std::{ @@ -86,7 +86,7 @@ impl IncrementalRendererConfig { pub fn build(self) -> IncrementalRenderer { let mut renderer = IncrementalRenderer { #[cfg(not(target_arch = "wasm32"))] - file_system_cache: crate::fs_cache::FileSystemCache::new( + file_system_cache: super::fs_cache::FileSystemCache::new( self.static_dir.clone(), self.map_path, self.invalidate_after, diff --git a/packages/isrg/src/freshness.rs b/packages/fullstack-server/src/isrg/freshness.rs similarity index 100% rename from packages/isrg/src/freshness.rs rename to packages/fullstack-server/src/isrg/freshness.rs diff --git a/packages/isrg/src/fs_cache.rs b/packages/fullstack-server/src/isrg/fs_cache.rs similarity index 100% rename from packages/isrg/src/fs_cache.rs rename to packages/fullstack-server/src/isrg/fs_cache.rs diff --git a/packages/isrg/src/memory_cache.rs b/packages/fullstack-server/src/isrg/memory_cache.rs similarity index 100% rename from packages/isrg/src/memory_cache.rs rename to packages/fullstack-server/src/isrg/memory_cache.rs diff --git a/packages/isrg/src/lib.rs b/packages/fullstack-server/src/isrg/mod.rs similarity index 95% rename from packages/isrg/src/lib.rs rename to packages/fullstack-server/src/isrg/mod.rs index b4f76509c..826f50730 100644 --- a/packages/isrg/src/lib.rs +++ b/packages/fullstack-server/src/isrg/mod.rs @@ -57,7 +57,7 @@ impl IncrementalRenderer { /// Cache a rendered response. /// /// ```rust - /// # use dioxus_isrg::IncrementalRenderer; + /// # use dioxus_server::IncrementalRenderer; /// # let mut renderer = IncrementalRenderer::builder().build(); /// let route = "/index".to_string(); /// let response = b"Hello world"; @@ -83,7 +83,7 @@ impl IncrementalRenderer { /// Try to get a cached response for a route. /// /// ```rust - /// # use dioxus_isrg::IncrementalRenderer; + /// # use dioxus_server::IncrementalRenderer; /// # let mut renderer = IncrementalRenderer::builder().build(); /// # let route = "/index".to_string(); /// # let response = b"Hello world"; @@ -96,7 +96,7 @@ impl IncrementalRenderer { /// If the route is not cached, `None` is returned. /// /// ```rust - /// # use dioxus_isrg::IncrementalRenderer; + /// # use dioxus_server::IncrementalRenderer; /// # let mut renderer = IncrementalRenderer::builder().build(); /// let route = "/index"; /// let response = renderer.get(route).unwrap(); @@ -153,10 +153,12 @@ pub enum IncrementalRendererError { /// An formatting error occurred while rendering a route. #[error("RenderError: {0}")] RenderError(#[from] std::fmt::Error), + /// An IO error occurred while rendering a route. #[error("IoError: {0}")] IoError(#[from] std::io::Error), + /// An error occurred while rendering a route. - #[error("Other: {0}")] - Other(#[from] Box), + #[error("Unknown error: {0}")] + Other(#[from] dioxus_core::CapturedError), } diff --git a/packages/server/src/launch.rs b/packages/fullstack-server/src/launch.rs similarity index 66% rename from packages/server/src/launch.rs rename to packages/fullstack-server/src/launch.rs index aa89d2621..c4e97a797 100644 --- a/packages/server/src/launch.rs +++ b/packages/fullstack-server/src/launch.rs @@ -1,9 +1,6 @@ //! A launch function that creates an axum router for the LaunchBuilder -use crate::{ - collect_raw_server_fns, render_handler, server::DioxusRouterExt, RenderHandleState, SSRState, - ServeConfig, ServeConfigBuilder, -}; +use crate::{server::DioxusRouterExt, RenderHandleState, ServeConfig, ServeConfigBuilder}; use axum::{ body::Body, extract::{Request, State}, @@ -22,7 +19,7 @@ use hyper_util::{ rt::{TokioExecutor, TokioIo}, service::TowerToHyperService, }; -use std::{any::Any, collections::HashMap, net::SocketAddr}; +use std::{any::Any, collections::HashMap, net::SocketAddr, prelude::rust_2024::Future}; use tokio::net::TcpStream; use tokio_util::task::LocalPoolHandle; use tower::Service; @@ -43,9 +40,7 @@ pub fn launch_cfg(root: BaseComp, contexts: ContextList, platform_config: Vec), Devtools(DevserverMsg), @@ -134,84 +127,11 @@ async fn serve_server( }; match res { - // We need to delete our old router and build a new one - // - // one challenge is that the server functions are sitting in the dlopened lib and no longer - // accessible by us (the original process) - // - // We need to somehow get them out... ? - // - // for now we just support editing existing server functions - Msg::Devtools(devserver_msg) => { - match devserver_msg { - DevserverMsg::HotReload(hot_reload_msg) => { - if hot_reload_msg.for_build_id == Some(dioxus_cli_config::build_id()) { - if let Some(table) = hot_reload_msg.jump_table { - unsafe { dioxus_devtools::subsecond::apply_patch(table).unwrap() }; - } - } - - let mut new_router = axum::Router::new().serve_static_assets(); - let new_cfg = ServeConfig::new().unwrap(); - - let server_fn_iter = collect_raw_server_fns(); - - // de-duplicate iteratively by preferring the most recent (first, since it's linked) - let mut server_fn_map: HashMap<_, _> = HashMap::new(); - for f in server_fn_iter.into_iter().rev() { - server_fn_map.insert(f.path(), f); - } - - for (_, fn_) in server_fn_map { - tracing::trace!( - "Registering server function: {:?} {:?}", - fn_.path(), - fn_.method() - ); - new_router = crate::register_server_fn_on_router( - fn_, - new_router, - new_cfg.context_providers.clone(), - ); - } - - let hot_root = subsecond::HotFn::current(original_root); - let new_root_addr = hot_root.ptr_address().0 as usize as *const (); - let new_root = unsafe { - std::mem::transmute::<*const (), fn() -> Element>(new_root_addr) - }; - - crate::document::reset_renderer(); - - let state = RenderHandleState::new(new_cfg.clone(), new_root) - .with_ssr_state(SSRState::new(&new_cfg)); - - let fallback_handler = axum::routing::get(render_handler).with_state(state); - - make_service = apply_base_path( - new_router.fallback(fallback_handler), - new_root, - new_cfg.clone(), - base_path().map(|s| s.to_string()), - ) - .into_make_service(); - - shutdown_tx.send_modify(|i| { - *i += 1; - hr_idx += 1; - }); - } - DevserverMsg::FullReloadStart => {} - DevserverMsg::FullReloadFailed => {} - DevserverMsg::FullReloadCommand => {} - DevserverMsg::Shutdown => {} - _ => {} - } - } Msg::TcpStream(Ok((tcp_stream, _remote_addr))) => { let this_hr_index = hr_idx; let mut make_service = make_service.clone(); let mut shutdown_rx = shutdown_rx.clone(); + task_pool.spawn_pinned(move || async move { let tcp_stream = TokioIo::new(tcp_stream); @@ -222,12 +142,12 @@ async fn serve_server( ) }) .await - .unwrap_or_else(|err| match err {}); + .unwrap(); let tower_service = make_service .call(()) .await - .unwrap_or_else(|err| match err {}) + .unwrap() .map_request(|req: Request| req.map(Body::new)); // upgrades needed for websockets @@ -252,6 +172,79 @@ async fn serve_server( }); } Msg::TcpStream(Err(_)) => {} + // We need to delete our old router and build a new one + // + // one challenge is that the server functions are sitting in the dlopened lib and no longer + // accessible by us (the original process) + // + // We need to somehow get them out... ? + // + // for now we just support editing existing server functions + Msg::Devtools(devserver_msg) => { + match devserver_msg { + DevserverMsg::HotReload(hot_reload_msg) => { + if hot_reload_msg.for_build_id == Some(dioxus_cli_config::build_id()) { + if let Some(table) = hot_reload_msg.jump_table { + use crate::ServerFunction; + + unsafe { dioxus_devtools::subsecond::apply_patch(table).unwrap() }; + + let mut new_router = axum::Router::new().serve_static_assets(); + let new_cfg = ServeConfig::new().unwrap(); + + let server_fn_iter = ServerFunction::collect(); + + // de-duplicate iteratively by preferring the most recent (first, since it's linked) + let mut server_fn_map: HashMap<_, _> = HashMap::new(); + for f in server_fn_iter.into_iter().rev() { + server_fn_map.insert(f.path(), f); + } + + for (_, fn_) in server_fn_map { + tracing::trace!( + "Registering server function: {:?} {:?}", + fn_.path(), + fn_.method() + ); + new_router = fn_.register_server_fn_on_router(new_router); + } + + let hot_root = subsecond::HotFn::current(original_root); + let new_root_addr = hot_root.ptr_address().0 as usize as *const (); + let new_root = unsafe { + std::mem::transmute::<*const (), fn() -> Element>(new_root_addr) + }; + + crate::document::reset_renderer(); + + let state = RenderHandleState::new(new_cfg.clone(), new_root); + + let fallback_handler = + axum::routing::get(RenderHandleState::render_handler) + .with_state(state); + + make_service = apply_base_path( + new_router.fallback(fallback_handler), + new_root, + new_cfg.clone(), + base_path().map(|s| s.to_string()), + ) + .into_make_service(); + + shutdown_tx.send_modify(|i| { + *i += 1; + hr_idx += 1; + }); + } + } + } + DevserverMsg::FullReloadStart => {} + DevserverMsg::FullReloadFailed => {} + DevserverMsg::FullReloadCommand => {} + DevserverMsg::Shutdown => {} + _ => {} + } + } } } } @@ -274,16 +267,60 @@ fn apply_base_path( ) -> impl IntoResponse { // The root of the base path always looks like the root from dioxus fullstack *request.uri_mut() = "/".parse().unwrap(); - render_handler(state, request).await + RenderHandleState::render_handler(state, request).await } - let ssr_state = SSRState::new(&cfg); router = router.route( &format!("/{base_path}"), axum::routing::method_routing::get(root_render_handler) - .with_state(RenderHandleState::new(cfg, root).with_ssr_state(ssr_state)), + .with_state(RenderHandleState::new(cfg, root)), ) } router } + +pub fn serve(mut serve_it: impl FnMut() -> F) +where + F: Future>, +{ + dioxus_logger::initialize_default(); + + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let router = serve_it().await.unwrap(); + + let address = dioxus_cli_config::fullstack_address_or_localhost(); + let listener = tokio::net::TcpListener::bind(address).await.unwrap(); + + tracing::trace!("Listening on {address}"); + + axum::serve::serve(listener, router.into_make_service()) + .await + .unwrap(); + }); + + // unreachable!("Serving a fullstack app should never return") +} + +/// Create a router that serves the dioxus application at the appropriate base path. +/// +/// This method automatically setups up: +/// - Static asset serving +/// - Mapping of base paths +/// - Automatic registration of server functions +/// - Handler to render the dioxus application +/// - WebSocket handling for live reload and devtools +/// - Hot-reloading +/// - Async Runtime +/// - Logging +pub fn router(app: fn() -> Element) -> axum::Router { + let cfg = ServeConfig::new().unwrap(); + apply_base_path( + axum::Router::new().serve_dioxus_application(cfg.clone(), app), + app, + cfg, + base_path().map(|s| s.to_string()), + ) +} diff --git a/packages/fullstack-server/src/lib.rs b/packages/fullstack-server/src/lib.rs new file mode 100644 index 000000000..b1177949e --- /dev/null +++ b/packages/fullstack-server/src/lib.rs @@ -0,0 +1,46 @@ +#![doc = include_str!("../README.md")] +#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")] +#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] +// #![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![forbid(unexpected_cfgs)] + +// re-exported to make it possible to implement a custom Client without adding a separate +// dependency on `bytes` +pub use bytes::Bytes; +pub use dioxus_fullstack_core::{ServerFnError, ServerFnResult}; + +pub use axum; +pub(crate) use config::*; +pub use config::*; +pub use config::{ServeConfig, ServeConfigBuilder}; +pub use document::ServerDocument; +pub use server::*; + +pub mod redirect; + +#[cfg(not(target_arch = "wasm32"))] +mod launch; + +#[cfg(not(target_arch = "wasm32"))] +pub use launch::{launch, launch_cfg}; + +/// Implementations of the server side of the server function call. +pub mod server; + +/// Types and traits for HTTP responses. +// pub mod response; +pub mod config; + +pub(crate) mod document; +pub(crate) mod ssr; +pub(crate) mod streaming; + +pub use launch::router; +pub use launch::serve; + +pub mod serverfn; +pub use serverfn::*; + +pub mod isrg; +pub use isrg::*; diff --git a/packages/fullstack-server/src/redirect.rs b/packages/fullstack-server/src/redirect.rs new file mode 100644 index 000000000..f5f6321ca --- /dev/null +++ b/packages/fullstack-server/src/redirect.rs @@ -0,0 +1,31 @@ +use std::sync::OnceLock; + +/// A custom header that can be set with any value to indicate +/// that the server function client should redirect to a new route. +/// +/// This is useful because it allows returning a value from the request, +/// while also indicating that a redirect should follow. This cannot be +/// done with an HTTP `3xx` status code, because the browser will follow +/// that redirect rather than returning the desired data. +pub const REDIRECT_HEADER: &str = "serverfnredirect"; + +/// A function that will be called if a server function returns a `3xx` status +/// or the [`REDIRECT_HEADER`]. +pub type RedirectHook = Box; + +// allowed: not in a public API, and pretty straightforward +#[allow(clippy::type_complexity)] +pub(crate) static REDIRECT_HOOK: OnceLock = OnceLock::new(); + +/// Sets a function that will be called if a server function returns a `3xx` status +/// or the [`REDIRECT_HEADER`]. Returns `Err(_)` if the hook has already been set. +pub fn set_redirect_hook(hook: impl Fn(&str) + Send + Sync + 'static) -> Result<(), RedirectHook> { + REDIRECT_HOOK.set(Box::new(hook)) +} + +/// Calls the hook that has been set by [`set_redirect_hook`] to redirect to `loc`. +pub fn call_redirect_hook(loc: &str) { + if let Some(hook) = REDIRECT_HOOK.get() { + hook(loc) + } +} diff --git a/packages/server/src/server.rs b/packages/fullstack-server/src/server.rs similarity index 55% rename from packages/server/src/server.rs rename to packages/fullstack-server/src/server.rs index 844eedf07..97f77ce89 100644 --- a/packages/server/src/server.rs +++ b/packages/fullstack-server/src/server.rs @@ -1,23 +1,65 @@ -use crate::{render::SSRError, with_server_context, DioxusServerContext, SSRState, ServeConfig}; -use crate::{ContextProviders, ProvideServerContext}; -use axum::body; -use axum::extract::State; -use axum::routing::*; +use crate::{ + ssr::{SSRError, SsrRendererPool}, + ServeConfig, ServerFunction, +}; +use crate::{IncrementalRendererError, RenderFreshness}; use axum::{ + body, body::Body, + extract::State, http::{Request, Response, StatusCode}, response::IntoResponse, + routing::*, }; - use dioxus_core::{Element, VirtualDom}; +use futures::Stream; use http::header::*; -use server_fn::ServerFnTraitObj; use std::path::Path; use std::sync::Arc; use tower::util::MapResponse; use tower::ServiceExt; use tower_http::services::fs::ServeFileSystemResponseBody; +/// SSR renderer handler for Axum with added context injection. +/// +/// # Example +/// ```rust,no_run +/// #![allow(non_snake_case)] +/// use std::sync::{Arc, Mutex}; +/// +/// use axum::routing::get; +/// use dioxus::prelude::*; +/// use dioxus_server::{RenderHandleState, render_handler, ServeConfig}; +/// +/// fn app() -> Element { +/// rsx! { +/// "hello!" +/// } +/// } +/// +/// #[tokio::main] +/// async fn main() { +/// let addr = dioxus::cli_config::fullstack_address_or_localhost(); +/// let router = axum::Router::new() +/// // Register server functions, etc. +/// // Note you can use `register_server_functions_with_context` +/// // to inject the context into server functions running outside +/// // of an SSR render context. +/// .fallback(get(render_handler) +/// .with_state(RenderHandleState::new(ServeConfig::new().unwrap(), app)) +/// ) +/// .into_make_service(); +/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); +/// axum::serve(listener, router).await.unwrap(); +/// } +/// ``` +pub async fn render_handler( + State(state): State, + request: Request, +) -> impl IntoResponse { + RenderHandleState::render_handler(State(state), request).await +} + /// A extension trait with utilities for integrating Dioxus with your Axum router. pub trait DioxusRouterExt: DioxusRouterFnExt { /// Serves the static WASM for your Dioxus application (except the generated index.html). @@ -29,7 +71,7 @@ pub trait DioxusRouterExt: DioxusRouterFnExt { /// use dioxus_server::{DioxusRouterExt, DioxusRouterFnExt}; /// /// #[tokio::main] - /// async fn main() { + /// async fn main() -> anyhow::Result<()> { /// let addr = dioxus::cli_config::fullstack_address_or_localhost(); /// let router = axum::Router::new() /// // Server side render the application, serve static assets, and register server functions @@ -37,8 +79,9 @@ pub trait DioxusRouterExt: DioxusRouterFnExt { /// // Server render the application /// // ... /// .into_make_service(); - /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - /// axum::serve(listener, router).await.unwrap(); + /// let listener = tokio::net::TcpListener::bind(addr).await?; + /// axum::serve(listener, router).await?; + /// Ok(()) /// } /// ``` fn serve_static_assets(self) -> Self @@ -91,23 +134,17 @@ where } fn serve_dioxus_application(self, cfg: ServeConfig, app: fn() -> Element) -> Self { - // Add server functions and render index.html - let server = self + self.register_server_functions() .serve_static_assets() - .register_server_functions_with_context(cfg.context_providers.clone()); - - let ssr_state = SSRState::new(&cfg); - - server.fallback( - get(render_handler) - .with_state(RenderHandleState::new(cfg, app).with_ssr_state(ssr_state)), - ) + .fallback( + get(RenderHandleState::render_handler).with_state(RenderHandleState::new(cfg, app)), + ) } } /// A extension trait with server function utilities for integrating Dioxus with your Axum router. pub trait DioxusRouterFnExt { - /// Registers server functions with the default handler. This handler function will pass an empty [`DioxusServerContext`] to your server functions. + /// Registers server functions with the default handler. /// /// # Example /// ```rust, no_run @@ -127,31 +164,7 @@ pub trait DioxusRouterFnExt { #[allow(dead_code)] fn register_server_functions(self) -> Self where - Self: Sized, - { - self.register_server_functions_with_context(Default::default()) - } - - /// Registers server functions with some additional context to insert into the [`DioxusServerContext`] for that handler. - /// - /// # Example - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// # use dioxus_server::DioxusRouterFnExt; - /// # use std::sync::Arc; - /// #[tokio::main] - /// async fn main() { - /// let addr = dioxus::cli_config::fullstack_address_or_localhost(); - /// let router = axum::Router::new() - /// // Register server functions routes with the default handler - /// .register_server_functions_with_context(Arc::new(vec![Box::new(|| Box::new(1234567890u32))])) - /// .into_make_service(); - /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); - /// axum::serve(listener, router).await.unwrap(); - /// } - /// ``` - fn register_server_functions_with_context(self, context_providers: ContextProviders) -> Self; - + Self: Sized; /// Serves a Dioxus application without static assets. /// Sets up server function routes and rendering endpoints only. /// @@ -179,16 +192,10 @@ pub trait DioxusRouterFnExt { Self: Sized; } -impl DioxusRouterFnExt for Router -where - S: Send + Sync + Clone + 'static, -{ - fn register_server_functions_with_context( - mut self, - context_providers: ContextProviders, - ) -> Self { - for f in collect_raw_server_fns() { - self = register_server_fn_on_router(f, self, context_providers.clone()); +impl DioxusRouterFnExt for Router { + fn register_server_functions(mut self) -> Self { + for f in ServerFunction::collect() { + self = f.register_server_fn_on_router(self); } self } @@ -197,129 +204,27 @@ where where Self: Sized, { - let server = self.register_server_functions_with_context(cfg.context_providers.clone()); - - let ssr_state = SSRState::new(&cfg); - - server.fallback( - get(render_handler) - .with_state(RenderHandleState::new(cfg, app).with_ssr_state(ssr_state)), + self.register_server_functions().fallback( + get(RenderHandleState::render_handler).with_state(RenderHandleState::new(cfg, app)), ) } } -pub fn register_server_fn_on_router( - f: &'static AxumServerFn, - router: Router, - context_providers: ContextProviders, -) -> Router -where - S: Send + Sync + Clone + 'static, -{ - use http::method::Method; - let path = f.path(); - let method = f.method(); - - tracing::trace!("Registering server function: {} {}", method, path); - let handler = move |req| handle_server_fns_inner(f, context_providers, req); - match method { - Method::GET => router.route(path, get(handler)), - Method::POST => router.route(path, post(handler)), - Method::PUT => router.route(path, put(handler)), - _ => unimplemented!("Unsupported server function method: {}", method), - } -} - -pub type AxumServerFn = ServerFnTraitObj, http::Response>; - -pub(crate) fn collect_raw_server_fns() -> Vec<&'static AxumServerFn> { - inventory::iter::().collect() -} - -/// A handler for Dioxus server functions. This will run the server function and return the result. -async fn handle_server_fns_inner( - f: &AxumServerFn, - additional_context: ContextProviders, - req: Request, -) -> Response { - let (parts, body) = req.into_parts(); - let req = Request::from_parts(parts.clone(), body); - - // Create the server context with info from the request - let server_context = DioxusServerContext::new(parts); - - // Provide additional context from the render state - add_server_context(&server_context, &additional_context); - - // store Accepts and Referrer in case we need them for redirect (below) - let accepts_html = req - .headers() - .get(ACCEPT) - .and_then(|v| v.to_str().ok()) - .map(|v| v.contains("text/html")) - .unwrap_or(false); - let referrer = req.headers().get(REFERER).cloned(); - - // this is taken from server_fn source... - // - // [`server_fn::axum::get_server_fn_service`] - let mut service = { - let middleware = f.middleware(); - let mut service = f.clone().boxed(); - for middleware in middleware { - service = middleware.layer(service); - } - service - }; - - // actually run the server fn (which may use the server context) - let fut = with_server_context(server_context.clone(), || service.run(req)); - - let mut res = ProvideServerContext::new(fut, server_context.clone()).await; - - // it it accepts text/html (i.e., is a plain form post) and doesn't already have a - // Location set, then redirect to Referer - if accepts_html { - if let Some(referrer) = referrer { - let has_location = res.headers().get(LOCATION).is_some(); - if !has_location { - *res.status_mut() = StatusCode::FOUND; - res.headers_mut().insert(LOCATION, referrer); - } - } - } - - // apply the response parts from the server context to the response - server_context.send_response(&mut res); - - res -} - -pub(crate) fn add_server_context( - server_context: &DioxusServerContext, - context_providers: &ContextProviders, -) { - for index in 0..context_providers.len() { - let context_providers = context_providers.clone(); - server_context.insert_boxed_factory(Box::new(move || context_providers[index]())); - } -} - -/// State used by [`render_handler`] to render a dioxus component with axum +/// State used by [`RenderHandleState::render_handler`] to render a dioxus component with axum #[derive(Clone)] pub struct RenderHandleState { config: ServeConfig, build_virtual_dom: Arc VirtualDom + Send + Sync>, - ssr_state: std::sync::OnceLock, + renderers: Arc, } impl RenderHandleState { /// Create a new [`RenderHandleState`] pub fn new(config: ServeConfig, root: fn() -> Element) -> Self { Self { - config, + renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())), build_virtual_dom: Arc::new(move || VirtualDom::new(root)), - ssr_state: Default::default(), + config, } } @@ -329,9 +234,9 @@ impl RenderHandleState { build_virtual_dom: impl Fn() -> VirtualDom + Send + Sync + 'static, ) -> Self { Self { + renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())), config, build_virtual_dom: Arc::new(build_virtual_dom), - ssr_state: Default::default(), } } @@ -341,113 +246,113 @@ impl RenderHandleState { self } - /// Set the [`SSRState`] for this [`RenderHandleState`]. Sharing a [`SSRState`] between multiple [`RenderHandleState`]s is more efficient than creating a new [`SSRState`] for each [`RenderHandleState`]. - pub fn with_ssr_state(mut self, ssr_state: SSRState) -> Self { - self.ssr_state = std::sync::OnceLock::new(); - if self.ssr_state.set(ssr_state).is_err() { - panic!("SSRState already set"); - } - self - } - - fn ssr_state(&self) -> &SSRState { - self.ssr_state.get_or_init(|| SSRState::new(&self.config)) - } -} - -/// SSR renderer handler for Axum with added context injection. -/// -/// # Example -/// ```rust,no_run -/// #![allow(non_snake_case)] -/// use std::sync::{Arc, Mutex}; -/// -/// use axum::routing::get; -/// use dioxus::prelude::*; -/// use dioxus_server::{RenderHandleState, render_handler, ServeConfig}; -/// -/// fn app() -> Element { -/// rsx! { -/// "hello!" -/// } -/// } -/// -/// #[tokio::main] -/// async fn main() { -/// let addr = dioxus::cli_config::fullstack_address_or_localhost(); -/// let router = axum::Router::new() -/// // Register server functions, etc. -/// // Note you can use `register_server_functions_with_context` -/// // to inject the context into server functions running outside -/// // of an SSR render context. -/// .fallback(get(render_handler) -/// .with_state(RenderHandleState::new(ServeConfig::new().unwrap(), app)) -/// ) -/// .into_make_service(); -/// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); -/// axum::serve(listener, router).await.unwrap(); -/// } -/// ``` -pub async fn render_handler( - State(state): State, - request: Request, -) -> impl IntoResponse { - let cfg = &state.config; - let ssr_state = state.ssr_state(); - let build_virtual_dom = { - let build_virtual_dom = state.build_virtual_dom.clone(); - let context_providers = state.config.context_providers.clone(); - move || { - let mut vdom = build_virtual_dom(); - for state in context_providers.as_slice() { - vdom.insert_any_root_context(state()); + /// SSR renderer handler for Axum with added context injection. + /// + /// # Example + /// ```rust,no_run + /// #![allow(non_snake_case)] + /// use std::sync::{Arc, Mutex}; + /// + /// use axum::routing::get; + /// use dioxus::prelude::*; + /// use dioxus_server::{RenderHandleState, render_handler, ServeConfig}; + /// + /// fn app() -> Element { + /// rsx! { + /// "hello!" + /// } + /// } + /// + /// #[tokio::main] + /// async fn main() { + /// let addr = dioxus::cli_config::fullstack_address_or_localhost(); + /// let router = axum::Router::new() + /// // Register server functions, etc. + /// // Note you can use `register_server_functions_with_context` + /// // to inject the context into server functions running outside + /// // of an SSR render context. + /// .fallback(get(render_handler) + /// .with_state(RenderHandleState::new(ServeConfig::new().unwrap(), app)) + /// ) + /// .into_make_service(); + /// let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); + /// axum::serve(listener, router).await.unwrap(); + /// } + /// ``` + pub async fn render_handler( + State(state): State, + request: Request, + ) -> impl IntoResponse { + let cfg = &state.config; + let build_virtual_dom = { + let build_virtual_dom = state.build_virtual_dom.clone(); + let context_providers = state.config.context_providers.clone(); + move || { + let mut vdom = build_virtual_dom(); + for state in context_providers.as_slice() { + vdom.insert_any_root_context(state()); + } + vdom } - vdom - } - }; + }; - let (parts, _) = request.into_parts(); - let url = parts - .uri - .path_and_query() - .ok_or(StatusCode::BAD_REQUEST)? - .to_string(); - let parts: Arc> = - Arc::new(parking_lot::RwLock::new(parts)); - // Create the server context with info from the request - let server_context = DioxusServerContext::from_shared_parts(parts.clone()); - // Provide additional context from the render state - add_server_context(&server_context, &state.config.context_providers); + let (parts, _) = request.into_parts(); + let url = parts + .uri + .path_and_query() + .ok_or(StatusCode::BAD_REQUEST)? + .to_string(); - match ssr_state - .render(url, cfg, build_virtual_dom, &server_context) - .await - { - Ok((freshness, rx)) => { - let mut response = axum::response::Html::from(Body::from_stream(rx)).into_response(); - freshness.write(response.headers_mut()); - server_context.send_response(&mut response); - Result::, StatusCode>::Ok(response) - } - Err(SSRError::Incremental(e)) => { - tracing::error!("Failed to render page: {}", e); - Ok(report_err(e).into_response()) - } - Err(SSRError::Routing(e)) => { - tracing::trace!("Page not found: {}", e); - Ok(Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("Page not found")) - .unwrap()) + match state.render(url, cfg, build_virtual_dom).await { + Ok((freshness, rx)) => { + let mut response = + axum::response::Html::from(Body::from_stream(rx)).into_response(); + freshness.write(response.headers_mut()); + Result::, StatusCode>::Ok(response) + } + + Err(SSRError::Incremental(e)) => { + tracing::error!("Failed to render page: {}", e); + Ok(Self::err_to_axum_response(e).into_response()) + } + + Err(SSRError::HttpError { status, message }) => Ok(Response::builder() + .status(status) + .body(Body::from(message.unwrap_or_else(|| { + status + .canonical_reason() + .unwrap_or("An unknown error occurred") + .to_string() + }))) + .unwrap()), } } -} -fn report_err(e: E) -> Response { - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(body::Body::new(format!("Error: {}", e))) - .unwrap() + fn err_to_axum_response(e: E) -> Response { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(body::Body::new(format!("Error: {}", e))) + .unwrap() + } + + /// Render the application to HTML. + pub async fn render<'a>( + &'a self, + route: String, + cfg: &'a ServeConfig, + virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, + ) -> Result< + ( + RenderFreshness, + impl Stream>, + ), + SSRError, + > { + self.renderers + .clone() + .render_to(cfg, route, virtual_dom_factory) + .await + } } fn serve_dir_cached( @@ -490,6 +395,33 @@ where router } +fn path_components_to_route_lossy(path: &Path) -> String { + let route = path + .iter() + .map(|segment| segment.to_string_lossy()) + .collect::>() + .join("/"); + format!("/{}", route) +} + +type MappedAxumService = MapResponse< + S, + fn(Response) -> Response, +>; + +fn cache_response_forever(service: S) -> MappedAxumService +where + S: ServiceExt, Response = Response>, +{ + service.map_response(|mut response: Response| { + response.headers_mut().insert( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + response + }) +} + fn file_name_looks_immutable(file_name: &str) -> bool { // Check if the file name looks like a hash (e.g., "main-dxh12345678.js") file_name.rsplit_once("-dxh").is_some_and(|(_, hash)| { @@ -508,31 +440,3 @@ fn test_file_name_looks_immutable() { assert!(!file_name_looks_immutable("main-dxh1234wyz.js")); assert!(!file_name_looks_immutable("main-dxh12345678-invalid.js")); } - -fn path_components_to_route_lossy(path: &Path) -> String { - let route = path - .iter() - .map(|segment| segment.to_string_lossy()) - .collect::>() - .join("/"); - format!("/{}", route) -} - -type MappedAxumService = MapResponse< - S, - fn(Response) -> Response, ->; - -fn cache_response_forever< - S: ServiceExt, Response = Response>, ->( - service: S, -) -> MappedAxumService { - service.map_response(|mut response: Response| { - response.headers_mut().insert( - CACHE_CONTROL, - HeaderValue::from_static("public, max-age=31536000, immutable"), - ); - response - }) -} diff --git a/packages/fullstack-server/src/serverfn.rs b/packages/fullstack-server/src/serverfn.rs new file mode 100644 index 000000000..926c9fce6 --- /dev/null +++ b/packages/fullstack-server/src/serverfn.rs @@ -0,0 +1,178 @@ +use axum::body::Body; +use axum::handler::Handler; +use axum::routing::MethodRouter; +use axum::Router; // both req/res // both req/res // req only +use dashmap::DashMap; +use dioxus_fullstack_core::DioxusServerState; +use http::Method; +use std::{marker::PhantomData, sync::LazyLock}; + +pub type AxumRequest = http::Request; +pub type AxumResponse = http::Response; + +/// A function endpoint that can be called from the client. +#[derive(Clone)] +pub struct ServerFunction { + path: &'static str, + method: Method, + handler: fn() -> MethodRouter, + _phantom: PhantomData, +} + +pub struct MakeRequest { + _phantom: PhantomData, +} + +impl ServerFunction { + /// Create a new server function object. + pub const fn new( + method: Method, + path: &'static str, + handler: fn() -> MethodRouter, + ) -> Self { + Self { + path, + method, + handler, + _phantom: PhantomData, + } + } + + /// The path of the server function. + pub fn path(&self) -> &'static str { + self.path + } + + /// The HTTP method the server function expects. + pub fn method(&self) -> Method { + self.method.clone() + } + + pub fn collect() -> Vec<&'static ServerFunction> { + inventory::iter::().collect() + } + + pub fn register_server_fn_on_router(&'static self, router: Router) -> Router + where + S: Send + Sync + Clone + 'static, + { + tracing::info!( + "Registering server function: {} {}", + self.method(), + self.path() + ); + use http::method::Method; + let path = self.path(); + let method = self.method(); + let handler = move |req| self.handle_server_fns_inner(req); + match method { + Method::GET => router.route(path, axum::routing::get(handler)), + Method::POST => router.route(path, axum::routing::post(handler)), + Method::PUT => router.route(path, axum::routing::put(handler)), + Method::DELETE => router.route(path, axum::routing::delete(handler)), + Method::PATCH => router.route(path, axum::routing::patch(handler)), + Method::HEAD => router.route(path, axum::routing::head(handler)), + Method::OPTIONS => router.route(path, axum::routing::options(handler)), + Method::CONNECT => router.route(path, axum::routing::connect(handler)), + Method::TRACE => router.route(path, axum::routing::trace(handler)), + _ => unimplemented!("Unsupported server function method: {}", method), + } + } + + pub async fn handle_server_fns_inner(&self, req: http::Request) -> http::Response { + // todo: jon + // - bring back middleware to serverfns. the new layer system is fine but isn't a full replacement + // + // use axum::body; + // use axum::extract::State; + // use axum::routing::*; + // use axum::{ + // body::Body, + // http::{Request, Response, StatusCode}, + // response::IntoResponse, + // }; + // use http::header::*; + + // let (parts, body) = req.into_parts(); + // let req = Request::from_parts(parts.clone(), body); + + // // Create the server context with info from the request + // let server_context = DioxusServerContext::new(parts); + + // // Provide additional context from the render state + // server_context.add_server_context(&additional_context); + + // // store Accepts and Referrer in case we need them for redirect (below) + // let referrer = req.headers().get(REFERER).cloned(); + // let accepts_html = req + // .headers() + // .get(ACCEPT) + // .and_then(|v| v.to_str().ok()) + // .map(|v| v.contains("text/html")) + // .unwrap_or(false); + + // let mthd: MethodRouter<()> = axum::routing::get(self.handler); + + // // this is taken from server_fn source... + // // + // // [`server_fn::axum::get_server_fn_service`] + // let mut service = { + // let middleware = self.middleware(); + // let mut service = self.clone().boxed(); + // for middleware in middleware { + // service = middleware.layer(service); + // } + // service + // }; + + // actually run the server fn (which may use the server context) + // let fut = crate::with_server_context(server_context.clone(), || service.run(req)); + // let fut = crate::with_server_context(server_context.clone(), || service.run(req)); + + // let res = ProvideServerContext::new(fut, server_context.clone()).await; + // let mut res = res.res; + + // // it it accepts text/html (i.e., is a plain form post) and doesn't already have a + // // Location set, then redirect to Referer + // if accepts_html { + // if let Some(referrer) = referrer { + // let has_location = res.headers().get(LOCATION).is_some(); + // if !has_location { + // *res.status_mut() = StatusCode::FOUND; + // res.headers_mut().insert(LOCATION, referrer); + // } + // } + // } + + // // apply the response parts from the server context to the response + // server_context.send_response(&mut res); + + let mthd: MethodRouter = + (self.handler)().with_state(DioxusServerState {}); + + mthd.call(req, DioxusServerState {}).await + } +} + +impl inventory::Collect for ServerFunction { + #[inline] + fn registry() -> &'static inventory::Registry { + static REGISTRY: inventory::Registry = inventory::Registry::new(); + ®ISTRY + } +} + +/// The set of all registered server function paths. +pub fn server_fn_paths() -> impl Iterator { + REGISTERED_SERVER_FUNCTIONS + .iter() + .map(|item| (item.path(), item.method())) +} + +type LazyServerFnMap = LazyLock>; +static REGISTERED_SERVER_FUNCTIONS: LazyServerFnMap = std::sync::LazyLock::new(|| { + inventory::iter:: + .into_iter() + .map(|obj| ((obj.path().to_string(), obj.method()), obj.clone())) + .collect() +}); diff --git a/packages/server/src/render.rs b/packages/fullstack-server/src/ssr.rs similarity index 51% rename from packages/server/src/render.rs rename to packages/fullstack-server/src/ssr.rs index c99d1c111..2fd161d6a 100644 --- a/packages/server/src/render.rs +++ b/packages/fullstack-server/src/ssr.rs @@ -1,82 +1,52 @@ //! A shared pool of renderers for efficient server side rendering. -use crate::{document::ServerDocument, ProvideServerContext, ServeConfig}; -use crate::{ - streaming::{Mount, StreamingRenderer}, - DioxusServerContext, +use crate::isrg::{ + CachedRender, IncrementalRenderer, IncrementalRendererConfig, IncrementalRendererError, + RenderFreshness, }; +use crate::streaming::{Mount, StreamingRenderer}; +use crate::{document::ServerDocument, ServeConfig}; use dioxus_cli_config::base_path; use dioxus_core::{ has_context, DynamicNode, ErrorContext, ScopeId, SuspenseContext, VNode, VirtualDom, }; -use dioxus_fullstack_hooks::history::provide_fullstack_history_context; -use dioxus_fullstack_hooks::{StreamingContext, StreamingStatus}; -use dioxus_fullstack_protocol::{HydrationContext, SerializedHydrationData}; -use dioxus_isrg::{CachedRender, IncrementalRendererError, RenderFreshness}; +use dioxus_fullstack_core::{history::provide_fullstack_history_context, HttpError, ServerFnError}; +use dioxus_fullstack_core::{HydrationContext, SerializedHydrationData}; +use dioxus_fullstack_core::{StreamingContext, StreamingStatus}; use dioxus_router::ParseRouteError; use dioxus_ssr::Renderer; use futures_channel::mpsc::Sender; use futures_util::{Stream, StreamExt}; +use http::StatusCode; use std::{collections::HashMap, fmt::Write, future::Future, rc::Rc, sync::Arc, sync::RwLock}; use tokio::task::JoinHandle; use crate::StreamingMode; +/// Errors that can occur during server side rendering before the initial chunk is sent down +pub enum SSRError { + /// An error from the incremental renderer. This should result in a 500 code + Incremental(IncrementalRendererError), + + HttpError { + status: StatusCode, + message: Option, + }, +} + /// A suspense boundary that is pending with a placeholder in the client struct PendingSuspenseBoundary { mount: Mount, children: Vec, } -/// Spawn a task in the background. If wasm is enabled, this will use the single threaded tokio runtime -fn spawn_platform(f: impl FnOnce() -> Fut + Send + 'static) -> JoinHandle -where - Fut: Future + 'static, - Fut::Output: Send + 'static, -{ - #[cfg(not(target_arch = "wasm32"))] - { - use tokio_util::task::LocalPoolHandle; - static TASK_POOL: std::sync::OnceLock = std::sync::OnceLock::new(); - - let pool = TASK_POOL.get_or_init(|| { - LocalPoolHandle::new( - std::thread::available_parallelism() - .map(usize::from) - .unwrap_or(1), - ) - }); - - pool.spawn_pinned(f) - } - #[cfg(target_arch = "wasm32")] - { - tokio::task::spawn_local(f()) - } -} - -fn in_root_scope(virtual_dom: &VirtualDom, f: impl FnOnce() -> T) -> T { - virtual_dom.in_runtime(|| ScopeId::ROOT.in_runtime(f)) -} - -/// Errors that can occur during server side rendering before the initial chunk is sent down -pub enum SSRError { - /// An error from the incremental renderer. This should result in a 500 code - Incremental(IncrementalRendererError), - /// An error from the dioxus router. This should result in a 404 code - Routing(ParseRouteError), -} - -struct SsrRendererPool { +pub(crate) struct SsrRendererPool { renderers: RwLock>, - incremental_cache: Option>, + incremental_cache: Option>, } impl SsrRendererPool { - fn new( - initial_size: usize, - incremental: Option, - ) -> Self { - let renderers = RwLock::new((0..initial_size).map(|_| pre_renderer()).collect()); + pub(crate) fn new(initial_size: usize, incremental: Option) -> Self { + let renderers = RwLock::new((0..initial_size).map(|_| Self::pre_renderer()).collect()); Self { renderers, incremental_cache: incremental.map(|cache| RwLock::new(cache.build())), @@ -87,7 +57,7 @@ impl SsrRendererPool { fn check_cached_route( &self, route: &str, - render_into: &mut Sender>, + render_into: &mut Sender>, ) -> Option { if let Some(incremental) = &self.incremental_cache { if let Ok(mut incremental) = incremental.write() { @@ -98,9 +68,10 @@ impl SsrRendererPool { response, .. } = cached_render; - _ = render_into.start_send(String::from_utf8(response.to_vec()).map_err( - |err| dioxus_isrg::IncrementalRendererError::Other(Box::new(err)), - )); + _ = render_into.start_send( + String::from_utf8(response.to_vec()) + .map_err(|err| IncrementalRendererError::Other(err.into())), + ); return Some(freshness); } Err(e) => { @@ -117,28 +88,25 @@ impl SsrRendererPool { /// Render a virtual dom into a stream. This method will return immediately and continue streaming the result in the background /// The streaming is canceled when the stream the function returns is dropped - async fn render_to( + pub(crate) async fn render_to( self: Arc, cfg: &ServeConfig, route: String, virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, - server_context: &DioxusServerContext, ) -> Result< ( RenderFreshness, - impl Stream>, + impl Stream>, ), SSRError, > { struct ReceiverWithDrop { - receiver: futures_channel::mpsc::Receiver< - Result, - >, + receiver: futures_channel::mpsc::Receiver>, cancel_task: Option>, } impl Stream for ReceiverWithDrop { - type Item = Result; + type Item = Result; fn poll_next( mut self: std::pin::Pin<&mut Self>, @@ -157,9 +125,8 @@ impl SsrRendererPool { } } - let (mut into, rx) = futures_channel::mpsc::channel::< - Result, - >(1000); + let (mut into, rx) = + futures_channel::mpsc::channel::>(1000); let (initial_result_tx, initial_result_rx) = futures_channel::oneshot::channel(); @@ -174,23 +141,22 @@ impl SsrRendererPool { )); } - let wrapper = FullstackHTMLTemplate { cfg: cfg.clone() }; - - let server_context = server_context.clone(); let mut renderer = self .renderers .write() .unwrap() .pop() - .unwrap_or_else(pre_renderer); + .unwrap_or_else(Self::pre_renderer); let myself = self.clone(); let streaming_mode = cfg.streaming_mode; + let cfg = cfg.clone(); let create_render_future = move || async move { let mut virtual_dom = virtual_dom_factory(); let document = Rc::new(ServerDocument::default()); virtual_dom.provide_root_context(document.clone()); + // If there is a base path, trim the base path from the route and add the base path formatting to the // history provider let history = if let Some(base_path) = base_path() { @@ -202,16 +168,17 @@ impl SsrRendererPool { dioxus_history::MemoryHistory::with_initial_path(&route) }; - let streaming_context = in_root_scope(&virtual_dom, StreamingContext::new); + // Provide the document and streaming context to the root of the app + let streaming_context = virtual_dom.in_scope(ScopeId::ROOT, StreamingContext::new); virtual_dom.provide_root_context(document.clone() as Rc); virtual_dom.provide_root_context(streaming_context); - in_root_scope(&virtual_dom, || { + virtual_dom.in_scope(ScopeId::ROOT, || { // Wrap the memory history in a fullstack history provider to provide the initial route for hydration provide_fullstack_history_context(history); // Provide a hydration compatible error boundary that serializes errors for the client dioxus_core::provide_create_error_boundary( - dioxus_fullstack_hooks::errors::init_error_boundary, + dioxus_fullstack_core::init_error_boundary, ); }); @@ -222,14 +189,13 @@ impl SsrRendererPool { // before rendering anything if streaming_mode == StreamingMode::Disabled { virtual_dom.wait_for_suspense().await; - } - // Otherwise, just wait for the streaming context to signal the initial chunk is ready - else { + } else { + // Otherwise, just wait for the streaming context to signal the initial chunk is ready loop { // Check if the router has finished and set the streaming context to finished - let streaming_context_finished = - in_root_scope(&virtual_dom, || streaming_context.current_status()) - == StreamingStatus::InitialChunkCommitted; + let streaming_context_finished = virtual_dom + .in_scope(ScopeId::ROOT, || streaming_context.current_status()) + == StreamingStatus::InitialChunkCommitted; // Or if this app isn't using the router and has finished suspense let suspense_finished = !virtual_dom.suspended_tasks_remaining(); if streaming_context_finished || suspense_finished { @@ -245,42 +211,73 @@ impl SsrRendererPool { } // check if there are any errors - let errors = virtual_dom.in_runtime(|| { - let error_context: ErrorContext = ScopeId::ROOT_ERROR_BOUNDARY - .consume_context() - .expect("The root should be under an error boundary"); - let errors = error_context.errors(); - errors.to_vec() + let error = virtual_dom.in_runtime(|| { + ScopeId::ROOT_ERROR_BOUNDARY + .consume_context::() + .expect("The root should be under an error boundary") + .error() }); - if errors.is_empty() { - // If routing was successful, we can return a 200 status and render into the stream - _ = initial_result_tx.send(Ok(())); - } else { - // If there was an error while routing, return the error with a 400 status + + if let Some(error) = error { + let mut status_code = None; + let mut out_message = None; + + // If the errors include an `HttpError` or `StatusCode` or `ServerFnError`, we need + // to try and return the appropriate status code + if let Some(error) = error.downcast_ref::() { + status_code = Some(error.status); + out_message = error.message.clone(); + } + + if let Some(error) = error.downcast_ref::() { + status_code = Some(*error); + } + + // todo - the user is allowed to return anything that impls `From` + // we need to eventually be able to downcast that and get the status code from it + if let Some(ServerFnError::ServerError { message, code, .. }) = error.downcast_ref() + { + if let Some(code) = code { + status_code = Some( + (*code) + .try_into() + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + ); + } else { + status_code = Some(StatusCode::INTERNAL_SERVER_ERROR); + } + out_message = Some(message.clone()); + } + + // If there was an error while routing, return the error with a 404 status // Return a routing error if any of the errors were a routing error - let routing_error = errors.iter().find_map(|err| err.downcast().cloned()); - if let Some(routing_error) = routing_error { - _ = initial_result_tx.send(Err(SSRError::Routing(routing_error))); + if let Some(routing_error) = error.downcast_ref::().cloned() { + status_code = Some(StatusCode::NOT_FOUND); + out_message = Some(routing_error.to_string()); + } + + // If we captured anything that produces a status code, we should return that status code. + if let Some(status_code) = status_code { + _ = initial_result_tx.send(Err(SSRError::HttpError { + status: status_code, + message: out_message, + })); return; } - #[derive(thiserror::Error, Debug)] - #[error("{0}")] - pub struct ErrorWhileRendering(String); - let mut all_errors = String::new(); - for error in errors { - all_errors += &error.to_string(); - all_errors += "\n" - } - let error = ErrorWhileRendering(all_errors); + _ = initial_result_tx.send(Err(SSRError::Incremental( - IncrementalRendererError::Other(Box::new(error)), + IncrementalRendererError::Other(error), ))); + return; } - let mut pre_body = String::new(); + // Now that we handled any errors from rendering, we can send the initial ok result + _ = initial_result_tx.send(Ok(())); - if let Err(err) = wrapper.render_head(&mut pre_body, &virtual_dom) { + // Wait long enough to assemble the `` of the document before starting to stream + let mut pre_body = String::new(); + if let Err(err) = Self::render_head(&cfg, &mut pre_body, &virtual_dom) { _ = into.start_send(Err(err)); return; } @@ -292,7 +289,7 @@ impl SsrRendererPool { { let scope_to_mount_mapping = scope_to_mount_mapping.clone(); let stream = stream.clone(); - renderer.set_render_components(streaming_render_component_callback( + renderer.set_render_components(Self::streaming_render_component_callback( stream, scope_to_mount_mapping, )); @@ -309,7 +306,7 @@ impl SsrRendererPool { let mut initial_frame = renderer.render(&virtual_dom); // Along with the initial frame, we render the html after the main element, but before the body tag closes. This should include the script that starts loading the wasm bundle. - if let Err(err) = wrapper.render_after_main(&mut initial_frame, &virtual_dom) { + if let Err(err) = Self::render_after_main(&cfg, &mut initial_frame, &virtual_dom) { throw_error!(err); } stream.render(initial_frame); @@ -333,14 +330,14 @@ impl SsrRendererPool { renderer.reset_hydration(); renderer.render_scope(into, &virtual_dom, scope) }; - let resolved_data = serialize_server_data(&virtual_dom, scope); + let resolved_data = Self::serialize_server_data(&virtual_dom, scope); if let Err(err) = stream.replace_placeholder( pending_suspense_boundary.mount, render_suspense, resolved_data, &mut resolved_chunk, ) { - throw_error!(dioxus_isrg::IncrementalRendererError::RenderError(err)); + throw_error!(IncrementalRendererError::RenderError(err)); } stream.render(resolved_chunk); @@ -356,7 +353,7 @@ impl SsrRendererPool { // we need to capture the errors and send them to the client as it resolves virtual_dom.in_runtime(|| { for &suspense_scope in pending_suspense_boundary.children.iter() { - start_capturing_errors(suspense_scope); + Self::start_capturing_errors(suspense_scope); } }); } @@ -367,21 +364,21 @@ impl SsrRendererPool { // After suspense is done, we render the html after the body let mut post_streaming = String::new(); - if let Err(err) = wrapper.render_after_body(&mut post_streaming) { + if let Err(err) = Self::render_after_body(&cfg, &mut post_streaming) { throw_error!(err); } // If incremental rendering is enabled, add the new render to the cache without the streaming bits if let Some(incremental) = &self.incremental_cache { let mut cached_render = String::new(); - if let Err(err) = wrapper.render_head(&mut cached_render, &virtual_dom) { + if let Err(err) = Self::render_head(&cfg, &mut cached_render, &virtual_dom) { throw_error!(err); } renderer.reset_hydration(); if let Err(err) = renderer.render_to(&mut cached_render, &virtual_dom) { - throw_error!(dioxus_isrg::IncrementalRendererError::RenderError(err)); + throw_error!(IncrementalRendererError::RenderError(err)); } - if let Err(err) = wrapper.render_after_main(&mut cached_render, &virtual_dom) { + if let Err(err) = Self::render_after_main(&cfg, &mut cached_render, &virtual_dom) { throw_error!(err); } cached_render.push_str(&post_streaming); @@ -397,14 +394,12 @@ impl SsrRendererPool { myself.renderers.write().unwrap().push(renderer); }; - let join_handle = spawn_platform(move || { - ProvideServerContext::new(create_render_future(), server_context) - }); + let join_handle = Self::spawn_platform(create_render_future); // Wait for the initial result which determines the status code - initial_result_rx.await.map_err(|err| { - SSRError::Incremental(IncrementalRendererError::Other(Box::new(err))) - })??; + initial_result_rx + .await + .map_err(|err| SSRError::Incremental(IncrementalRendererError::Other(err.into())))??; Ok(( RenderFreshness::now(None), @@ -414,220 +409,176 @@ impl SsrRendererPool { }, )) } -} -/// Create the streaming render component callback. It will keep track of what scopes are mounted to what pending -/// suspense boundaries in the DOM. -/// -/// This mapping is used to replace the DOM mount with the resolved contents once the suspense boundary is finished. -fn streaming_render_component_callback( - stream: Arc>, - scope_to_mount_mapping: Arc>>, -) -> impl Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result - + Send - + Sync - + 'static { - // We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary - // The stack starts with the root scope because the root is a suspense boundary - let pending_suspense_boundaries_stack = RwLock::new(vec![]); - move |renderer, to, vdom, scope| { - let is_suspense_boundary = - SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope) - .filter(|s| s.has_suspended_tasks()) - .is_some(); - if is_suspense_boundary { - let mount = stream.render_placeholder( - |to| { - { - pending_suspense_boundaries_stack - .write() - .unwrap() - .push(scope); - } - let out = renderer.render_scope(to, vdom, scope); - { - pending_suspense_boundaries_stack.write().unwrap().pop(); - } - out - }, - &mut *to, - )?; - // Add the suspense boundary to the list of pending suspense boundaries - // We will replace the mount with the resolved contents later once the suspense boundary is resolved - let mut scope_to_mount_mapping_write = scope_to_mount_mapping.write().unwrap(); - scope_to_mount_mapping_write.insert( - scope, - PendingSuspenseBoundary { - mount, - children: vec![], - }, - ); - // Add the scope to the list of children of the parent suspense boundary - let pending_suspense_boundaries_stack = - pending_suspense_boundaries_stack.read().unwrap(); - // If there is a parent suspense boundary, add the scope to the list of children - // This suspense boundary will start capturing errors when the parent is resolved - if let Some(parent) = pending_suspense_boundaries_stack.last() { - let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap(); - parent.children.push(scope); - } - // Otherwise this is a root suspense boundary, so we need to start capturing errors immediately - else { - vdom.in_runtime(|| { - start_capturing_errors(scope); - }); - } - } else { - renderer.render_scope(to, vdom, scope)? - } - Ok(()) + fn pre_renderer() -> Renderer { + let mut renderer = Renderer::default(); + renderer.pre_render = true; + renderer } -} -/// Start capturing errors at a suspense boundary. If the parent suspense boundary is frozen, we need to capture the errors in the suspense boundary -/// and send them to the client to continue bubbling up -fn start_capturing_errors(suspense_scope: ScopeId) { - // Add an error boundary to the scope. We serialize the suspense error boundary separately so we can use - // the normal in memory ErrorContext here - suspense_scope.in_runtime(|| dioxus_core::provide_context(ErrorContext::new(Vec::new()))); -} - -fn serialize_server_data(virtual_dom: &VirtualDom, scope: ScopeId) -> SerializedHydrationData { - // After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node - // Extract any data we serialized for hydration (from server futures) - let html_data = extract_from_suspense_boundary(virtual_dom, scope); - - // serialize the server state into a base64 string - html_data.serialized() -} - -/// Walks through the suspense boundary in a depth first order and extracts the data from the context API. -/// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic. -pub(crate) fn extract_from_suspense_boundary( - vdom: &VirtualDom, - scope: ScopeId, -) -> HydrationContext { - let data = HydrationContext::default(); - serialize_errors(&data, vdom, scope); - take_from_scope(&data, vdom, scope); - data -} - -/// Get the errors from the suspense boundary -fn serialize_errors(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) { - // If there is an error boundary on the suspense boundary, grab the error from the context API - // and throw it on the client so that it bubbles up to the nearest error boundary - let error = vdom.in_runtime(|| { - scope - .consume_context::() - .and_then(|error_context| error_context.errors().first().cloned()) - }); - context - .error_entry() - .insert(&error, std::panic::Location::caller()); -} - -fn take_from_scope(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) { - vdom.in_runtime(|| { - scope.in_runtime(|| { - // Grab any serializable server context from this scope - let other: Option = has_context(); - if let Some(other) = other { - context.extend(&other); + /// Create the streaming render component callback. It will keep track of what scopes are mounted to what pending + /// suspense boundaries in the DOM. + /// + /// This mapping is used to replace the DOM mount with the resolved contents once the suspense boundary is finished. + fn streaming_render_component_callback( + stream: Arc>, + scope_to_mount_mapping: Arc>>, + ) -> impl Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> std::fmt::Result + + Send + + Sync + + 'static { + // We use a stack to keep track of what suspense boundaries we are nested in to add children to the correct boundary + // The stack starts with the root scope because the root is a suspense boundary + let pending_suspense_boundaries_stack = RwLock::new(vec![]); + move |renderer, to, vdom, scope| { + let is_suspense_boundary = + SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope) + .filter(|s| s.has_suspended_tasks()) + .is_some(); + if is_suspense_boundary { + let mount = stream.render_placeholder( + |to| { + { + pending_suspense_boundaries_stack + .write() + .unwrap() + .push(scope); + } + let out = renderer.render_scope(to, vdom, scope); + { + pending_suspense_boundaries_stack.write().unwrap().pop(); + } + out + }, + &mut *to, + )?; + // Add the suspense boundary to the list of pending suspense boundaries + // We will replace the mount with the resolved contents later once the suspense boundary is resolved + let mut scope_to_mount_mapping_write = scope_to_mount_mapping.write().unwrap(); + scope_to_mount_mapping_write.insert( + scope, + PendingSuspenseBoundary { + mount, + children: vec![], + }, + ); + // Add the scope to the list of children of the parent suspense boundary + let pending_suspense_boundaries_stack = + pending_suspense_boundaries_stack.read().unwrap(); + // If there is a parent suspense boundary, add the scope to the list of children + // This suspense boundary will start capturing errors when the parent is resolved + if let Some(parent) = pending_suspense_boundaries_stack.last() { + let parent = scope_to_mount_mapping_write.get_mut(parent).unwrap(); + parent.children.push(scope); + } + // Otherwise this is a root suspense boundary, so we need to start capturing errors immediately + else { + vdom.in_runtime(|| { + Self::start_capturing_errors(scope); + }); + } + } else { + renderer.render_scope(to, vdom, scope)? } + Ok(()) + } + } + + /// Start capturing errors at a suspense boundary. If the parent suspense boundary is frozen, we need to capture the errors in the suspense boundary + /// and send them to the client to continue bubbling up + fn start_capturing_errors(suspense_scope: ScopeId) { + // Add an error boundary to the scope. We serialize the suspense error boundary separately so we can use + // the normal in memory ErrorContext here + suspense_scope.in_runtime(|| dioxus_core::provide_context(ErrorContext::new(None))); + } + + fn serialize_server_data(virtual_dom: &VirtualDom, scope: ScopeId) -> SerializedHydrationData { + // After we replace the placeholder in the dom with javascript, we need to send down the resolved data so that the client can hydrate the node + // Extract any data we serialized for hydration (from server futures) + let html_data = Self::extract_from_suspense_boundary(virtual_dom, scope); + + // serialize the server state into a base64 string + html_data.serialized() + } + + /// Walks through the suspense boundary in a depth first order and extracts the data from the context API. + /// We use depth first order instead of relying on the order the hooks are called in because during suspense on the server, the order that futures are run in may be non deterministic. + pub(crate) fn extract_from_suspense_boundary( + vdom: &VirtualDom, + scope: ScopeId, + ) -> HydrationContext { + let data = HydrationContext::default(); + Self::serialize_errors(&data, vdom, scope); + Self::take_from_scope(&data, vdom, scope); + data + } + + /// Get the errors from the suspense boundary + fn serialize_errors(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) { + // If there is an error boundary on the suspense boundary, grab the error from the context API + // and throw it on the client so that it bubbles up to the nearest error boundary + let error = vdom.in_runtime(|| { + scope + .consume_context::() + .and_then(|error_context| error_context.error()) }); - }); - - // then continue to any children - if let Some(scope) = vdom.get_scope(scope) { - // If this is a suspense boundary, move into the children first (even if they are suspended) because that will be run first on the client - if let Some(suspense_boundary) = - SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope.id()) - { - if let Some(node) = suspense_boundary.suspended_nodes() { - take_from_vnode(context, vdom, &node); - } - } - if let Some(node) = scope.try_root_node() { - take_from_vnode(context, vdom, node); - } + context + .error_entry() + .insert(&error, std::panic::Location::caller()); } -} -fn take_from_vnode(context: &HydrationContext, vdom: &VirtualDom, vnode: &VNode) { - for (dynamic_node_index, dyn_node) in vnode.dynamic_nodes.iter().enumerate() { - match dyn_node { - DynamicNode::Component(comp) => { - if let Some(scope) = comp.mounted_scope(dynamic_node_index, vnode, vdom) { - take_from_scope(context, vdom, scope.id()); + fn take_from_scope(context: &HydrationContext, vdom: &VirtualDom, scope: ScopeId) { + vdom.in_runtime(|| { + scope.in_runtime(|| { + // Grab any serializable server context from this scope + let other: Option = has_context(); + if let Some(other) = other { + context.extend(&other); + } + }); + }); + + // then continue to any children + if let Some(scope) = vdom.get_scope(scope) { + // If this is a suspense boundary, move into the children first (even if they are suspended) because that will be run first on the client + if let Some(suspense_boundary) = + SuspenseContext::downcast_suspense_boundary_from_scope(&vdom.runtime(), scope.id()) + { + if let Some(node) = suspense_boundary.suspended_nodes() { + Self::take_from_vnode(context, vdom, &node); } } - DynamicNode::Fragment(nodes) => { - for node in nodes { - take_from_vnode(context, vdom, node); - } + if let Some(node) = scope.try_root_node() { + Self::take_from_vnode(context, vdom, node); } - _ => {} - } - } -} - -/// State used in server side rendering. This utilizes a pool of [`dioxus_ssr::Renderer`]s to cache static templates between renders. -#[derive(Clone)] -pub struct SSRState { - // We keep a pool of renderers to avoid re-creating them on every request. They are boxed to make them very cheap to move - renderers: Arc, -} - -impl SSRState { - /// Create a new [`SSRState`]. - pub fn new(cfg: &ServeConfig) -> Self { - Self { - renderers: Arc::new(SsrRendererPool::new(4, cfg.incremental.clone())), } } - /// Render the application to HTML. - pub async fn render<'a>( - &'a self, - route: String, - cfg: &'a ServeConfig, - virtual_dom_factory: impl FnOnce() -> VirtualDom + Send + Sync + 'static, - server_context: &'a DioxusServerContext, - ) -> Result< - ( - RenderFreshness, - impl Stream>, - ), - SSRError, - > { - self.renderers - .clone() - .render_to(cfg, route, virtual_dom_factory, server_context) - .await + fn take_from_vnode(context: &HydrationContext, vdom: &VirtualDom, vnode: &VNode) { + for (dynamic_node_index, dyn_node) in vnode.dynamic_nodes.iter().enumerate() { + match dyn_node { + DynamicNode::Component(comp) => { + if let Some(scope) = comp.mounted_scope(dynamic_node_index, vnode, vdom) { + Self::take_from_scope(context, vdom, scope.id()); + } + } + DynamicNode::Fragment(nodes) => { + for node in nodes { + Self::take_from_vnode(context, vdom, node); + } + } + _ => {} + } + } } -} -/// The template that wraps the body of the HTML for a fullstack page. This template contains the data needed to hydrate server functions that were run on the server. -pub struct FullstackHTMLTemplate { - cfg: ServeConfig, -} - -impl FullstackHTMLTemplate { - /// Create a new [`FullstackHTMLTemplate`]. - pub fn new(cfg: &ServeConfig) -> Self { - Self { cfg: cfg.clone() } - } -} - -impl FullstackHTMLTemplate { /// Render any content before the head of the page. pub fn render_head( - &self, + cfg: &ServeConfig, to: &mut R, virtual_dom: &VirtualDom, - ) -> Result<(), dioxus_isrg::IncrementalRendererError> { - let ServeConfig { index, .. } = &self.cfg; + ) -> Result<(), IncrementalRendererError> { + let ServeConfig { index, .. } = cfg; let title = { let document: Option> = @@ -654,17 +605,17 @@ impl FullstackHTMLTemplate { document.start_streaming(); } - self.render_before_body(to)?; + Self::render_before_body(cfg, to)?; Ok(()) } /// Render any content before the body of the page. fn render_before_body( - &self, + cfg: &ServeConfig, to: &mut R, - ) -> Result<(), dioxus_isrg::IncrementalRendererError> { - let ServeConfig { index, .. } = &self.cfg; + ) -> Result<(), IncrementalRendererError> { + let ServeConfig { index, .. } = cfg; to.write_str(&index.close_head)?; @@ -679,15 +630,15 @@ impl FullstackHTMLTemplate { /// Render all content after the main element of the page. pub fn render_after_main( - &self, + cfg: &ServeConfig, to: &mut R, virtual_dom: &VirtualDom, - ) -> Result<(), dioxus_isrg::IncrementalRendererError> { - let ServeConfig { index, .. } = &self.cfg; + ) -> Result<(), IncrementalRendererError> { + let ServeConfig { index, .. } = cfg; // Collect the initial server data from the root node. For most apps, no use_server_futures will be resolved initially, so this will be full on `None`s. // Sending down those Nones are still important to tell the client not to run the use_server_futures that are already running on the backend - let resolved_data = serialize_server_data(virtual_dom, ScopeId::ROOT); + let resolved_data = SsrRendererPool::serialize_server_data(virtual_dom, ScopeId::ROOT); // We always send down the data required to hydrate components on the client let raw_data = resolved_data.data; write!( @@ -716,19 +667,41 @@ impl FullstackHTMLTemplate { /// Render all content after the body of the page. pub fn render_after_body( - &self, + cfg: &ServeConfig, to: &mut R, - ) -> Result<(), dioxus_isrg::IncrementalRendererError> { - let ServeConfig { index, .. } = &self.cfg; + ) -> Result<(), IncrementalRendererError> { + let ServeConfig { index, .. } = cfg; to.write_str(&index.after_closing_body_tag)?; Ok(()) } -} -fn pre_renderer() -> Renderer { - let mut renderer = Renderer::default(); - renderer.pre_render = true; - renderer + /// Spawn a task in the background. If wasm is enabled, this will use the single threaded tokio runtime + fn spawn_platform(f: impl FnOnce() -> Fut + Send + 'static) -> JoinHandle + where + Fut: Future + 'static, + Fut::Output: Send + 'static, + { + #[cfg(not(target_arch = "wasm32"))] + { + use tokio_util::task::LocalPoolHandle; + static TASK_POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + + let pool = TASK_POOL.get_or_init(|| { + LocalPoolHandle::new( + std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1), + ) + }); + + pool.spawn_pinned(f) + } + + #[cfg(target_arch = "wasm32")] + { + tokio::task::spawn_local(f()) + } + } } diff --git a/packages/server/src/streaming.rs b/packages/fullstack-server/src/streaming.rs similarity index 99% rename from packages/server/src/streaming.rs rename to packages/fullstack-server/src/streaming.rs index 263fffe05..cf34dcd68 100644 --- a/packages/server/src/streaming.rs +++ b/packages/fullstack-server/src/streaming.rs @@ -26,7 +26,7 @@ //! //! ``` -use dioxus_fullstack_protocol::SerializedHydrationData; +use dioxus_fullstack_core::SerializedHydrationData; use futures_channel::mpsc::Sender; use std::{ diff --git a/packages/fullstack/.vscode/settings.json b/packages/fullstack/.vscode/settings.json new file mode 100644 index 000000000..ebbf709c9 --- /dev/null +++ b/packages/fullstack/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "rust-analyzer.cargo.features": "all", + "rust-analyzer.check.features": "all", + "rust-analyzer.cargo.extraArgs": [ + "--tests" + ], +} diff --git a/packages/fullstack/Cargo.toml b/packages/fullstack/Cargo.toml index ce4175e78..6ed89471d 100644 --- a/packages/fullstack/Cargo.toml +++ b/packages/fullstack/Cargo.toml @@ -3,108 +3,116 @@ name = "dioxus-fullstack" authors = ["Jonathan Kelley", "Evan Almloff"] version = { workspace = true } edition = "2021" -description = "Fullstack utilities for Dioxus: Build fullstack web, desktop, and mobile apps with a single codebase." +description = "Library for fetching resources from servers in Dioxus apps." license = "MIT OR Apache-2.0" repository = "https://github.com/DioxusLabs/dioxus/" homepage = "https://dioxuslabs.com" -keywords = ["web", "desktop", "mobile", "gui", "server"] +keywords = ["web", "gui", "server"] resolver = "2" [dependencies] -# server functions -server_fn = { workspace = true, default-features = false } -dioxus_server_macro = { workspace = true } - -# axum -axum = { workspace = true, optional = true } -tower-http = { workspace = true, optional = true, features = ["fs"] } - -dioxus-core = { workspace = true } -dioxus-document = { workspace = true } -generational-box = { workspace = true } - -# Dioxus + SSR -dioxus-server = { workspace = true, optional = true} -dioxus-isrg = { workspace = true, optional = true } -dioxus-router = { workspace = true, features = ["streaming"], optional = true } -dioxus-fullstack-hooks = { workspace = true } -dioxus-fullstack-protocol = { workspace = true } - -# Web Integration -dioxus-web = { workspace = true, features = ["hydrate"], default-features = false, optional = true } -dioxus-interpreter-js = { workspace = true, optional = true } - -# Desktop Integration -dioxus-desktop = { workspace = true, optional = true } - -tracing = { workspace = true } -tracing-futures = { workspace = true, optional = true } -tokio-util = { workspace = true, features = ["rt"], optional = true } -async-trait = { workspace = true, optional = true } - -serde = { workspace = true } -tokio-stream = { workspace = true, features = ["sync"], optional = true } -futures-util = { workspace = true } +# shared for both server + client futures-channel = { workspace = true } +serde = { workspace = true, features = ["derive"] } ciborium = { workspace = true } base64 = { workspace = true } -rustls = { workspace = true, optional = true } -hyper-rustls = { workspace = true, optional = true } +tracing = { workspace = true } +thiserror = { workspace = true } +dioxus-fullstack-core = { workspace = true } +http = { workspace = true } +anyhow = { workspace = true } +dioxus-core = { workspace = true } +dioxus-signals = { workspace = true } +dioxus-hooks = { workspace = true } +dioxus-html = { workspace = true } +dioxus-cli-config = { workspace = true } +futures = { workspace = true, default-features = true } +bytes = { workspace = true, features = ["serde"] } +dioxus-fullstack-macro = { workspace = true } +http-body-util = "0.1.3" +futures-util = { workspace = true } +axum-core = { workspace = true } +send_wrapper = { features = ["futures"], workspace = true, default-features = true } +serde_json = { workspace = true } +pin-project = { workspace = true } +serde_qs = { workspace = true, default-features = true} +mime = "0.3.17" +reqwest = { workspace = true, features = ["json", "rustls-tls", "cookies", "stream"] } +url = { workspace = true } +serde_urlencoded = { workspace = true } +form_urlencoded = { workspace = true } +axum = { workspace = true, default-features = false, features = ["json", "form", "query", "multipart", "query"] } +http-body = { workspace = true } +headers = "0.4.1" +dioxus-asset-resolver = { workspace = true, features = ["native"] } -pin-project = { version = "1.1.10", optional = true } -thiserror = { workspace = true, optional = true } -bytes = "1.10.1" +const_format = { workspace = true, default-features = true } +const-str = { workspace = true, default-features = true } +rustversion = { workspace = true, default-features = true } +xxhash-rust = { features = ["const_xxh64"], workspace = true, default-features = true } +# rmp-serde = { version = "1.3", default-features = true } + +derive_more = { version = "2.0.1", features = ["deref", "deref_mut", "display", "from"] } + + +# server deps +axum-extra = { workspace = true, optional = true, features = ["typed-header"] } +inventory = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +tokio-tungstenite = { workspace = true, optional = true } +tokio-stream = { workspace = true, features = ["sync"], optional = true } tower = { workspace = true, features = ["util"], optional = true } +tower-http = { workspace = true, features = ["fs", "limit"], optional = true } tower-layer = { version = "0.3.3", optional = true } -parking_lot = { workspace = true, features = ["send_guard"], optional = true } -web-sys = { version = "0.3.77", optional = true, features = [ - "Window", - "Document", - "Element", - "HtmlDocument", - "Storage", - "console", -] } -dioxus-cli-config = { workspace = true, optional = true } +# payloads +postcard = { features = ["alloc"], optional = true, workspace = true, default-features = true } +rmp-serde = { version = "1.3", optional = true } +async-stream = "0.3.6" -dioxus-devtools = { workspace = true, optional = true } -aws-lc-rs = { version = "1.13.1", optional = true } -dioxus-history = { workspace = true } -http.workspace = true +# websocket stuff +# [target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", features = ["WebSocket", "CloseEvent", "ErrorEvent", "Event", "MessageEvent", "BinaryType", "FormData", "Response", "ReadableStream", "AbortController", "File", "ReadableStream", "HtmlFormElement", "FormData"], optional = true } +gloo-net = "0.6.0" +js-sys = { workspace = true } +wasm-bindgen = { workspace = true } +wasm-bindgen-futures = { workspace = true } +wasm-streams = "0.4.2" +content_disposition = "0.4.0" + +# native websocket deps +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +async-tungstenite = { version = "0.31.0", default-features = false, features = ["futures-03-sink"], optional = true } +tungstenite = { version = "0.27", default-features = false, features = ["handshake"], optional = true } +tokio-util = { workspace = true, features = ["codec", "compat"] } [dev-dependencies] -dioxus = { workspace = true, features = ["fullstack"] } -tokio = { workspace = true, features = ["full"] } +dioxus = { workspace = true, features = ["fullstack", "router"] } +dioxus-server = { workspace = true } [features] -default = ["devtools", "document", "file_engine", "mounted"] -devtools = ["dioxus-web?/devtools", "dep:dioxus-devtools"] -mounted = ["dioxus-web?/mounted"] -file_engine = ["dioxus-web?/file_engine"] -document = ["dioxus-web?/document"] -web = ["dep:dioxus-web", "dep:web-sys", "dioxus-fullstack-hooks/web", "server_fn/browser", "dioxus_server_macro/browser"] -desktop = ["server_fn/reqwest", "dioxus_server_macro/reqwest"] -mobile = ["server_fn/reqwest", "dioxus_server_macro/reqwest"] -default-tls = ["server_fn/default-tls"] -rustls = ["server_fn/rustls", "dep:rustls", "dep:hyper-rustls"] +default = ["ws"] +web = [ + "dep:web-sys" +] +native = [] server = [ - "server-core" , - "default-tls", - "server_fn/axum", + "dep:inventory", + "dep:tokio", + "dep:tokio-tungstenite", + "dep:tokio-stream", + "dep:async-tungstenite", + "dep:tungstenite", + "dep:tower", + "dep:tower-http", + "dep:tower-layer", + "dep:axum-extra", + "axum/ws", + "axum/tokio", ] -server-core = [ - "server_fn/axum-no-default", - "dioxus_server_macro/axum", - "server_fn/reqwest", - "server_fn/ssr", - "dioxus_server_macro/reqwest", - "dioxus-fullstack-hooks/server", - "dep:dioxus-server", - "dioxus-interpreter-js", -] -aws-lc-rs = ["dep:aws-lc-rs"] +postcard = ["dep:postcard"] +msgpack = ["dep:rmp-serde"] +ws = ["dep:async-tungstenite", "dep:tungstenite"] [package.metadata.docs.rs] cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] -features = ["axum", "web", "aws-lc-rs"] diff --git a/packages/fullstack/README.md b/packages/fullstack/README.md index 23dcaffaa..603124b5f 100644 --- a/packages/fullstack/README.md +++ b/packages/fullstack/README.md @@ -1,157 +1,5 @@ -# Dioxus Fullstack +# dioxus-fullstack -[![Crates.io][crates-badge]][crates-url] -[![MIT licensed][mit-badge]][mit-url] -[![Build Status][actions-badge]][actions-url] -[![Discord chat][discord-badge]][discord-url] +- wraps dioxus-server +- exposes rpc system -[crates-badge]: https://img.shields.io/crates/v/dioxus-fullstack.svg -[crates-url]: https://crates.io/crates/dioxus-fullstack -[mit-badge]: https://img.shields.io/badge/license-MIT-blue.svg -[mit-url]: https://github.com/dioxuslabs/dioxus/blob/main/LICENSE-MIT -[actions-badge]: https://github.com/dioxuslabs/dioxus/actions/workflows/main.yml/badge.svg -[actions-url]: https://github.com/dioxuslabs/dioxus/actions?query=workflow%3ACI+branch%3Amaster -[discord-badge]: https://img.shields.io/discord/899851952891002890.svg?logo=discord&style=flat-square -[discord-url]: https://discord.gg/XgGxMSkvUM - -[Website](https://dioxuslabs.com) | -[Guides](https://dioxuslabs.com/learn/0.6/) | -[API Docs](https://docs.rs/dioxus-fullstack/latest/dioxus_fullstack/) | -[Chat](https://discord.gg/XgGxMSkvUM) - -Fullstack utilities for the [`Dioxus`](https://dioxuslabs.com) framework. - -# Features - -- Integrates with the [Axum](./examples/axum-hello-world/src/main.rs) server framework with utilities for serving and rendering Dioxus applications. -- [Server functions](https://docs.rs/dioxus-fullstack/latest/dioxus_fullstack/prelude/attr.server.html) allow you to call code on the server from the client as if it were a normal function. -- Instant RSX Hot reloading with [`dioxus-hot-reload`](https://crates.io/crates/dioxus-hot-reload). -- Passing root props from the server to the client. - -# Example - -Full stack Dioxus in under 30 lines of code - -```rust, no_run -#![allow(non_snake_case)] -use dioxus::prelude::*; - -fn main() { - dioxus::launch(App); -} - -#[component] -fn App() -> Element { - let mut meaning = use_signal(|| None); - - rsx! { - h1 { "Meaning of life: {meaning:?}" } - button { - onclick: move |_| async move { - if let Ok(data) = get_meaning("life the universe and everything".into()).await { - meaning.set(data); - } - }, - "Run a server function" - } - } -} - -#[server] -async fn get_meaning(of: String) -> ServerFnResult> { - Ok(of.contains("life").then(|| 42)) -} -``` - -## Axum Integration - -If you have an existing Axum router or you need more control over the server, you can use the [`DioxusRouterExt`](https://docs.rs/dioxus-fullstack/0.6.0-alpha.2/dioxus_fullstack/prelude/trait.DioxusRouterExt.html) trait to integrate with your existing Axum router. - -First, make sure your `axum` dependency is optional and enabled by the server feature flag. Axum cannot be compiled to wasm, so if it is enabled by default, it will cause a compile error: - -```toml -[dependencies] -dioxus = { version = "*", features = ["fullstack"] } -axum = { version = "0.8.0", optional = true } -tokio = { version = "1.0", features = ["full"], optional = true } -dioxus-cli-config = { version = "*", optional = true } - -[features] -server = ["dioxus/server", "dep:axum", "dep:tokio", "dioxus-cli-config"] -web = ["dioxus/web"] -``` - -Then we can set up dioxus with the axum server: - -```rust, no_run -#![allow(non_snake_case)] -use dioxus::prelude::*; -use dioxus_fullstack::ServeConfig; - -// The entry point for the server -#[cfg(feature = "server")] -#[tokio::main] -async fn main() { - // Get the address the server should run on. If the CLI is running, the CLI proxies fullstack into the main address - // and we use the generated address the CLI gives us - let address = dioxus::cli_config::fullstack_address_or_localhost(); - - // Set up the axum router - let router = axum::Router::new() - // You can add a dioxus application to the router with the `serve_dioxus_application` method - // This will add a fallback route to the router that will serve your component and server functions - .serve_dioxus_application(ServeConfig::new().unwrap(), App); - - // Finally, we can launch the server - let router = router.into_make_service(); - let listener = tokio::net::TcpListener::bind(address).await.unwrap(); - axum::serve(listener, router).await.unwrap(); -} - -// For any other platform, we just launch the app -#[cfg(not(feature = "server"))] -fn main() { - dioxus::launch(App); -} - -#[component] -fn App() -> Element { - let mut meaning = use_signal(|| None); - - rsx! { - h1 { "Meaning of life: {meaning:?}" } - button { - onclick: move |_| async move { - if let Ok(data) = get_meaning("life the universe and everything".into()).await { - meaning.set(data); - } - }, - "Run a server function" - } - } -} - -#[server] -async fn get_meaning(of: String) -> ServerFnResult> { - Ok(of.contains("life").then(|| 42)) -} -``` - -## Getting Started - -To get started with full stack Dioxus, check out our [getting started guide](https://dioxuslabs.com/learn/0.6/getting_started), or the [full stack examples](https://github.com/DioxusLabs/dioxus/tree/master/examples). - -## Contributing - -- Report issues on our [issue tracker](https://github.com/dioxuslabs/dioxus/issues). -- Join the discord and ask questions! - -## License - -This project is licensed under the [MIT license]. - -[mit license]: https://github.com/dioxuslabs/dioxus/blob/main/LICENSE-MIT - -Unless you explicitly state otherwise, any contribution intentionally submitted -for inclusion in Dioxus by you shall be licensed as MIT without any additional -terms or conditions. diff --git a/packages/fullstack/src/client.rs b/packages/fullstack/src/client.rs new file mode 100644 index 000000000..6f50c64ac --- /dev/null +++ b/packages/fullstack/src/client.rs @@ -0,0 +1,601 @@ +#![allow(unreachable_code)] + +use crate::{reqwest_error_to_request_error, StreamingError}; +use bytes::Bytes; +use dioxus_fullstack_core::RequestError; +use futures::Stream; +use futures::{TryFutureExt, TryStreamExt}; +use headers::{ContentType, Header}; +use http::{response::Parts, Extensions, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; +use send_wrapper::SendWrapper; +use serde::{de::DeserializeOwned, Serialize}; +use std::{fmt::Display, pin::Pin, prelude::rust_2024::Future, sync::OnceLock}; +use url::Url; + +pub static GLOBAL_REQUEST_CLIENT: OnceLock = OnceLock::new(); + +pub type ClientResult = Result; + +pub struct ClientRequest { + pub url: Url, + pub headers: HeaderMap, + pub method: Method, + pub extensions: Extensions, +} + +impl ClientRequest { + /// Create a new ClientRequest with the given method, url path, and query parameters. + pub fn new(method: http::Method, path: String, params: &impl Serialize) -> Self { + Self::fetch_inner(method, path, serde_qs::to_string(params).unwrap()) + } + + // Shrink monomorphization bloat by moving this to its own function + fn fetch_inner(method: http::Method, path: String, query: String) -> ClientRequest { + // On wasm, this doesn't matter since we always use relative URLs when making requests anyways + let mut server_url = get_server_url(); + + if server_url.is_empty() { + server_url = "http://this.is.not.a.real.url:9000"; + } + + let url = format!( + "{server_url}{path}{params}", + params = if query.is_empty() { + "".to_string() + } else { + format!("?{}", query) + } + ) + .parse() + .unwrap(); + + ClientRequest { + method, + url, + headers: HeaderMap::new(), + extensions: Extensions::new(), + } + } + + /// Get the HTTP method of this Request. + pub fn method(&self) -> &Method { + &self.method + } + + pub fn url(&self) -> &Url { + &self.url + } + + /// Extend the query parameters of this request with the given serialzable struct. + /// + /// This will use `serde_qs` to serialize the struct into query parameters. `serde_qs` has various + /// restrictions - make sure to read its documentation! + pub fn extend_query(mut self, query: &impl Serialize) -> Self { + let old_query = self.url.query().unwrap_or(""); + let new_query = serde_qs::to_string(query).unwrap(); + let combined_query = format!( + "{}{}{}", + old_query, + if old_query.is_empty() { "" } else { "&" }, + new_query + ); + self.url.set_query(Some(&combined_query)); + self + } + + /// Add a `Header` to this Request. + pub fn header( + mut self, + name: impl TryInto, + value: impl TryInto, + ) -> Result { + self.headers.append( + name.try_into() + .map_err(|d| RequestError::Builder(d.to_string()))?, + value + .try_into() + .map_err(|d| RequestError::Builder(d.to_string()))?, + ); + Ok(self) + } + + /// Add a `Header` to this Request. + pub fn typed_header(mut self, header: H) -> Self { + let mut headers = vec![]; + header.encode(&mut headers); + for header in headers { + self.headers.append(H::name(), header); + } + self + } + + /// Creates a new reqwest client with cookies set + pub fn new_reqwest_client() -> reqwest::Client { + #[allow(unused_mut)] + let mut client = reqwest::Client::builder(); + + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::Arc; + use std::sync::LazyLock; + + static COOKIES: LazyLock> = + LazyLock::new(|| Arc::new(reqwest::cookie::Jar::default())); + + client = client.cookie_store(true).cookie_provider(COOKIES.clone()); + } + + client.build().unwrap() + } + + /// Creates a new reqwest request builder with the method, url, and headers set from this ClientRequest + pub fn new_reqwest_request(&self) -> reqwest::RequestBuilder { + let client = GLOBAL_REQUEST_CLIENT.get_or_init(Self::new_reqwest_client); + + let mut req = client.request(self.method.clone(), self.url.clone()); + + for (key, value) in self.headers.iter() { + req = req.header(key, value); + } + + req + } + + #[cfg(feature = "web")] + pub fn new_gloo_request(&self) -> gloo_net::http::RequestBuilder { + let mut builder = + gloo_net::http::RequestBuilder::new(self.url.path()).method(self.method.clone()); + + for (key, value) in self.headers.iter() { + let value = match value.to_str() { + Ok(v) => v, + Err(er) => { + tracing::error!("Error converting header {key} value: {}", er); + continue; + } + }; + + builder = builder.header(key.as_str(), value); + } + + builder + } + + pub async fn send_form(self, data: &impl Serialize) -> Result { + // For GET and HEAD requests, we encode the form data as query parameters. + // For other request methods, we encode the form data as the request body. + if matches!(*self.method(), Method::GET | Method::HEAD) { + return self.extend_query(data).send_empty_body().await; + } + + let body = + serde_urlencoded::to_string(data).map_err(|err| RequestError::Body(err.to_string()))?; + + self.typed_header(ContentType::form_url_encoded()) + .send_raw_bytes(body) + .await + } + + /// Sends the request with an empty body. + pub async fn send_empty_body(self) -> Result { + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + return self.send_js_value(wasm_bindgen::JsValue::UNDEFINED).await; + } + + #[cfg(not(target_arch = "wasm32"))] + { + let res = self + .new_reqwest_request() + .send() + .await + .map_err(reqwest_error_to_request_error)?; + + return Ok(ClientResponse { + response: Box::new(res), + extensions: self.extensions, + }); + } + + unimplemented!() + } + + pub async fn send_raw_bytes( + self, + bytes: impl Into, + ) -> Result { + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + let bytes = bytes.into(); + let uint_8_array = js_sys::Uint8Array::from(&bytes[..]); + return self.send_js_value(uint_8_array.into()).await; + } + + #[cfg(not(target_arch = "wasm32"))] + { + let res = self + .new_reqwest_request() + .body(bytes.into()) + .send() + .await + .map_err(reqwest_error_to_request_error)?; + + return Ok(ClientResponse { + response: Box::new(res), + extensions: self.extensions, + }); + } + + unimplemented!() + } + + /// Sends text data with the `text/plain; charset=utf-8` content type. + pub async fn send_text( + self, + text: impl Into + Into, + ) -> Result { + self.typed_header(ContentType::text_utf8()) + .send_raw_bytes(text) + .await + } + + /// Sends JSON data with the `application/json` content type. + pub async fn send_json(self, json: &impl Serialize) -> Result { + let bytes = + serde_json::to_vec(json).map_err(|e| RequestError::Serialization(e.to_string()))?; + + if bytes.is_empty() || bytes == b"{}" || bytes == b"null" { + return self.send_empty_body().await; + } + + self.typed_header(ContentType::json()) + .send_raw_bytes(bytes) + .await + } + + pub async fn send_body_stream( + self, + stream: impl Stream> + Send + 'static, + ) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + let res = self + .new_reqwest_request() + .body(reqwest::Body::wrap_stream(stream)) + .send() + .await + .map_err(reqwest_error_to_request_error)?; + + return Ok(ClientResponse { + response: Box::new(res), + extensions: self.extensions, + }); + } + + // On the web, we have to buffer the entire stream into a Blob before sending it, + // since the Fetch API doesn't support streaming request bodies on browsers yet. + #[cfg(feature = "web")] + { + use wasm_bindgen::JsValue; + + let stream: Vec = stream.try_collect().await.map_err(|e| { + RequestError::Request(format!("Error collecting stream for request body: {}", e)) + })?; + + let uint_8_array = + js_sys::Uint8Array::new_with_length(stream.iter().map(|b| b.len() as u32).sum()); + + let mut offset = 0; + for chunk in stream { + uint_8_array.set(&js_sys::Uint8Array::from(&chunk[..]), offset); + offset += chunk.len() as u32; + } + + return self.send_js_value(JsValue::from(uint_8_array)).await; + } + + unimplemented!() + } + + #[cfg(feature = "web")] + pub async fn send_js_value( + self, + value: wasm_bindgen::JsValue, + ) -> Result { + use std::str::FromStr; + + let inner = self + .new_gloo_request() + .body(value) + .map_err(|e| RequestError::Request(e.to_string()))? + .send() + .await + .map_err(|e| RequestError::Request(e.to_string()))?; + + let status = inner.status(); + let url = inner + .url() + .parse() + .map_err(|e| RequestError::Request(format!("Error parsing response URL: {}", e)))?; + + let headers = { + let mut map = HeaderMap::new(); + for (key, value) in inner.headers().entries() { + if let Ok(header_value) = http::HeaderValue::from_str(&value) { + let header = HeaderName::from_str(&key).unwrap(); + map.append(header, header_value); + } + } + map + }; + + let content_length = headers + .get(http::header::CONTENT_LENGTH) + .and_then(|val| val.to_str().ok()) + .and_then(|s| s.parse::().ok()); + + let status = http::StatusCode::from_u16(status).unwrap_or(http::StatusCode::OK); + + Ok(ClientResponse { + extensions: self.extensions, + response: Box::new(browser::WrappedGlooResponse { + inner, + headers, + status, + url, + content_length, + }), + }) + } +} + +// On wasm reqwest not being send/sync gets annoying, but it's not relevant since wasm is single-threaded +unsafe impl Send for ClientRequest {} +unsafe impl Sync for ClientRequest {} + +/// A wrapper type over the platform's HTTP response type. +/// +/// This abstracts over the inner `reqwest::Response` type and provides the original request +/// and a way to store state associated with the response. +/// +/// On the web, it uses `web_sys::Response` instead of `reqwest::Response` to avoid pulling in +/// the entire `reqwest` crate and to support native browser APIs. +pub struct ClientResponse { + pub(crate) response: Box, + pub(crate) extensions: Extensions, +} + +impl ClientResponse { + pub fn status(&self) -> StatusCode { + self.response.status() + } + + pub fn headers(&self) -> &HeaderMap { + self.response.headers() + } + + pub fn url(&self) -> &Url { + self.response.url() + } + + pub fn content_length(&self) -> Option { + self.response.content_length() + } + + pub async fn bytes(self) -> Result { + self.response.bytes().await + } + + pub fn bytes_stream( + self, + ) -> impl futures_util::Stream> + 'static + Unpin + Send + { + self.response.bytes_stream() + } + + pub fn extensions(&self) -> &Extensions { + &self.extensions + } + + pub fn extensions_mut(&mut self) -> &mut Extensions { + &mut self.extensions + } + + pub async fn json(self) -> Result { + serde_json::from_slice(&self.bytes().await?) + .map_err(|e| RequestError::Decode(e.to_string())) + } + + pub async fn text(self) -> Result { + self.response.text().await + } + + /// Creates the `http::response::Parts` from this response. + pub fn make_parts(&self) -> Parts { + let mut response = http::response::Response::builder().status(self.response.status()); + + response = response.version(self.response.version()); + + for (key, value) in self.response.headers().iter() { + response = response.header(key, value); + } + + let (parts, _) = response.body(()).unwrap().into_parts(); + + parts + } + + /// Consumes the response, returning the head and a stream of the body. + pub fn into_parts(self) -> (Parts, impl Stream>) { + (self.make_parts(), self.bytes_stream()) + } +} + +/// Set the root server URL that all server function paths are relative to for the client. +/// +/// If this is not set, it defaults to the origin. +pub fn set_server_url(url: &'static str) { + ROOT_URL.set(url).unwrap(); +} + +/// Returns the root server URL for all server functions. +pub fn get_server_url() -> &'static str { + ROOT_URL.get().copied().unwrap_or("") +} + +static ROOT_URL: OnceLock<&'static str> = OnceLock::new(); + +pub trait ClientResponseDriver { + fn status(&self) -> StatusCode; + fn headers(&self) -> &HeaderMap; + fn url(&self) -> &Url; + fn version(&self) -> http::Version { + http::Version::HTTP_2 + } + fn content_length(&self) -> Option; + fn bytes(self: Box) -> Pin> + Send>>; + fn bytes_stream( + self: Box, + ) -> Pin> + 'static + Unpin + Send>>; + + fn text(self: Box) -> Pin> + Send>>; +} + +mod native { + use futures::Stream; + + use super::*; + + impl ClientResponseDriver for reqwest::Response { + fn status(&self) -> http::StatusCode { + reqwest::Response::status(self) + } + + fn version(&self) -> http::Version { + #[cfg(target_arch = "wasm32")] + { + return http::Version::HTTP_2; + } + + reqwest::Response::version(self) + } + + fn headers(&self) -> &http::HeaderMap { + reqwest::Response::headers(self) + } + + fn url(&self) -> &url::Url { + reqwest::Response::url(self) + } + + fn content_length(&self) -> Option { + reqwest::Response::content_length(self) + } + + fn bytes( + self: Box, + ) -> Pin> + Send>> { + Box::pin(SendWrapper::new(async move { + reqwest::Response::bytes(*self) + .map_err(reqwest_error_to_request_error) + .await + })) + } + + fn bytes_stream( + self: Box, + ) -> Pin> + 'static + Unpin + Send>> + { + Box::pin(SendWrapper::new( + reqwest::Response::bytes_stream(*self).map_err(|_| StreamingError::Failed), + )) + } + + fn text( + self: Box, + ) -> Pin> + Send>> { + Box::pin(SendWrapper::new(async move { + reqwest::Response::text(*self) + .map_err(reqwest_error_to_request_error) + .await + })) + } + } +} + +#[cfg(feature = "web")] +mod browser { + use crate::{ClientResponseDriver, StreamingError}; + use bytes::Bytes; + use dioxus_fullstack_core::RequestError; + use futures::{Stream, StreamExt}; + use http::{HeaderMap, StatusCode}; + use js_sys::Uint8Array; + use send_wrapper::SendWrapper; + use std::{pin::Pin, prelude::rust_2024::Future}; + use wasm_bindgen::JsCast; + + pub(crate) struct WrappedGlooResponse { + pub(crate) inner: gloo_net::http::Response, + pub(crate) headers: HeaderMap, + pub(crate) status: StatusCode, + pub(crate) url: url::Url, + pub(crate) content_length: Option, + } + + impl ClientResponseDriver for WrappedGlooResponse { + fn status(&self) -> StatusCode { + self.status + } + + fn headers(&self) -> &HeaderMap { + &self.headers + } + + fn url(&self) -> &url::Url { + &self.url + } + + fn content_length(&self) -> Option { + self.content_length + } + + fn bytes( + self: Box, + ) -> Pin> + Send>> { + Box::pin(SendWrapper::new(async move { + let bytes = self.inner.binary().await.unwrap(); + Ok(bytes.into()) + })) + } + + fn bytes_stream( + self: Box, + ) -> Pin> + 'static + Unpin + Send>> + { + Box::pin(SendWrapper::new( + wasm_streams::ReadableStream::from_raw(self.inner.body().unwrap()) + .into_stream() + .map(|chunk| { + Ok(chunk + .unwrap() + .dyn_into::() + .unwrap() + .to_vec() + .into()) + }), + )) + } + + fn text( + self: Box, + ) -> Pin> + Send>> { + Box::pin(SendWrapper::new(async move { + self.inner + .text() + .await + .map_err(|e| RequestError::Request(e.to_string())) + })) + } + } +} diff --git a/packages/fullstack/src/encoding.rs b/packages/fullstack/src/encoding.rs new file mode 100644 index 000000000..22408b48c --- /dev/null +++ b/packages/fullstack/src/encoding.rs @@ -0,0 +1,106 @@ +use bytes::Bytes; +use serde::{de::DeserializeOwned, Serialize}; + +/// A trait for encoding and decoding data. +/// +/// This takes an owned self to make it easier for zero-copy encodings. +pub trait Encoding { + fn content_type() -> &'static str; + fn stream_content_type() -> &'static str; + fn to_bytes(data: impl Serialize) -> Option; + fn from_bytes(bytes: Bytes) -> Option; +} + +pub struct JsonEncoding; +impl Encoding for JsonEncoding { + fn content_type() -> &'static str { + "application/json" + } + fn stream_content_type() -> &'static str { + "application/stream+json" + } + fn to_bytes(data: impl Serialize) -> Option { + serde_json::to_vec(&data).ok().map(Into::into) + } + + fn from_bytes(bytes: Bytes) -> Option { + serde_json::from_slice(&bytes).ok() + } +} + +pub struct CborEncoding; +impl Encoding for CborEncoding { + fn content_type() -> &'static str { + "application/cbor" + } + fn stream_content_type() -> &'static str { + "application/stream+cbor" + } + fn to_bytes(data: impl Serialize) -> Option { + let mut buf = Vec::new(); + ciborium::into_writer(&data, &mut buf).ok()?; + Some(buf.into()) + } + + fn from_bytes(bytes: Bytes) -> Option { + ciborium::de::from_reader(bytes.as_ref()).ok() + } +} + +#[cfg(feature = "postcard")] +pub struct PostcardEncoding; +#[cfg(feature = "postcard")] +impl Encoding for PostcardEncoding { + fn content_type() -> &'static str { + "application/postcard" + } + fn stream_content_type() -> &'static str { + "application/stream+postcard" + } + fn to_bytes(data: impl Serialize) -> Option { + postcard::to_allocvec(&data).ok().map(Into::into) + } + + fn from_bytes(bytes: Bytes) -> Option { + postcard::from_bytes(bytes.as_ref()).ok() + } +} + +#[cfg(feature = "msgpack")] +pub struct MsgPackEncoding; +#[cfg(feature = "msgpack")] +impl Encoding for MsgPackEncoding { + fn content_type() -> &'static str { + "application/msgpack" + } + fn stream_content_type() -> &'static str { + "application/stream+msgpack" + } + fn to_bytes(data: impl Serialize) -> Option { + rmp_serde::to_vec(&data).ok().map(Into::into) + } + + fn from_bytes(bytes: Bytes) -> Option { + rmp_serde::from_slice(&bytes).ok() + } +} + +// todo: ... add rkyv support +// pub struct RkyvEncoding; +// impl Encoding for RkyvEncoding { +// fn content_type() -> &'static str { +// "application/rkyv" +// } +// fn stream_content_type() -> &'static str { +// "application/stream+rkyv" +// } +// fn to_bytes(data: impl Serialize) -> Option { +// let mut buf = rkyv::ser::Serializer::new(rkyv::ser::AllocSerializer::new()); +// rkyv::ser::Serializer::serialize(&mut buf, &data).ok()?; +// Some(Bytes::from(buf.into_inner())) +// } +// fn from_bytes(bytes: Bytes) -> Option { +// let archived = unsafe { rkyv::archived_root::(&bytes) }; +// rkyv::Deserialize::deserialize(archived).ok() +// } +// } diff --git a/packages/fullstack/src/error.rs b/packages/fullstack/src/error.rs deleted file mode 100644 index 0bc0a25d6..000000000 --- a/packages/fullstack/src/error.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::{ - error::Error, - fmt::{Debug, Display}, - str::FromStr, -}; - -use dioxus_core::{CapturedError, RenderError}; -use serde::{de::DeserializeOwned, Serialize}; -use server_fn::{ - codec::JsonEncoding, - error::{FromServerFnError, ServerFnErrorErr}, -}; - -/// A default result type for server functions, which can either be successful or contain an error. The [`ServerFnResult`] type -/// is a convenient alias for a `Result` type that uses [`ServerFnError`] as the error type. -/// -/// # Example -/// ```rust -/// use dioxus::prelude::*; -/// -/// #[server] -/// async fn parse_number(number: String) -> ServerFnResult { -/// let parsed_number: f32 = number.parse()?; -/// Ok(parsed_number) -/// } -/// ``` -pub type ServerFnResult = std::result::Result>; - -/// An error type for server functions. This may either be an error that occurred while running the server -/// function logic, or an error that occurred while communicating with the server inside the server function crate. -/// -/// ## Usage -/// -/// You can use the [`ServerFnError`] type in the Error type of your server function result or use the [`ServerFnResult`] -/// type as the return type of your server function. When you call the server function, you can handle the error directly -/// or convert it into a [`CapturedError`] to throw into the nearest [`ErrorBoundary`](dioxus_core::ErrorBoundary). -/// -/// ```rust -/// use dioxus::prelude::*; -/// -/// #[server] -/// async fn parse_number(number: String) -> ServerFnResult { -/// // You can convert any error type into the `ServerFnError` with the `?` operator -/// let parsed_number: f32 = number.parse()?; -/// Ok(parsed_number) -/// } -/// -/// #[component] -/// fn ParseNumberServer() -> Element { -/// let mut number = use_signal(|| "42".to_string()); -/// let mut parsed = use_signal(|| None); -/// -/// rsx! { -/// input { -/// value: "{number}", -/// oninput: move |e| number.set(e.value()), -/// } -/// button { -/// onclick: move |_| async move { -/// // Call the server function to parse the number -/// // If the result is Ok, continue running the closure, otherwise bubble up the -/// // error to the nearest error boundary with `?` -/// let result = parse_number(number()).await?; -/// parsed.set(Some(result)); -/// Ok(()) -/// }, -/// "Parse Number" -/// } -/// if let Some(value) = parsed() { -/// p { "Parsed number: {value}" } -/// } else { -/// p { "No number parsed yet." } -/// } -/// } -/// } -/// ``` -/// -/// ## Differences from [`CapturedError`] -/// -/// Both this error type and [`CapturedError`] can be used to represent boxed errors in dioxus. However, this error type -/// is more strict about the kinds of errors it can represent. [`CapturedError`] can represent any error that implements -/// the [`Error`] trait or can be converted to a string. [`CapturedError`] holds onto the type information of the error -/// and lets you downcast the error to its original type. -/// -/// [`ServerFnError`] represents server function errors as [`String`]s by default without any additional type information. -/// This makes it easy to serialize the error to JSON and send it over the wire, but it means that you can't get the -/// original type information of the error back. If you need to preserve the type information of the error, you can use a -/// [custom error variant](#custom-error-variants) that holds onto the type information. -/// -/// ## Custom error variants -/// -/// The [`ServerFnError`] type accepts a generic type parameter `T` that is used to represent the error type used for server -/// functions. If you need to keep the type information of your error, you can create a custom error variant that implements -/// [`Serialize`] and [`DeserializeOwned`]. This allows you to serialize the error to JSON and send it over the wire, -/// while still preserving the type information. -/// -/// ```rust -/// use dioxus::prelude::*; -/// use serde::{Deserialize, Serialize}; -/// use std::fmt::Debug; -/// -/// #[derive(Clone, Debug, Serialize, Deserialize)] -/// pub struct MyCustomError { -/// message: String, -/// code: u32, -/// } -/// -/// impl MyCustomError { -/// pub fn new(message: String, code: u32) -> Self { -/// Self { message, code } -/// } -/// } -/// -/// #[server] -/// async fn server_function() -> ServerFnResult { -/// // Return your custom error -/// Err(ServerFnError::ServerError(MyCustomError::new( -/// "An error occurred".to_string(), -/// 404, -/// ))) -/// } -/// ``` -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)] -pub enum ServerFnError { - /// An error running the server function - ServerError(T), - - /// An error communicating with the server - CommunicationError(ServerFnErrorErr), -} - -impl ServerFnError { - /// Creates a new `ServerFnError` from something that implements `ToString`. - /// - /// # Examples - /// ```rust - /// use dioxus::prelude::*; - /// use serde::{Serialize, Deserialize}; - /// - /// #[server] - /// async fn server_function() -> ServerFnResult { - /// // Return your custom error - /// Err(ServerFnError::new("Something went wrong")) - /// } - /// ``` - pub fn new(error: impl ToString) -> Self { - Self::ServerError(error.to_string()) - } -} - -impl ServerFnError { - /// Returns true if the error is a server error - pub fn is_server_error(&self) -> bool { - matches!(self, ServerFnError::ServerError(_)) - } - - /// Returns true if the error is a communication error - pub fn is_communication_error(&self) -> bool { - matches!(self, ServerFnError::CommunicationError(_)) - } - - /// Returns a reference to the server error if it is a server error - /// or `None` if it is a communication error. - pub fn server_error(&self) -> Option<&T> { - match self { - ServerFnError::ServerError(err) => Some(err), - ServerFnError::CommunicationError(_) => None, - } - } - - /// Returns a reference to the communication error if it is a communication error - /// or `None` if it is a server error. - pub fn communication_error(&self) -> Option<&ServerFnErrorErr> { - match self { - ServerFnError::ServerError(_) => None, - ServerFnError::CommunicationError(err) => Some(err), - } - } -} - -impl FromServerFnError for ServerFnError { - type Encoder = JsonEncoding; - - fn from_server_fn_error(err: ServerFnErrorErr) -> Self { - Self::CommunicationError(err) - } -} - -impl FromStr for ServerFnError { - type Err = ::Err; - - fn from_str(s: &str) -> std::result::Result { - std::result::Result::Ok(Self::ServerError(T::from_str(s)?)) - } -} - -impl Display for ServerFnError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ServerFnError::ServerError(err) => write!(f, "Server error: {err}"), - ServerFnError::CommunicationError(err) => write!(f, "Communication error: {err}"), - } - } -} - -impl From for CapturedError { - fn from(error: ServerFnError) -> Self { - Self::from_display(error) - } -} - -impl From for RenderError { - fn from(error: ServerFnError) -> Self { - RenderError::Aborted(CapturedError::from(error)) - } -} - -impl From for ServerFnError { - fn from(error: E) -> Self { - Self::ServerError(error.to_string()) - } -} diff --git a/packages/fullstack/src/lazy.rs b/packages/fullstack/src/lazy.rs new file mode 100644 index 000000000..225c57b66 --- /dev/null +++ b/packages/fullstack/src/lazy.rs @@ -0,0 +1,141 @@ +#![allow(clippy::needless_return)] + +use std::{hint::black_box, prelude::rust_2024::Future, sync::atomic::AtomicBool}; + +/// `Lazy` is a thread-safe, lazily-initialized global variable. +/// +/// Unlike other async once-cell implementations, accessing the value of a `Lazy` instance is synchronous +/// and done on `deref`. +/// +/// This is done by offloading the async initialization to a blocking thread during the first access, +/// and then using the initialized value for all subsequent accesses. +/// +/// It uses `std::sync::OnceLock` internally to ensure that the value is only initialized once. +pub struct Lazy { + value: std::sync::OnceLock, + started_initialization: AtomicBool, + constructor: Option Result>, + _phantom: std::marker::PhantomData, +} + +impl Lazy { + /// Create a new `Lazy` instance. + /// + /// This internally calls `std::sync::OnceLock::new()` under the hood. + #[allow(clippy::self_named_constructors)] + pub const fn lazy() -> Self { + Self { + _phantom: std::marker::PhantomData, + constructor: None, + started_initialization: AtomicBool::new(false), + value: std::sync::OnceLock::new(), + } + } + + pub const fn new(constructor: F) -> Self + where + F: Fn() -> G + Copy, + G: Future> + Send + 'static, + E: Into, + { + if std::mem::size_of::() != 0 { + panic!("The constructor function must be a zero-sized type (ZST). Consider using a function pointer or a closure without captured variables."); + } + + // Prevent the constructor from being optimized out + black_box(constructor); + + Self { + _phantom: std::marker::PhantomData, + value: std::sync::OnceLock::new(), + started_initialization: AtomicBool::new(false), + constructor: Some(blocking_initialize::), + } + } + + /// Set the value of the `Lazy` instance. + /// + /// This should only be called once during the server setup phase, typically inside `dioxus::serve`. + /// Future calls to this method will return an error containing the provided value. + pub fn set(&self, pool: T) -> Result<(), dioxus_core::Error> { + let res = self.value.set(pool); + if res.is_err() { + return Err(anyhow::anyhow!("Lazy value is already initialized.")); + } + + Ok(()) + } + + pub fn try_set(&self, pool: T) -> Result<(), T> { + self.value.set(pool) + } + + /// Initialize the value of the `Lazy` instance if it hasn't been initialized yet. + pub fn initialize(&self) -> Result<(), dioxus_core::Error> { + if let Some(constructor) = self.constructor { + // If we're already initializing this value, wait on the receiver. + if self + .started_initialization + .swap(true, std::sync::atomic::Ordering::SeqCst) + { + self.value.wait(); + return Ok(()); + } + + // Otherwise, we need to initialize the value + self.set(constructor().unwrap())?; + } + Ok(()) + } +} + +impl Default for Lazy { + fn default() -> Self { + Self::lazy() + } +} + +impl std::ops::Deref for Lazy { + type Target = T; + + fn deref(&self) -> &Self::Target { + if self.constructor.is_none() { + return self.value.get().expect("Lazy value is not initialized. Make sure to call `initialize` before dereferencing."); + }; + + if self.value.get().is_none() { + self.initialize().expect("Failed to initialize lazy value"); + } + + self.value.get().unwrap() + } +} + +/// This is a small hack that allows us to staple the async initialization into a blocking context. +/// +/// We call the `rust-call` method of the zero-sized constructor function. This is safe because we're +/// not actually dereferencing any unsafe data, just calling its vtable entry to get the future. +fn blocking_initialize() -> Result +where + T: Send + Sync + 'static, + F: Fn() -> G + Copy, + G: Future> + Send + 'static, + E: Into, +{ + assert_eq!(std::mem::size_of::(), 0, "The constructor function must be a zero-sized type (ZST). Consider using a function pointer or a closure without captured variables."); + + #[cfg(feature = "server")] + { + let ptr: F = unsafe { std::mem::zeroed() }; + let fut = ptr(); + let rt = tokio::runtime::Handle::current(); + return std::thread::spawn(move || rt.block_on(fut).map_err(|e| e.into())) + .join() + .unwrap(); + } + + // todo: technically we can support constructors in wasm with the same tricks inventory uses with `__wasm_call_ctors` + // the host would need to decide when to cal the ctors and when to block them. + #[cfg(not(feature = "server"))] + unimplemented!("Lazy initialization is only supported with tokio and threads enabled.") +} diff --git a/packages/fullstack/src/lib.rs b/packages/fullstack/src/lib.rs index 47f56854a..613d83272 100644 --- a/packages/fullstack/src/lib.rs +++ b/packages/fullstack/src/lib.rs @@ -1,28 +1,111 @@ -#![doc = include_str!("../README.md")] -#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")] -#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] -#![warn(missing_docs)] -#![cfg_attr(docsrs, feature(doc_cfg))] +// #![warn(missing_docs)] +#![allow(clippy::manual_async_fn)] +#![allow(clippy::needless_return)] + +pub use client::{get_server_url, set_server_url}; +pub use dioxus_fullstack_core::*; + +#[doc(inline)] +pub use dioxus_fullstack_macro::*; + +pub use axum_core; +pub use headers; +pub use http; +pub use reqwest; +pub use serde; + +/// Re-export commonly used items from axum, http, and hyper for convenience. +pub use axum::{body, extract, response, routing}; -mod error; #[doc(hidden)] -pub mod mock_client; +pub use const_format; +#[doc(hidden)] +pub use const_str; +#[doc(hidden)] +pub use xxhash_rust; -pub use dioxus_fullstack_hooks::history::provide_fullstack_history_context; - -pub use crate::error::{ServerFnError, ServerFnResult}; -#[doc(inline)] -pub use dioxus_fullstack_hooks::*; #[cfg(feature = "server")] -#[doc(inline)] -pub use dioxus_server::*; -#[doc(inline)] -pub use dioxus_server_macro::*; -pub use server_fn::ServerFn as _; -#[doc(inline)] -pub use server_fn::{ - self, client, - client::{get_server_url, set_server_url}, - codec, server, BoxedStream, ContentType, Decodes, Encodes, Format, FormatType, ServerFn, - Websocket, -}; +pub use {axum, axum_extra::TypedHeader, inventory}; + +#[cfg(feature = "server")] +pub(crate) mod spawn; +#[cfg(feature = "server")] +pub(crate) use spawn::*; + +pub mod magic; +pub use magic::*; + +pub mod request; +pub use request::*; + +pub use http::StatusCode; + +pub mod encoding; +pub use encoding::*; + +pub mod lazy; +pub use lazy::*; + +pub use http::{HeaderMap, HeaderValue, Method}; + +mod client; +pub use client::*; + +pub use axum::extract::Json; + +pub use payloads::*; +pub mod payloads { + use crate::{ClientRequest, ClientResponse, ClientResult, IntoRequest}; + use crate::{FromResponse, FromResponseParts}; + use axum::extract::FromRequest; + use axum::response::{IntoResponse, IntoResponseParts, ResponseParts}; + use bytes::Bytes; + use dioxus_fullstack_core::ServerFnError; + use futures::Stream; + use headers::Header; + use http::{header::InvalidHeaderValue, HeaderValue}; + use serde::{de::DeserializeOwned, Serialize}; + use std::future::Future; + + mod axum_types; + + pub mod cbor; + pub use cbor::*; + + pub mod form; + pub use form::*; + + pub mod multipart; + pub use multipart::*; + + #[cfg(feature = "postcard")] + pub mod postcard; + + #[cfg(feature = "postcard")] + pub use postcard::*; + + #[cfg(feature = "msgpack")] + pub mod msgpack; + #[cfg(feature = "msgpack")] + pub use msgpack::*; + + pub mod text; + pub use text::*; + + pub mod sse; + pub use sse::*; + + pub mod stream; + pub use stream::*; + + pub mod files; + pub use files::*; + + pub mod header; + pub use header::*; + + #[cfg(feature = "ws")] + pub mod websocket; + #[cfg(feature = "ws")] + pub use websocket::*; +} diff --git a/packages/fullstack/src/magic.rs b/packages/fullstack/src/magic.rs new file mode 100644 index 000000000..bcc187ea6 --- /dev/null +++ b/packages/fullstack/src/magic.rs @@ -0,0 +1,753 @@ +//! ServerFn request magical 🧙 decoders and encoders. +//! +//! The Dioxus Server Function implementation brings a lot of *magic* to the types of endpoints we can handle. +//! Our ultimate goal is to handle *all* endpoints, even axum endpoints, with the macro. +//! +//! Unfortunately, some axum traits like `FromRequest` overlap with some of the default magic we want +//! to provide, like allowing DeserializedOwned groups. +//! +//! Our ultimate goal - to accept all axum handlers - is feasible but not fully implemented. +//! +//! Broadly, we support the following categories of handlers arguments: +//! - Handlers with a single argument that implements `FromRequest` + `IntoRequest` +//! - Handlers with multiple arguments that implement *all* `DeserializeOwned` (and thus can be deserialized from a JSON body) +//! +//! The handler error return types we support are: +//! - `Result where E: From + Serialize + DeserializeOwned` (basically any custom `thiserror` impl) +//! - `Result` where we transport the error as a string and/or through ServerFnError +//! +//! The handler return types we support are: +//! - `T where T: FromResponse` +//! - `T where T: DeserializeOwned` +//! +//! Note that FromResponse and IntoRequest are *custom* traits defined in this crate. The intention +//! is to provide "inverse" traits of the axum traits, allowing types to flow seamlessly between client and server. +//! +//! These are unfortunately in conflict with the serialization traits. Types like `Bytes` implement both +//! IntoResponse and Serialize, so what should you use? +//! +//! This module implements auto-deref specialization to allow tiering of the above cases. +//! +//! This is sadly quite "magical", but it works. Because the FromResponse traits are defined in this crate, +//! they are sealed against types that implement Deserialize/Serialize, meaning you cannot implement +//! FromResponse for a type that implements Serialize. +//! +//! This module is broken up into several parts, attempting to match how the server macro generates code: +//! - ReqwestEncoder: encodes a set of arguments into a reqwest request + +use crate::{ + CantEncode, ClientRequest, ClientResponse, EncodeIsVerified, FromResponse, HttpError, + IntoRequest, ServerFnError, +}; +use axum::response::IntoResponse; +use axum_core::extract::{FromRequest, Request}; +use bytes::Bytes; +use dioxus_fullstack_core::{DioxusServerState, RequestError}; +use http::StatusCode; +use send_wrapper::SendWrapper; +use serde::Serialize; +use serde::{de::DeserializeOwned, Deserialize}; +use std::fmt::Display; +use std::{marker::PhantomData, prelude::rust_2024::Future}; + +#[doc(hidden)] +pub struct ServerFnEncoder(PhantomData (In, Out)>); +impl ServerFnEncoder { + #[doc(hidden)] + pub fn new() -> Self { + ServerFnEncoder(PhantomData) + } +} + +#[doc(hidden)] +pub struct ServerFnDecoder(PhantomData Out>); +impl ServerFnDecoder { + #[doc(hidden)] + pub fn new() -> Self { + ServerFnDecoder(PhantomData) + } +} + +/// A response structure for a regular REST API, with a success and error case where the status is +/// encoded in the body and all fields are serializable. This lets you call fetch().await.json() +/// and get a strongly typed result. +/// +/// Eventually we want to support JsonRPC which requires a different format. +/// +/// We use the `___status` field to avoid conflicts with user-defined fields. Hopefully no one uses this field name! +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum RestEndpointPayload { + #[serde(rename = "success")] + Success(T), + + #[serde(rename = "error")] + Error(ErrorPayload), +} + +/// The error payload structure for REST API errors. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct ErrorPayload { + message: String, + + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +/// Convert a `RequestError` into a `ServerFnError`. +/// +/// This is a separate function to avoid bringing in `reqwest` into fullstack-core. +pub fn reqwest_response_to_serverfn_err(err: reqwest::Error) -> ServerFnError { + ServerFnError::Request(reqwest_error_to_request_error(err)) +} + +pub fn reqwest_error_to_request_error(err: reqwest::Error) -> RequestError { + let message = err.to_string(); + if err.is_timeout() { + RequestError::Timeout(message) + } else if err.is_request() { + RequestError::Request(message) + } else if err.is_body() { + RequestError::Body(message) + } else if err.is_decode() { + RequestError::Decode(message) + } else if err.is_redirect() { + RequestError::Redirect(message) + } else if let Some(status) = err.status() { + RequestError::Status(message, status.as_u16()) + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if err.is_connect() { + RequestError::Connect(message) + } else { + RequestError::Request(message) + } + } + + #[cfg(target_arch = "wasm32")] + { + RequestError::Request(message) + } + } +} + +pub use req_to::*; +pub mod req_to { + use super::*; + + pub trait EncodeRequest { + type VerifyEncode; + fn fetch_client( + &self, + ctx: ClientRequest, + data: In, + map: fn(In) -> Out, + ) -> impl Future> + 'static; + fn verify_can_serialize(&self) -> Self::VerifyEncode; + } + + /// Using the deserialize path + impl EncodeRequest for &&&&&&&&&&ServerFnEncoder + where + T: DeserializeOwned + Serialize + 'static, + { + type VerifyEncode = EncodeIsVerified; + fn fetch_client( + &self, + ctx: ClientRequest, + data: T, + _map: fn(T) -> O, + ) -> impl Future> + 'static { + async move { ctx.send_json(&data).await } + } + + fn verify_can_serialize(&self) -> Self::VerifyEncode { + EncodeIsVerified + } + } + + /// When we use the FromRequest path, we don't need to deserialize the input type on the client, + impl EncodeRequest for &&&&&&&&&ServerFnEncoder + where + T: 'static, + O: FromRequest + IntoRequest, + { + type VerifyEncode = EncodeIsVerified; + fn fetch_client( + &self, + ctx: ClientRequest, + data: T, + map: fn(T) -> O, + ) -> impl Future> + 'static { + O::into_request(map(data), ctx) + } + + fn verify_can_serialize(&self) -> Self::VerifyEncode { + EncodeIsVerified + } + } + + /// The fall-through case that emits a `CantEncode` type which fails to compile when checked by the macro + impl EncodeRequest for &ServerFnEncoder + where + T: 'static, + { + type VerifyEncode = CantEncode; + #[allow(clippy::manual_async_fn)] + fn fetch_client( + &self, + _ctx: ClientRequest, + _data: T, + _map: fn(T) -> O, + ) -> impl Future> + 'static { + async move { unimplemented!() } + } + + fn verify_can_serialize(&self) -> Self::VerifyEncode { + CantEncode + } + } +} + +pub use decode_ok::*; +mod decode_ok { + + use super::*; + + /// Convert the reqwest response into the desired type, in place. + /// The point here is to prefer FromResponse types *first* and then DeserializeOwned types second. + /// + /// This is because FromResponse types are more specialized and can handle things like websockets and files. + /// DeserializeOwned types are more general and can handle things like JSON responses. + pub trait RequestDecodeResult { + fn decode_client_response( + &self, + res: Result, + ) -> impl Future, RequestError>> + Send; + } + + impl, E, R> RequestDecodeResult for &&&ServerFnDecoder> { + fn decode_client_response( + &self, + res: Result, + ) -> impl Future, RequestError>> + Send { + SendWrapper::new(async move { + match res { + Err(err) => Err(err), + Ok(res) => Ok(T::from_response(res).await), + } + }) + } + } + + impl RequestDecodeResult + for &&ServerFnDecoder> + { + fn decode_client_response( + &self, + res: Result, + ) -> impl Future, RequestError>> + Send { + SendWrapper::new(async move { + match res { + Err(err) => Err(err), + Ok(res) => { + let status = res.status(); + + let bytes = res.bytes().await.unwrap(); + let as_bytes = if bytes.is_empty() { + b"null".as_slice() + } else { + &bytes + }; + + let res = if status.is_success() { + serde_json::from_slice::(as_bytes) + .map(RestEndpointPayload::Success) + .map_err(|e| ServerFnError::Deserialization(e.to_string())) + } else { + match serde_json::from_slice::>( + as_bytes, + ) { + Ok(res) => Ok(RestEndpointPayload::Error(ErrorPayload { + message: res.message, + code: res.code, + data: res.data, + })), + Err(err) => { + if let Ok(text) = String::from_utf8(as_bytes.to_vec()) { + Ok(RestEndpointPayload::Error(ErrorPayload { + message: format!("HTTP {}: {}", status.as_u16(), text), + code: Some(status.as_u16()), + data: None, + })) + } else { + Err(ServerFnError::Deserialization(err.to_string())) + } + } + } + }; + + match res { + Ok(RestEndpointPayload::Success(t)) => Ok(Ok(t)), + Ok(RestEndpointPayload::Error(err)) => { + Ok(Err(ServerFnError::ServerError { + message: err.message, + details: err.data, + code: err.code, + })) + } + Err(e) => Ok(Err(e)), + } + } + } + }) + } + } + + pub trait RequestDecodeErr { + fn decode_client_err( + &self, + res: Result, RequestError>, + ) -> impl Future> + Send; + } + + impl RequestDecodeErr for &&&ServerFnDecoder> + where + E: From + DeserializeOwned + Serialize, + { + fn decode_client_err( + &self, + res: Result, RequestError>, + ) -> impl Future> + Send { + SendWrapper::new(async move { + match res { + Ok(Ok(res)) => Ok(res), + Ok(Err(e)) => match e { + ServerFnError::ServerError { + details, + message, + code, + } => { + // If there are "details", then we try to deserialize them into the error type. + // If there aren't, we just create a generic ServerFnError::ServerError with the message. + match details { + Some(details) => match serde_json::from_value::(details) { + Ok(res) => Err(res), + Err(err) => Err(E::from(ServerFnError::Deserialization( + err.to_string(), + ))), + }, + None => Err(E::from(ServerFnError::ServerError { + message, + details: None, + code, + })), + } + } + err => Err(err.into()), + }, + // todo: implement proper through-error conversion, instead of just ServerFnError::Request + // we should expand these cases. + Err(err) => Err(ServerFnError::from(err).into()), + } + }) + } + } + + /// Here we convert to ServerFnError and then into the anyhow::Error, letting the user downcast + /// from the ServerFnError if they want to. + /// + /// This loses any actual type information, but is the most flexible for users. + impl RequestDecodeErr for &&ServerFnDecoder> { + fn decode_client_err( + &self, + res: Result, RequestError>, + ) -> impl Future> + Send { + SendWrapper::new(async move { + match res { + Ok(Ok(res)) => Ok(res), + Ok(Err(e)) => Err(anyhow::Error::from(e)), + Err(err) => Err(anyhow::Error::from(err)), + } + }) + } + } + + /// This converts to statuscode, which can be useful but loses a lot of information. + impl RequestDecodeErr for &ServerFnDecoder> { + fn decode_client_err( + &self, + res: Result, RequestError>, + ) -> impl Future> + Send { + SendWrapper::new(async move { + match res { + Ok(Ok(res)) => Ok(res), + + // We do a best-effort conversion from ServerFnError to StatusCode. + Ok(Err(e)) => match e { + ServerFnError::Request(error) => { + Err(StatusCode::from_u16(error.status_code().unwrap_or(500)) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)) + } + + ServerFnError::ServerError { + message: _message, + details: _details, + code, + } => Err(StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)), + + ServerFnError::Registration(_) | ServerFnError::MiddlewareError(_) => { + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + + ServerFnError::Deserialization(_) + | ServerFnError::Serialization(_) + | ServerFnError::Args(_) + | ServerFnError::MissingArg(_) + | ServerFnError::StreamError(_) => Err(StatusCode::UNPROCESSABLE_ENTITY), + + ServerFnError::UnsupportedRequestMethod(_) => { + Err(StatusCode::METHOD_NOT_ALLOWED) + } + + ServerFnError::Response(_) => Err(StatusCode::INTERNAL_SERVER_ERROR), + }, + + // The reqwest error case, we try to convert the reqwest error into a status code. + Err(reqwest_err) => { + let code = reqwest_err + .status() + .unwrap_or(StatusCode::SERVICE_UNAVAILABLE); + Err(code) + } + } + }) + } + } + + impl RequestDecodeErr for &ServerFnDecoder> { + fn decode_client_err( + &self, + res: Result, RequestError>, + ) -> impl Future> + Send { + SendWrapper::new(async move { + match res { + Ok(Ok(res)) => Ok(res), + Ok(Err(res)) => match res { + ServerFnError::ServerError { message, code, .. } => Err(HttpError { + status: StatusCode::from_u16(code.unwrap_or(500)) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + message: Some(message), + }), + _ => HttpError::internal_server_error("Internal Server Error"), + }, + Err(err) => Err(HttpError::new( + err.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + err.to_string(), + )), + } + }) + } + } +} + +pub use req_from::*; +pub mod req_from { + use super::*; + use axum::{extract::FromRequestParts, response::Response}; + + pub trait ExtractRequest { + fn extract_axum( + &self, + state: DioxusServerState, + request: Request, + map: fn(In) -> Out, + ) -> impl Future> + 'static; + } + + /// When you're extracting entirely on the server, we need to reject client-consuning request bodies + /// This sits above priority in the combined headers on server / body on client case. + impl ExtractRequest for &&&&&&&&&&&ServerFnEncoder + where + H: FromRequest + 'static, + { + fn extract_axum( + &self, + state: DioxusServerState, + request: Request, + _map: fn(In) -> (), + ) -> impl Future> + 'static { + async move { + H::from_request(request, &state) + .await + .map_err(|e| e.into_response()) + .map(|out| (out, ())) + } + } + } + + // One-arg case + impl ExtractRequest for &&&&&&&&&&ServerFnEncoder + where + In: DeserializeOwned + 'static, + Out: 'static, + H: FromRequestParts, + { + fn extract_axum( + &self, + _state: DioxusServerState, + request: Request, + map: fn(In) -> Out, + ) -> impl Future> + 'static { + async move { + let (mut parts, body) = request.into_parts(); + let headers = H::from_request_parts(&mut parts, &_state) + .await + .map_err(|e| e.into_response())?; + + let request = Request::from_parts(parts, body); + let bytes = Bytes::from_request(request, &()).await.unwrap(); + let as_str = String::from_utf8_lossy(&bytes); + + let bytes = if as_str.is_empty() { + "{}".as_bytes() + } else { + &bytes + }; + + let out = serde_json::from_slice::(bytes) + .map(map) + .map_err(|e| ServerFnError::from(e).into_response()) + .unwrap(); + + Ok((headers, out)) + } + } + } + + /// We skip the BodySerialize wrapper and just go for the output type directly. + impl ExtractRequest for &&&&&&&&&ServerFnEncoder + where + Out: FromRequest + 'static, + H: FromRequestParts, + { + fn extract_axum( + &self, + state: DioxusServerState, + request: Request, + _map: fn(In) -> Out, + ) -> impl Future> + 'static { + async move { + let (mut parts, body) = request.into_parts(); + let headers = H::from_request_parts(&mut parts, &state) + .await + .map_err(|e| e.into_response())?; + + let request = Request::from_parts(parts, body); + + let res = Out::from_request(request, &state) + .await + .map_err(|e| e.into_response()); + + res.map(|out| (headers, out)) + } + } + } +} + +pub use resp::*; +mod resp { + use crate::HttpError; + + use super::*; + use axum::response::Response; + use http::HeaderValue; + + /// A trait for converting the result of the Server Function into an Axum response. + /// + /// This is to work around the issue where we want to return both Deserialize types and FromResponse types. + /// Stuff like websockets + /// + /// We currently have an `Input` type even though it's not useful since we might want to support regular axum endpoints later. + /// For now, it's just Result where T is either DeserializeOwned or FromResponse + pub trait MakeAxumResponse { + fn make_axum_response(self, result: Result) -> Result; + } + + // Higher priority impl for special types like websocket/file responses that generate their own responses + // The FromResponse impl helps narrow types to those usable on the client + impl MakeAxumResponse for &&&&ServerFnDecoder> + where + T: FromResponse + IntoResponse, + { + fn make_axum_response(self, result: Result) -> Result { + result.map(|v| v.into_response()) + } + } + + // Lower priority impl for regular serializable types + // We try to match the encoding from the incoming request, otherwise default to JSON + impl MakeAxumResponse for &&&ServerFnDecoder> + where + T: DeserializeOwned + Serialize, + { + fn make_axum_response(self, result: Result) -> Result { + match result { + Ok(res) => { + let body = serde_json::to_string(&res).unwrap(); + let mut resp = Response::new(body.into()); + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *resp.status_mut() = StatusCode::OK; + Ok(resp) + } + Err(err) => Err(err), + } + } + } + + #[allow(clippy::result_large_err)] + pub trait MakeAxumError { + fn make_axum_error(self, result: Result) -> Result; + } + + impl MakeAxumError for &&&ServerFnDecoder> + where + E: From + Serialize + DeserializeOwned + Display, + { + fn make_axum_error(self, result: Result) -> Result { + match result { + Ok(res) => Ok(res), + Err(err) => { + let err = ErrorPayload { + code: None, + message: err.to_string(), + data: Some(err), + }; + let body = serde_json::to_string(&err).unwrap(); + let mut resp = Response::new(body.into()); + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + Err(resp) + } + } + } + } + + impl MakeAxumError for &&ServerFnDecoder> { + fn make_axum_error( + self, + result: Result, + ) -> Result { + match result { + Ok(res) => Ok(res), + Err(errr) => { + // The `WithHttpError` trait emits ServerFnErrors so we can downcast them here + // to create richer responses. + let payload = match errr.downcast::() { + Ok(ServerFnError::ServerError { + message, + code, + details, + }) => ErrorPayload { + message, + code, + data: details, + }, + Ok(other) => ErrorPayload { + message: other.to_string(), + code: None, + data: None, + }, + Err(err) => match err.downcast::() { + Ok(http_err) => ErrorPayload { + message: http_err + .message + .unwrap_or_else(|| http_err.status.to_string()), + code: Some(http_err.status.as_u16()), + data: None, + }, + Err(err) => ErrorPayload { + code: None, + message: err.to_string(), + data: None, + }, + }, + }; + + let body = serde_json::to_string(&payload).unwrap(); + let mut resp = Response::new(body.into()); + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *resp.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; + Err(resp) + } + } + } + } + + impl MakeAxumError for &&ServerFnDecoder> { + fn make_axum_error( + self, + result: Result, + ) -> Result { + match result { + Ok(resp) => Ok(resp), + Err(status) => { + let body = serde_json::to_string(&ErrorPayload::<()> { + code: Some(status.as_u16()), + message: status.to_string(), + data: None, + }) + .unwrap(); + let mut resp = Response::new(body.into()); + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *resp.status_mut() = status; + Err(resp) + } + } + } + } + + impl MakeAxumError for &ServerFnDecoder> { + fn make_axum_error( + self, + result: Result, + ) -> Result { + match result { + Ok(resp) => Ok(resp), + Err(http_err) => { + let body = serde_json::to_string(&ErrorPayload::<()> { + code: Some(http_err.status.as_u16()), + message: http_err + .message + .unwrap_or_else(|| http_err.status.to_string()), + data: None, + }) + .unwrap(); + let mut resp = Response::new(body.into()); + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + *resp.status_mut() = http_err.status; + Err(resp) + } + } + } + } +} diff --git a/packages/fullstack/src/mock_client.rs b/packages/fullstack/src/mock_client.rs deleted file mode 100644 index baeed189d..000000000 --- a/packages/fullstack/src/mock_client.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! A mock [`server_fn::client::Client`] implementation used when no client feature is enabled. - -use std::future::Future; - -use futures_util::Stream; -use server_fn::{request::ClientReq, response::ClientRes}; - -/// A placeholder [`server_fn::client::Client`] used when no client feature is enabled. The -/// [`server_fn::client::browser::BrowserClient`] is used on web clients, and [`server_fn::client::reqwest::ReqwestClient`] -/// is used on native clients -#[non_exhaustive] -pub struct MockServerFnClient {} - -impl Default for MockServerFnClient { - fn default() -> Self { - Self::new() - } -} - -impl MockServerFnClient { - /// Create a new mock server function client - pub fn new() -> Self { - Self {} - } -} - -impl - server_fn::client::Client for MockServerFnClient -{ - type Request = MockServerFnClientRequest; - - type Response = MockServerFnClientResponse; - - async fn send(_: Self::Request) -> Result { - unimplemented!() - } - - #[allow(unreachable_code)] - async fn open_websocket( - _: &str, - ) -> Result< - ( - impl Stream> + std::marker::Send + 'static, - impl futures_util::Sink + std::marker::Send + 'static, - ), - Error, - > { - unimplemented!() - as Result< - ( - futures_util::stream::Once>, - futures_util::sink::Drain, - ), - _, - > - } - - fn spawn(_: impl Future + Send + 'static) { - unimplemented!() - } -} - -/// A placeholder [`ClientReq`] used when no client feature is enabled. -#[non_exhaustive] -pub struct MockServerFnClientRequest {} - -impl ClientReq for MockServerFnClientRequest { - type FormData = (); - - fn try_new_req_query(_: &str, _: &str, _: &str, _: &str, _: http::Method) -> Result { - unimplemented!() - } - - fn try_new_req_text(_: &str, _: &str, _: &str, _: String, _: http::Method) -> Result { - unimplemented!() - } - - fn try_new_req_bytes( - _: &str, - _: &str, - _: &str, - _: bytes::Bytes, - _: http::Method, - ) -> Result { - unimplemented!() - } - - fn try_new_req_form_data( - _: &str, - _: &str, - _: &str, - _: Self::FormData, - _: http::Method, - ) -> Result { - unimplemented!() - } - - fn try_new_req_multipart( - _: &str, - _: &str, - _: Self::FormData, - _: http::Method, - ) -> Result { - unimplemented!() - } - - fn try_new_req_streaming( - _: &str, - _: &str, - _: &str, - _: impl Stream + Send + 'static, - _: http::Method, - ) -> Result { - unimplemented!() - } -} - -/// A placeholder [`ClientRes`] used when no client feature is enabled. -pub struct MockServerFnClientResponse; - -impl ClientRes for MockServerFnClientResponse { - async fn try_into_string(self) -> Result { - unimplemented!() - } - - async fn try_into_bytes(self) -> Result { - unimplemented!() - } - - #[allow(unreachable_code)] - fn try_into_stream( - self, - ) -> Result> + Send + Sync + 'static, E> - { - unimplemented!() as Result>, _> - } - - fn status(&self) -> u16 { - unimplemented!() - } - - fn status_text(&self) -> String { - unimplemented!() - } - - fn location(&self) -> String { - unimplemented!() - } - - fn has_redirect(&self) -> bool { - unimplemented!() - } -} diff --git a/packages/fullstack/src/payloads/axum_types.rs b/packages/fullstack/src/payloads/axum_types.rs new file mode 100644 index 000000000..6500e1aba --- /dev/null +++ b/packages/fullstack/src/payloads/axum_types.rs @@ -0,0 +1,77 @@ +use super::*; +use crate::{ClientResponse, FromResponse}; +pub use axum::extract::Json; +use axum::response::Html; +use dioxus_fullstack_core::{RequestError, ServerFnError}; +use futures::StreamExt; +use std::future::Future; + +impl> FromResponse for Html { + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let content = res.text().await?; + Ok(Html(content.into())) + } + } +} + +impl IntoRequest for Json +where + T: Serialize + 'static, +{ + fn into_request(self, request: ClientRequest) -> impl Future + 'static { + async move { request.send_json(&self.0).await } + } +} + +impl FromResponse for Json { + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let data = res.json::().await?; + Ok(Json(data)) + } + } +} + +/// Implementation of `FromResponse` for `axum::response::Response`. +/// +/// This allows converting a `ClientResponse` (from a client-side HTTP request) +/// into an `axum::Response` for server-side handling. The response's status, +/// headers, and body are transferred from the client response to the axum response. +impl FromResponse for axum::response::Response { + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let parts = res.make_parts(); + let body = axum::body::Body::from_stream(res.bytes_stream()); + let response = axum::response::Response::from_parts(parts, body); + Ok(response) + } + } +} + +/// Implementation of `IntoRequest` for `axum::extract::Request`. +/// +/// This allows converting an `axum::Request` (from server-side extraction) +/// into a `ClientRequest` that can be sent as an HTTP request. The request's +/// headers and body are transferred from the axum request to the client request. +impl IntoRequest for axum::extract::Request { + fn into_request( + self, + mut request: ClientRequest, + ) -> impl Future> + 'static { + async move { + let (parts, body) = self.into_parts(); + + for (key, value) in &parts.headers { + request = request.header(key, value)?; + } + + request + .send_body_stream( + body.into_data_stream() + .map(|res| res.map_err(|_| StreamingError::Failed)), + ) + .await + } + } +} diff --git a/packages/fullstack/src/payloads/cbor.rs b/packages/fullstack/src/payloads/cbor.rs new file mode 100644 index 000000000..62d0b50d1 --- /dev/null +++ b/packages/fullstack/src/payloads/cbor.rs @@ -0,0 +1,160 @@ +use axum::{ + body::Bytes, + extract::{rejection::BytesRejection, FromRequest, Request}, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::{de::DeserializeOwned, Serialize}; + +/// CBOR Extractor / Response. +/// +/// When used as an extractor, it can deserialize request bodies into some type that +/// implements [`serde::Deserialize`]. The request will be rejected (and a [`CborRejection`] will +/// be returned) if: +/// +/// - The request doesn't have a `Content-Type: application/cbor` (or similar) header. +/// - The body doesn't contain syntactically valid CBOR. +/// - The body contains syntactically valid CBOR but it couldn't be deserialized into the target type. +/// - Buffering the request body fails. +/// +/// ⚠️ Since parsing CBOR requires consuming the request body, the `Cbor` extractor must be +/// *last* if there are multiple extractors in a handler. +/// See ["the order of extractors"][order-of-extractors] +/// +/// [order-of-extractors]: crate::extract#the-order-of-extractors +#[must_use] +pub struct Cbor(pub T); + +/// Check if the request has a valid CBOR content type header. +/// +/// This function validates that the `Content-Type` header is set to `application/cbor` +/// or a compatible CBOR media type (including subtypes with `+cbor` suffix). +fn is_valid_cbor_content_type(headers: &HeaderMap) -> bool { + let Some(content_type) = headers.get(header::CONTENT_TYPE) else { + return false; + }; + + let Ok(content_type) = content_type.to_str() else { + return false; + }; + + let Ok(mime) = content_type.parse::() else { + return false; + }; + + let is_cbor_content_type = mime.type_() == "application" + && (mime.subtype() == "cbor" || mime.suffix().is_some_and(|name| name == "cbor")); + + is_cbor_content_type +} + +impl FromRequest for Cbor +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = CborRejection; + + /// Extract a CBOR payload from the request body. + /// + /// This implementation validates the content type and deserializes the CBOR data. + /// Returns a `CborRejection` if validation or deserialization fails. + async fn from_request(req: Request, state: &S) -> Result { + if !is_valid_cbor_content_type(req.headers()) { + return Err(CborRejection::MissingCborContentType); + } + let bytes = Bytes::from_request(req, state).await?; + let value = + ciborium::from_reader(&bytes as &[u8]).map_err(|_| CborRejection::FailedToParseCbor)?; + Ok(Cbor(value)) + } +} + +impl IntoResponse for Cbor +where + T: Serialize, +{ + /// Convert the CBOR payload into an HTTP response. + /// + /// This serializes the inner value to CBOR format and sets the appropriate + /// `Content-Type: application/cbor` header. Returns a 500 Internal Server Error + /// if serialization fails. + fn into_response(self) -> Response { + let mut buf = Vec::new(); + match ciborium::into_writer(&self.0, &mut buf) { + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + [( + header::CONTENT_TYPE, + HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()), + )], + "Failed to serialize to CBOR".to_string(), + ) + .into_response(), + Ok(()) => ( + [( + header::CONTENT_TYPE, + HeaderValue::from_static("application/cbor"), + )], + buf, + ) + .into_response(), + } + } +} + +impl From for Cbor { + /// Create a `Cbor` from the inner value. + /// + /// This is a convenience constructor that wraps any value in the `Cbor` struct. + fn from(inner: T) -> Self { + Self(inner) + } +} + +impl Cbor +where + T: DeserializeOwned, +{ + /// Construct a `Cbor` from a byte slice. + /// + /// This method attempts to deserialize the provided bytes as CBOR data. + /// Returns a `CborRejection` if deserialization fails. + pub fn from_bytes(bytes: &[u8]) -> Result { + ciborium::de::from_reader(bytes) + .map(Cbor) + .map_err(|_| CborRejection::FailedToParseCbor) + } +} + +/// Rejection type for CBOR extraction failures. +/// +/// This enum represents the various ways that CBOR extraction can fail. +/// It implements `IntoResponse` to provide appropriate HTTP responses for each error type. +#[derive(thiserror::Error, Debug)] +pub enum CborRejection { + /// The request is missing the required `Content-Type: application/cbor` header. + #[error("Expected request with `Content-Type: application/cbor`")] + MissingCborContentType, + + /// Failed to parse the request body as valid CBOR. + #[error("Invalid CBOR data")] + FailedToParseCbor, + + /// Failed to read the request body bytes. + #[error(transparent)] + BytesRejection(#[from] BytesRejection), +} + +impl IntoResponse for CborRejection { + fn into_response(self) -> Response { + use CborRejection::*; + match self { + MissingCborContentType => { + (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()).into_response() + } + FailedToParseCbor => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), + BytesRejection(rejection) => rejection.into_response(), + } + } +} diff --git a/packages/fullstack/src/payloads/files.rs b/packages/fullstack/src/payloads/files.rs new file mode 100644 index 000000000..85964b07c --- /dev/null +++ b/packages/fullstack/src/payloads/files.rs @@ -0,0 +1,371 @@ +use super::*; +use axum_core::extract::Request; +use dioxus_fullstack_core::RequestError; +use dioxus_html::FileData; + +#[cfg(feature = "server")] +use std::path::Path; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +/// A payload for uploading files using streams. +/// +/// The `FileUpload` struct allows you to upload files by streaming their data. It can be constructed +/// from a stream of bytes and can be sent as part of an HTTP request. This is particularly useful for +/// handling large files without loading them entirely into memory. +/// +/// On the web, this uses the `ReadableStream` API to stream file data. +pub struct FileStream { + data: Option, + name: String, + size: Option, + content_type: Option, + #[cfg(feature = "server")] + server_body: Option, + + // For downloaded files... + #[allow(clippy::type_complexity)] + client_body: Option> + Send>>>, +} + +impl FileStream { + /// Get the name of the file. + pub fn file_name(&self) -> &str { + &self.name + } + + /// Get the size of the file, if known. + pub fn size(&self) -> Option { + self.size + } + + /// Get the content type of the file, if available. + pub fn content_type(&self) -> Option<&str> { + self.content_type.as_deref() + } + + /// Return the underlying body stream, assuming the `FileStream` was created by a server request. + #[cfg(feature = "server")] + pub fn body_mut(&mut self) -> Option<&mut axum_core::body::BodyDataStream> { + self.server_body.as_mut() + } + + /// Create a new `FileStream` from a file path. This is only available on the server. + #[cfg(feature = "server")] + pub async fn from_path(file: impl AsRef) -> Result { + Self::from_path_buf(file.as_ref()).await + } + + #[cfg(feature = "server")] + async fn from_path_buf(file: &Path) -> Result { + let metadata = file.metadata()?; + let contents = tokio::fs::File::open(&file).await?; + let mime = dioxus_asset_resolver::native::get_mime_from_ext( + file.extension().and_then(|s| s.to_str()), + ); + let size = metadata.len(); + let name = file + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("file") + .to_string(); + + // Convert the tokio file into an async byte stream + let reader_stream = tokio_util::io::ReaderStream::new(contents); + + // Attempt to construct a BodyDataStream from the reader stream. + // Many axum_core versions provide a `from_stream` or similar constructor. + let body = axum_core::body::Body::from_stream(reader_stream).into_data_stream(); + + Ok(Self { + data: None, + name, + size: Some(size), + content_type: Some(mime.to_string()), + #[cfg(feature = "server")] + server_body: Some(body), + client_body: None, + }) + } + + /// Create a new `FileStream` from raw components. + /// + /// This is meant to be used on the server where a file might not even exist but you still want + /// to stream it to the client as a download. + #[cfg(feature = "server")] + pub fn from_raw( + name: String, + size: Option, + content_type: String, + body: axum_core::body::BodyDataStream, + ) -> Self { + Self { + data: None, + name, + size, + content_type: Some(content_type), + #[cfg(feature = "server")] + server_body: Some(body), + client_body: None, + } + } +} + +impl IntoRequest for FileStream { + #[allow(unreachable_code)] + fn into_request( + self, + #[allow(unused_mut)] mut builder: ClientRequest, + ) -> impl Future + 'static { + async move { + let Some(file_data) = self.data else { + return Err(RequestError::Request( + "FileStream has no data to send".into(), + )); + }; + + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + use js_sys::escape; + use wasm_bindgen::JsCast; + + let as_file = file_data.inner().downcast_ref::().unwrap(); + let as_blob = as_file.dyn_ref::().unwrap(); + let content_type = as_blob.type_(); + let content_length = as_blob.size().to_string(); + let name = as_file.name(); + + // Set both Content-Length and X-Content-Size for compatibility with server extraction. + // In browsers, content-length is often overwritten, so we set X-Content-Size as well + // for better compatibility with dioxus-based clients. + return builder + .header("Content-Type", content_type)? + .header("Content-Length", content_length.clone())? + .header("X-Content-Size", content_length)? + .header( + "Content-Disposition", + format!("attachment; filename=\"{}\"", escape(&name)), + )? + .send_js_value(as_blob.clone().into()) + .await; + } + + #[cfg(not(target_arch = "wasm32"))] + { + use std::ascii::escape_default; + + use futures::TryStreamExt; + + let content_type = self + .content_type + .unwrap_or_else(|| "application/octet-stream".to_string()); + let content_length = self.size.map(|s| s.to_string()); + let name = self.name; + let stream = file_data.byte_stream().map_err(|_| StreamingError::Failed); + + // Ascii escape the filename to avoid issues with special characters. + let mut chars = vec![]; + for byte in name.chars() { + chars.extend(escape_default(byte as u8)); + } + let filename = String::from_utf8(chars).map_err(|_| { + RequestError::Request( + "Failed to escape filename for Content-Disposition".into(), + ) + }); + + if let Some(length) = content_length { + builder = builder.header("Content-Length", length)?; + } + + if let Ok(filename) = filename { + builder = builder.header( + "Content-Disposition", + format!("attachment; filename=\"{}\"", filename), + )?; + } + + return builder + .header("Content-Type", content_type)? + .send_body_stream(stream) + .await; + } + + unimplemented!("FileStream::into_request is only implemented for web targets"); + } + } +} + +impl FromRequest for FileStream { + type Rejection = ServerFnError; + + fn from_request( + req: Request, + _: &S, + ) -> impl Future> + Send { + async move { + tracing::info!("Extracting FileUpload from request: {:?}", req); + + let disposition = req.headers().get("Content-Disposition"); + let filename = match disposition.map(|s| s.to_str()) { + Some(Ok(dis)) => { + let content = content_disposition::parse_content_disposition(dis); + let names = content.filename(); + match names { + Some((_name, Some(filename))) => filename.to_string(), + Some((name, None)) => name.to_string(), + None => "file".to_string(), + } + } + _ => "file".to_string(), + }; + + // Content-length is unreliable, so we use `X-Content-Size` as an indicator. + // For stream requests with known bodies, the browser will still set Content-Length to 0, unfortunately. + let size = req + .headers() + .get("X-Content-Size") + .and_then(|s| s.to_str().ok()) + .and_then(|s| s.parse::().ok()); + + let content_type = req + .headers() + .get("Content-Type") + .and_then(|s| s.to_str().ok()) + .map(|s| s.to_string()); + + Ok(FileStream { + data: None, + name: filename, + content_type, + size, + client_body: None, + #[cfg(feature = "server")] + server_body: Some(req.into_body().into_data_stream()), + }) + } + } +} + +impl FromResponse for FileStream { + fn from_response(res: ClientResponse) -> impl Future> { + async move { + // Extract filename from Content-Disposition header if present. + let name = res + .headers() + .get("Content-Disposition") + .and_then(|h| h.to_str().ok()) + .and_then(|dis| { + let cd = content_disposition::parse_content_disposition(dis); + cd.filename().map(|(name, _)| name.to_string()) + }) + .unwrap_or_else(|| "file".to_string()); + + // Extract content type header + let content_type = res + .headers() + .get("Content-Type") + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + // Prefer the response's known content length but fall back to X-Content-Size header. + let size = res.content_length().or_else(|| { + res.headers() + .get("X-Content-Size") + .and_then(|h| h.to_str().ok()) + .and_then(|s| s.parse::().ok()) + }); + + Ok(Self { + data: None, + name, + size, + content_type, + client_body: Some(Box::pin(res.bytes_stream())), + #[cfg(feature = "server")] + server_body: None, + }) + } + } +} + +#[cfg(feature = "server")] +impl IntoResponse for FileStream { + fn into_response(self) -> axum::response::Response { + use axum::body::Body; + + let Some(body) = self.server_body else { + use dioxus_fullstack_core::HttpError; + return HttpError::new(http::StatusCode::BAD_REQUEST, "FileStream has no body") + .into_response(); + }; + + let mut res = axum::response::Response::new(Body::from_stream(body)); + + // Set relevant headers if available + if let Some(content_type) = &self.content_type { + res.headers_mut() + .insert("Content-Type", content_type.parse().unwrap()); + } + if let Some(size) = self.size { + res.headers_mut() + .insert("Content-Length", size.to_string().parse().unwrap()); + } + res.headers_mut().insert( + "Content-Disposition", + format!("attachment; filename=\"{}\"", self.name) + .parse() + .unwrap(), + ); + + res + } +} + +impl From for FileStream { + fn from(value: FileData) -> Self { + Self { + name: value.name().to_string(), + content_type: value.content_type().map(|s| s.to_string()), + size: Some(value.size()), + data: Some(value), + client_body: None, + #[cfg(feature = "server")] + server_body: None, + } + } +} + +impl Stream for FileStream { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + // For server-side builds, poll the server_body stream if it exists. + #[cfg(feature = "server")] + if let Some(body) = self.server_body.as_mut() { + return Pin::new(body) + .poll_next(cx) + .map_err(|_| StreamingError::Failed); + } + + // For client-side builds, poll the client_body stream if it exists. + if let Some(body) = self.client_body.as_mut() { + return body.as_mut().poll_next(cx); + } + + // Otherwise, the stream is exhausted. + Poll::Ready(None) + } +} + +impl std::fmt::Debug for FileStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FileStream") + .field("name", &self.name) + .field("size", &self.size) + .field("content_type", &self.content_type) + .finish() + } +} diff --git a/packages/fullstack/src/payloads/form.rs b/packages/fullstack/src/payloads/form.rs new file mode 100644 index 000000000..805e2305a --- /dev/null +++ b/packages/fullstack/src/payloads/form.rs @@ -0,0 +1,12 @@ +use super::*; + +pub use axum::extract::Form; + +impl IntoRequest for Form +where + T: Serialize + 'static, +{ + fn into_request(self, req: ClientRequest) -> impl Future + 'static { + async move { req.send_form(&self.0).await } + } +} diff --git a/packages/fullstack/src/payloads/header.rs b/packages/fullstack/src/payloads/header.rs new file mode 100644 index 000000000..f3e3b9b29 --- /dev/null +++ b/packages/fullstack/src/payloads/header.rs @@ -0,0 +1,66 @@ +use super::*; + +pub use headers::Cookie; +pub use headers::SetCookie; + +#[derive(Clone, Debug)] +pub struct SetHeader { + data: Option, +} + +impl SetHeader { + pub fn new( + value: impl TryInto, + ) -> Result { + let values = value.try_into().map_err(|_| headers::Error::invalid())?; + + let res = T::decode(&mut std::iter::once(&values))?; + + Ok(Self { data: Some(res) }) + } +} + +impl IntoResponseParts for SetHeader { + type Error = (); + + fn into_response_parts(self, res: ResponseParts) -> Result { + let data = self.data.expect("SetHeader must have data to set"); + + let mut headers = vec![]; + data.encode(&mut headers); + + Ok(axum::response::AppendHeaders( + headers.into_iter().map(|value| (T::name().clone(), value)), + ) + .into_response_parts(res) + .unwrap()) + } +} + +impl FromResponseParts for SetHeader { + fn from_response_parts(parts: &mut axum::http::response::Parts) -> Result { + let Some(header) = parts.headers.remove(T::name()) else { + return Ok(SetHeader { data: None }); + }; + + let data = T::decode(&mut std::iter::once(&header)) + .map_err(|_| ServerFnError::Deserialization("Failed to decode header".into()))?; + + Ok(SetHeader { data: Some(data) }) + } +} + +impl IntoResponse for SetHeader { + fn into_response(self) -> axum::response::Response { + let mut values = vec![]; + self.data.unwrap().encode(&mut values); + + let mut response = axum::response::Response::builder(); + + for value in values { + response = response.header(T::name(), value); + } + + response.body(axum_core::body::Body::empty()).unwrap() + } +} diff --git a/packages/fullstack/src/payloads/msgpack.rs b/packages/fullstack/src/payloads/msgpack.rs new file mode 100644 index 000000000..844bcaaff --- /dev/null +++ b/packages/fullstack/src/payloads/msgpack.rs @@ -0,0 +1,186 @@ +#![forbid(unsafe_code)] + +use axum::{ + body::{Body, Bytes}, + extract::{FromRequest, Request}, + http::{header::HeaderValue, StatusCode}, + response::{IntoResponse, Response}, +}; +use axum::{extract::rejection::BytesRejection, http, BoxError}; +use derive_more::{Deref, DerefMut, From}; +use serde::{de::DeserializeOwned, Serialize}; + +/// MessagePack Extractor / Response. +/// +/// When used as an extractor, it can deserialize request bodies into some type that +/// implements [`serde::Deserialize`]. If the request body cannot be parsed, or value of the +/// `Content-Type` header does not match any of the `application/msgpack`, `application/x-msgpack` +/// or `application/*+msgpack` it will reject the request and return a `400 Bad Request` response. +/// +/// When used as a response, it can serialize any type that implements [`serde::Serialize`] to +/// `MsgPack`, and will automatically set `Content-Type: application/msgpack` header. +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +pub struct MsgPack(pub T); + +impl FromRequest for MsgPack +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = MsgPackRejection; + + async fn from_request(req: Request, state: &S) -> Result { + if !message_pack_content_type(&req) { + return Err(MsgPackRejection::MissingMsgPackContentType); + } + let bytes = Bytes::from_request(req, state).await?; + let value = rmp_serde::from_slice(&bytes) + .map_err(|e| MsgPackRejection::InvalidMsgPackBody(e.into()))?; + Ok(MsgPack(value)) + } +} + +impl IntoResponse for MsgPack +where + T: Serialize, +{ + fn into_response(self) -> Response { + let bytes = match rmp_serde::encode::to_vec_named(&self.0) { + Ok(res) => res, + Err(err) => { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("Content-Type", "text/plain") + .body(Body::new(err.to_string())) + .unwrap(); + } + }; + + let mut res = bytes.into_response(); + + res.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("application/msgpack"), + ); + res + } +} + +/// MessagePack Extractor / Response. +/// +/// When used as an extractor, it can deserialize request bodies into some type that +/// implements [`serde::Deserialize`]. If the request body cannot be parsed, or value of the +/// `Content-Type` header does not match any of the `application/msgpack`, `application/x-msgpack` +/// or `application/*+msgpack` it will reject the request and return a `400 Bad Request` response. +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +pub struct MsgPackRaw(pub T); + +impl FromRequest for MsgPackRaw +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = MsgPackRejection; + + async fn from_request(req: Request, state: &S) -> Result { + if !message_pack_content_type(&req) { + return Err(MsgPackRejection::MissingMsgPackContentType); + } + let bytes = Bytes::from_request(req, state).await?; + let value = rmp_serde::from_slice(&bytes) + .map_err(|e| MsgPackRejection::InvalidMsgPackBody(e.into()))?; + Ok(MsgPackRaw(value)) + } +} + +impl IntoResponse for MsgPackRaw +where + T: Serialize, +{ + fn into_response(self) -> Response { + let bytes = match rmp_serde::encode::to_vec(&self.0) { + Ok(res) => res, + Err(err) => { + return Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("Content-Type", "text/plain") + .body(Body::new(err.to_string())) + .unwrap(); + } + }; + + let mut res = bytes.into_response(); + + res.headers_mut().insert( + "Content-Type", + HeaderValue::from_static("application/msgpack"), + ); + res + } +} + +fn message_pack_content_type(req: &Request) -> bool { + let Some(content_type) = req.headers().get("Content-Type") else { + return false; + }; + + let Ok(content_type) = content_type.to_str() else { + return false; + }; + + match content_type { + "application/msgpack" => true, + "application/x-msgpack" => true, + ct if ct.starts_with("application/") && ct.ends_with("+msgpack") => true, + _ => false, + } +} + +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum MsgPackRejection { + #[error("Failed to parse the request body as MsgPack: {0}")] + InvalidMsgPackBody(BoxError), + + #[error("Expected request with `Content-Type: application/msgpack`")] + MissingMsgPackContentType, + + #[error("Cannot have two request body extractors for a single handler")] + BodyAlreadyExtracted, + + #[error(transparent)] + BytesRejection(#[from] BytesRejection), +} + +impl IntoResponse for MsgPackRejection { + fn into_response(self) -> Response { + match self { + Self::InvalidMsgPackBody(inner) => { + let mut res = Response::new(Body::from(format!( + "Failed to parse the request body as MsgPack: {}", + inner + ))); + *res.status_mut() = http::StatusCode::BAD_REQUEST; + res + } + + Self::MissingMsgPackContentType => { + let mut res = Response::new(Body::from( + "Expected request with `Content-Type: application/msgpack`", + )); + *res.status_mut() = http::StatusCode::BAD_REQUEST; + res + } + + Self::BodyAlreadyExtracted => { + let mut res = Response::new(Body::from( + "Cannot have two request body extractors for a single handler", + )); + *res.status_mut() = http::StatusCode::INTERNAL_SERVER_ERROR; + res + } + + Self::BytesRejection(inner) => inner.into_response(), + } + } +} diff --git a/packages/fullstack/src/payloads/multipart.rs b/packages/fullstack/src/payloads/multipart.rs new file mode 100644 index 000000000..54fe163e8 --- /dev/null +++ b/packages/fullstack/src/payloads/multipart.rs @@ -0,0 +1,165 @@ +#![allow(unreachable_code)] + +use crate::{ClientRequest, ClientResponse, IntoRequest}; +use axum::{ + extract::{FromRequest, Request}, + response::{IntoResponse, Response}, +}; +use dioxus_fullstack_core::RequestError; +use dioxus_html::{FormData, FormEvent}; +use std::{prelude::rust_2024::Future, rc::Rc}; + +#[cfg(feature = "server")] +use axum::extract::multipart::{Field, MultipartError}; + +/// A streaming multipart form data handler. +/// +/// This type makes it easy to send and receive multipart form data in a streaming fashion by directly +/// leveraging the corresponding `dioxus_html::FormData` and `axum::extract::Multipart` types. +/// +/// On the client, you can create a `MultipartFormData` instance by using `.into()` on a `FormData` instance. +/// This is typically done by using the `FormEvent`'s `.data()` method. +/// +/// On the server, you can extract a `MultipartFormData` instance by using it as an extractor in your handler function. +/// This gives you access to axum's `Multipart` extractor, which allows you to handle the various fields +/// and files in the multipart form data. +/// +/// ## Axum Usage +/// +/// Extractor that parses `multipart/form-data` requests (commonly used with file uploads). +/// +/// ⚠️ Since extracting multipart form data from the request requires consuming the body, the +/// `Multipart` extractor must be *last* if there are multiple extractors in a handler. +/// See ["the order of extractors"][order-of-extractors] +/// +/// [order-of-extractors]: crate::extract#the-order-of-extractors +/// +/// # Large Files +/// +/// For security reasons, by default, `Multipart` limits the request body size to 2MB. +/// See [`DefaultBodyLimit`][default-body-limit] for how to configure this limit. +/// +/// [default-body-limit]: crate::extract::DefaultBodyLimit +pub struct MultipartFormData { + #[cfg(feature = "server")] + form: Option, + + _client: Option>, + + _phantom: std::marker::PhantomData, +} + +impl MultipartFormData { + #[cfg(feature = "server")] + pub async fn next_field(&mut self) -> Result>, MultipartError> { + if let Some(form) = &mut self.form { + form.next_field().await + } else { + Ok(None) + } + } +} + +impl IntoRequest for MultipartFormData { + fn into_request( + self, + _req: ClientRequest, + ) -> impl Future> + 'static { + async move { + // On the web, it's just easier to convert the form data into a blob and then send that + // blob as the body of the request. This handles setting the correct headers, wiring + // up file uploads as streams, and encoding the request. + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + let data = self._client.clone().ok_or_else(|| { + RequestError::Builder("Failed to get FormData from event".into()) + })?; + + fn get_form_data(data: Rc) -> Option { + use wasm_bindgen::JsCast; + let event: &web_sys::Event = data.downcast()?; + let target = event.target()?; + let form: &web_sys::HtmlFormElement = target.dyn_ref()?; + let data: web_sys::FormData = web_sys::FormData::new_with_form(form).ok()?; + Some(data.into()) + } + + let js_form_data = get_form_data(data).ok_or_else(|| { + RequestError::Builder("Failed to get FormData from event".into()) + })?; + + return _req.send_js_value(js_form_data).await; + } + + // On non-web platforms, we actually need to read the values out of the FormData + // and construct a multipart form body manually. + #[cfg(not(target_arch = "wasm32"))] + { + todo!("Properly implement multipart form data for non-web wasm32 clients"); + } + + unimplemented!("Non web wasm32 clients are not supported yet") + } + } +} +impl FromRequest for MultipartFormData { + type Rejection = Response; + + #[doc = " Perform the extraction."] + fn from_request( + req: Request, + state: &S, + ) -> impl Future> + Send { + #[cfg(feature = "server")] + return async move { + let form = axum::extract::multipart::Multipart::from_request(req, state) + .await + .map_err(|err| err.into_response())?; + + Ok(MultipartFormData { + form: Some(form), + _client: None, + _phantom: std::marker::PhantomData, + }) + }; + + #[cfg(not(feature = "server"))] + async { + use dioxus_fullstack_core::HttpError; + + let _ = req; + let _ = state; + Err(HttpError::new( + http::StatusCode::INTERNAL_SERVER_ERROR, + "MultipartFormData extractor is not supported on non-server builds", + ) + .into_response()) + } + } +} + +impl From> for MultipartFormData { + fn from(_value: Rc) -> Self { + MultipartFormData { + #[cfg(feature = "server")] + form: None, + _client: Some(_value), + _phantom: std::marker::PhantomData, + } + } +} + +impl From for MultipartFormData { + fn from(event: FormEvent) -> Self { + let data = event.data(); + MultipartFormData { + #[cfg(feature = "server")] + form: None, + _client: Some(data), + _phantom: std::marker::PhantomData, + } + } +} + +unsafe impl Send for MultipartFormData {} +unsafe impl Sync for MultipartFormData {} diff --git a/packages/fullstack/src/payloads/postcard.rs b/packages/fullstack/src/payloads/postcard.rs new file mode 100644 index 000000000..cbe8d9e6a --- /dev/null +++ b/packages/fullstack/src/payloads/postcard.rs @@ -0,0 +1,117 @@ +use axum::{ + body::Bytes, + extract::{rejection::BytesRejection, FromRequest}, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use postcard::{from_bytes, to_allocvec}; +use serde::{de::DeserializeOwned, Serialize}; +use std::future::Future; + +/// Postcard Extractor / Response. +/// +/// When used as an extractor, it can deserialize request bodies into some type that +/// implements [`serde::Deserialize`]. The request will be rejected (and a [`PostcardRejection`] will +/// be returned) if: +/// +/// - The request doesn't have a `Content-Type: application/postcard` (or similar) header. +/// - The body doesn't contain syntactically valid Postcard. +/// - The body contains syntactically valid Postcard but it couldn't be deserialized into the target type. +/// - Buffering the request body fails. +/// +/// ⚠️ Since parsing Postcard requires consuming the request body, the `Postcard` extractor must be +/// *last* if there are multiple extractors in a handler. +/// See ["the order of extractors"][order-of-extractors] +/// +/// [order-of-extractors]: crate::extract#the-order-of-extractors +pub struct Postcard(pub T); + +#[derive(thiserror::Error, Debug)] +pub enum PostcardRejection { + #[error("Expected request with `Content-Type: application/postcard`")] + MissingPostcardContentType, + + #[error(transparent)] + PostcardError(#[from] postcard::Error), + + #[error(transparent)] + Bytes(#[from] BytesRejection), +} + +impl IntoResponse for PostcardRejection { + fn into_response(self) -> Response { + use PostcardRejection::*; + // its often easiest to implement `IntoResponse` by calling other implementations + match self { + MissingPostcardContentType => { + (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()).into_response() + } + PostcardError(err) => (StatusCode::BAD_REQUEST, err.to_string()).into_response(), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(), + } + } +} + +impl FromRequest for Postcard +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = PostcardRejection; + + fn from_request( + req: axum::extract::Request, + state: &S, + ) -> impl Future> + Send { + async move { + if postcard_content_type(req.headers()) { + let bytes = Bytes::from_request(req, state).await?; + let value = match from_bytes(&bytes) { + Ok(value) => value, + Err(err) => return Err(PostcardRejection::PostcardError(err)), + }; + Ok(Postcard(value)) + } else { + Err(PostcardRejection::MissingPostcardContentType) + } + } + } +} + +fn postcard_content_type(headers: &HeaderMap) -> bool { + let content_type = if let Some(content_type) = headers.get(header::CONTENT_TYPE) { + content_type + } else { + return false; + }; + + let content_type = if let Ok(content_type) = content_type.to_str() { + content_type + } else { + return false; + }; + + let mime = if let Ok(mime) = content_type.parse::() { + mime + } else { + return false; + }; + + let is_postcard_content_type = mime.type_() == "application" + && (mime.subtype() == "postcard" || mime.suffix().is_some_and(|name| name == "postcard")); + + is_postcard_content_type +} + +impl IntoResponse for Postcard +where + T: Serialize, +{ + fn into_response(self) -> Response { + // TODO: maybe use 128 bytes cause serde is doing something like that + match to_allocvec(&self.0) { + Ok(value) => ([(header::CONTENT_TYPE, "application/postcard")], value).into_response(), + Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + } + } +} diff --git a/packages/fullstack/src/payloads/redirect.rs b/packages/fullstack/src/payloads/redirect.rs new file mode 100644 index 000000000..e69de29bb diff --git a/packages/fullstack/src/payloads/sse.rs b/packages/fullstack/src/payloads/sse.rs new file mode 100644 index 000000000..ae5a360c8 --- /dev/null +++ b/packages/fullstack/src/payloads/sse.rs @@ -0,0 +1,422 @@ +use crate::{ClientResponse, FromResponse, RequestError, ServerFnError}; +#[cfg(feature = "server")] +use axum::{ + response::sse::{Event, KeepAlive}, + BoxError, +}; +use futures::io::AsyncBufReadExt; +use futures::Stream; +use futures::{StreamExt, TryStreamExt}; +use http::{header::CONTENT_TYPE, HeaderValue, StatusCode}; +use serde::de::DeserializeOwned; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +/// A stream of Server-Sent Events (SSE) that can be used to receive events from the server. +/// +/// This type implements `Stream` for asynchronous iteration over events. +/// Events are automatically deserialized from JSON to the specified type `T`. +#[allow(clippy::type_complexity)] +pub struct ServerEvents { + _marker: std::marker::PhantomData T>, + + // The receiving end from the server + client: Option>>>>, + + #[cfg(feature = "server")] + keep_alive: Option, + + // The actual SSE response to send to the client + #[cfg(feature = "server")] + sse: Option> + Send>>>>, +} + +impl ServerEvents { + /// Receives the next event from the stream, deserializing it to `T`. + /// + /// Returns `None` if the stream has ended. + pub async fn recv(&mut self) -> Option> { + let event = self.next_event().await?; + match event { + Ok(event) => { + let data: Result = + serde_json::from_str(&event.data).map_err(|err| { + ServerFnError::Serialization(format!( + "failed to deserialize event data: {}", + err + )) + }); + Some(data) + } + Err(err) => Some(Err(err)), + } + } +} + +impl ServerEvents { + /// Receives the next raw event from the stream. + /// + /// Returns `None` if the stream has ended. + pub async fn next_event(&mut self) -> Option> { + self.client.as_mut()?.next().await + } +} + +impl Stream for ServerEvents { + type Item = Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let Some(client) = self.client.as_mut() else { + return Poll::Ready(None); + }; + + match client.as_mut().poll_next(cx) { + Poll::Ready(Some(Ok(event))) => { + let data = serde_json::from_str(&event.data).map_err(|err| { + ServerFnError::Serialization(format!( + "failed to deserialize event data: {}", + err + )) + }); + Poll::Ready(Some(data)) + } + Poll::Ready(Some(Err(err))) => Poll::Ready(Some(Err(err))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +impl FromResponse for ServerEvents { + async fn from_response(res: ClientResponse) -> Result { + let status = res.status(); + if status != StatusCode::OK { + return Err(ServerFnError::Request(RequestError::Status( + format!("Expected status 200 OK, got {}", status), + status.as_u16(), + ))); + } + + let content_type = res.headers().get(CONTENT_TYPE); + if content_type != Some(&HeaderValue::from_static(mime::TEXT_EVENT_STREAM.as_ref())) { + return Err(ServerFnError::Request(RequestError::Request(format!( + "Expected content type 'text/event-stream', got {:?}", + content_type + )))); + } + + let mut stream = res + .bytes_stream() + .map(|result| result.map_err(std::io::Error::other)) + .into_async_read(); + + let mut line_buffer = String::new(); + let mut event_buffer = EventBuffer::new(); + + let stream: Pin>>> = Box::pin( + async_stream::try_stream! { + loop { + line_buffer.clear(); + if stream.read_line(&mut line_buffer).await.map_err(|err| ServerFnError::StreamError(err.to_string()))? == 0 { + break; + } + + let line = if let Some(line) = line_buffer.strip_suffix('\n') { + line + } else { + &line_buffer + }; + + // dispatch + if line.is_empty() { + if let Some(event) = event_buffer.produce_event() { + yield event; + } + continue; + } + + // Parse line to split field name and value, applying proper trimming. + let (field, value) = line.split_once(':').unwrap_or((line, "")); + let value = value.strip_prefix(' ').unwrap_or(value); + + // Handle fields - these are the in SSE speci. + match field { + "event" => event_buffer.set_event_type(value), + "data" => event_buffer.push_data(value), + "id" => event_buffer.set_id(value), + "retry" => { + if let Ok(millis) = value.parse() { + event_buffer.set_retry(Duration::from_millis(millis)); + } + } + _ => {} + } + } + }, + ); + + Ok(Self { + _marker: std::marker::PhantomData, + client: Some(stream), + + #[cfg(feature = "server")] + keep_alive: None, + + #[cfg(feature = "server")] + sse: None, + }) + } +} + +/// Server-Sent Event representation. +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct ServerSentEvent { + /// A string identifying the type of event described. + pub event_type: String, + + /// The data field for the message. + pub data: String, + + /// Last event ID value. + pub last_event_id: Option, + + /// Reconnection time. + pub retry: Option, +} + +/// Internal buffer used to accumulate lines of an SSE (Server-Sent Events) stream. +struct EventBuffer { + event_type: String, + data: String, + last_event_id: Option, + retry: Option, +} + +impl EventBuffer { + /// Creates fresh new [`EventBuffer`]. + #[allow(clippy::new_without_default)] + fn new() -> Self { + Self { + event_type: String::new(), + data: String::new(), + last_event_id: None, + retry: None, + } + } + + /// Produces a [`Event`], if current state allow it. + /// + /// Reset the internal state to process further data. + fn produce_event(&mut self) -> Option { + let event = if self.data.is_empty() { + None + } else { + Some(ServerSentEvent { + event_type: if self.event_type.is_empty() { + "message".to_string() + } else { + self.event_type.clone() + }, + data: self.data.to_string(), + last_event_id: self.last_event_id.clone(), + retry: self.retry, + }) + }; + + self.event_type.clear(); + self.data.clear(); + + event + } + + /// Set the [`Event`]'s type. Override previous value. + fn set_event_type(&mut self, event_type: &str) { + self.event_type.clear(); + self.event_type.push_str(event_type); + } + + /// Extends internal data with given data. + fn push_data(&mut self, data: &str) { + if !self.data.is_empty() { + self.data.push('\n'); + } + self.data.push_str(data); + } + + fn set_id(&mut self, id: &str) { + self.last_event_id = Some(id.to_string()); + } + + fn set_retry(&mut self, retry: Duration) { + self.retry = Some(retry); + } +} + +#[cfg(feature = "server")] +pub use server_impl::*; + +#[cfg(feature = "server")] +mod server_impl { + use super::*; + use crate::spawn_platform; + use axum::response::sse::Sse; + use axum_core::response::IntoResponse; + use futures::Future; + use futures::SinkExt; + use futures::{Sink, TryStream}; + use serde::Serialize; + + impl ServerEvents { + /// Create a `ServerEvents` from a function that is given a sender to send events to the client. + /// + /// By default, we send a comment every 15 seconds to keep the connection alive. + pub fn new(f: impl FnOnce(SseTx) -> F + Send + 'static) -> Self + where + F: Future + 'static, + R: 'static + Send, + { + let (tx, mut rx) = futures_channel::mpsc::unbounded(); + + let tx = SseTx { + sender: tx, + _marker: std::marker::PhantomData, + }; + + // Spawn the user function in the background + spawn_platform(move || f(tx)); + + // Create the stream of events, mapping the incoming events to `Ok` + // If the user function ends, the stream will end and the connection will be closed + let stream = futures::stream::poll_fn(move |cx| match rx.poll_next_unpin(cx) { + std::task::Poll::Ready(Some(event)) => std::task::Poll::Ready(Some( + Ok(event) as Result + )), + std::task::Poll::Ready(None) => std::task::Poll::Ready(None), + std::task::Poll::Pending => std::task::Poll::Pending, + }); + + let sse = Sse::new(stream.boxed()); + + Self { + _marker: std::marker::PhantomData, + client: None, + keep_alive: Some(KeepAlive::new().interval(Duration::from_secs(15))), + sse: Some(sse), + } + } + + /// Create a `ServerEvents` from a `TryStream` of events. + pub fn from_stream(stream: S) -> Self + where + S: TryStream + Send + 'static, + T: Serialize, + { + let stream = stream.map_ok(|event| { + axum::response::sse::Event::default() + .json_data(event) + .expect("Failed to serialize SSE event") + }); + let sse = axum::response::Sse::new(stream.boxed()); + Self { + _marker: std::marker::PhantomData, + client: None, + keep_alive: Some(KeepAlive::new().interval(Duration::from_secs(15))), + sse: Some(sse), + } + } + + /// Set the keep-alive configuration for the SSE connection. + /// + /// A `None` value will disable the default `KeepAlive` of 15 seconds. + pub fn with_keep_alive(mut self, keep_alive: Option) -> Self { + self.keep_alive = keep_alive; + self + } + + /// Create a `ServerEvents` from an existing Axum `Sse` response. + #[allow(clippy::type_complexity)] + pub fn from_sse( + sse: Sse> + Send>>>, + ) -> Self { + Self { + _marker: std::marker::PhantomData, + client: None, + keep_alive: None, + sse: Some(sse), + } + } + } + + impl IntoResponse for ServerEvents { + fn into_response(self) -> axum_core::response::Response { + let mut sse = self + .sse + .expect("SSE should be initialized before using it as a response"); + + if let Some(keep_alive) = self.keep_alive { + sse = sse.keep_alive(keep_alive) + } + + sse.into_response() + } + } + + /// A transmitter for sending events to the SSE stream. + pub struct SseTx { + sender: futures_channel::mpsc::UnboundedSender, + _marker: std::marker::PhantomData T>, + } + + impl SseTx { + /// Sends an event to the SSE stream. + pub async fn send(&mut self, event: T) -> anyhow::Result<()> { + let event = axum::response::sse::Event::default().json_data(event)?; + self.sender.unbounded_send(event)?; + Ok(()) + } + } + + impl std::ops::Deref for SseTx { + type Target = futures_channel::mpsc::UnboundedSender; + fn deref(&self) -> &Self::Target { + &self.sender + } + } + + impl std::ops::DerefMut for SseTx { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.sender + } + } + + impl Sink for SseTx { + type Error = anyhow::Error; + + fn poll_ready( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.sender.poll_ready_unpin(_cx).map_err(|e| e.into()) + } + + fn start_send(mut self: Pin<&mut Self>, item: T) -> Result<(), Self::Error> { + let event = axum::response::sse::Event::default().json_data(item)?; + self.sender.start_send(event).map_err(|e| e.into()) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.sender.poll_flush_unpin(_cx).map_err(|e| e.into()) + } + + fn poll_close( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll> { + self.sender.poll_close_unpin(_cx).map_err(|e| e.into()) + } + } +} diff --git a/packages/fullstack/src/payloads/stream.rs b/packages/fullstack/src/payloads/stream.rs new file mode 100644 index 000000000..1e0af3c03 --- /dev/null +++ b/packages/fullstack/src/payloads/stream.rs @@ -0,0 +1,405 @@ +#![allow(clippy::type_complexity)] + +use crate::{ + CborEncoding, ClientRequest, ClientResponse, Encoding, FromResponse, IntoRequest, JsonEncoding, + ServerFnError, +}; +use axum::extract::{FromRequest, Request}; +use axum_core::response::IntoResponse; +use bytes::Bytes; +use dioxus_fullstack_core::{HttpError, RequestError}; +use futures::{Stream, StreamExt}; +use headers::{ContentType, Header}; +use send_wrapper::SendWrapper; +use serde::{de::DeserializeOwned, Serialize}; +use std::{future::Future, marker::PhantomData, pin::Pin}; + +pub type TextStream = Streaming; +pub type ByteStream = Streaming; +pub type JsonStream = Streaming; +pub type CborStream = Streaming; + +/// A streaming payload. +/// +/// ## Browser Support for Streaming Input +/// +/// Browser fetch requests do not currently support full request duplexing, which +/// means that that they do not begin handling responses until the full request has been sent. +/// +/// This means that if you use a streaming input encoding, the input stream needs to +/// end before the output will begin. +/// +/// Streaming requests are only allowed over HTTP2 or HTTP3. +/// +/// Also note that not all browsers support streaming bodies to servers. +pub struct Streaming { + output_stream: Pin> + Send>>, + input_stream: Pin> + Send>>, + encoding: PhantomData, +} + +#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq, Hash)] +pub enum StreamingError { + /// The streaming request was interrupted and could not be completed. + #[error("The streaming request was interrupted")] + Interrupted, + + /// The stream failed to decode a chunk - possibly due to invalid data or version mismatch. + #[error("The stream failed to decode a chunk")] + Decoding, + + /// The stream failed to connect or encountered an error. + #[error("The streaming request failed")] + Failed, +} + +impl Streaming { + /// Creates a new stream from the given stream. + pub fn new(value: impl Stream + Send + 'static) -> Self { + // Box and pin the incoming stream and store as a trait object + Self { + output_stream: Box::pin(futures::stream::empty()) as _, + input_stream: Box::pin(value.map(|item| Ok(item))) + as Pin> + Send>>, + encoding: PhantomData, + } + } + + /// Spawns a new task that produces items for the stream. + /// + /// The callback is provided an `UnboundedSender` that can be used to send items to the stream. + #[cfg(feature = "server")] + pub fn spawn( + callback: impl FnOnce(futures_channel::mpsc::UnboundedSender) -> F + Send + 'static, + ) -> Self + where + F: Future + 'static, + T: Send, + { + let (tx, rx) = futures_channel::mpsc::unbounded(); + + crate::spawn_platform(move || callback(tx)); + + Self::new(rx) + } + + /// Returns the next item in the stream, or `None` if the stream has ended. + pub async fn next(&mut self) -> Option> { + self.output_stream.as_mut().next().await + } + + /// Consumes the wrapper, returning the inner stream. + pub fn into_inner(self) -> impl Stream> + Send { + self.input_stream + } +} + +impl From for TextStream +where + S: Stream + Send + 'static, + U: Into, +{ + fn from(value: S) -> Self { + Self { + input_stream: Box::pin(value.map(|data| Ok(data.into()))), + output_stream: Box::pin(futures::stream::empty()) as _, + encoding: PhantomData, + } + } +} + +impl From for ByteStream +where + S: Stream> + Send + 'static, +{ + fn from(value: S) -> Self { + Self { + input_stream: Box::pin(value.map(|data| data.map_err(|_| StreamingError::Failed))), + output_stream: Box::pin(futures::stream::empty()) as _, + encoding: PhantomData, + } + } +} + +impl From for Streaming +where + S: Stream + Send + 'static, + U: Into, + T: 'static + Send, + E: Encoding, +{ + fn from(value: S) -> Self { + Self { + input_stream: Box::pin(value.map(|data| Ok(data.into()))), + output_stream: Box::pin(futures::stream::empty()) as _, + encoding: PhantomData, + } + } +} + +impl IntoResponse for Streaming { + fn into_response(self) -> axum_core::response::Response { + axum::response::Response::builder() + .header("Content-Type", "text/plain; charset=utf-8") + .body(axum::body::Body::from_stream(self.input_stream)) + .unwrap() + } +} + +impl IntoResponse for Streaming { + fn into_response(self) -> axum_core::response::Response { + axum::response::Response::builder() + .header("Content-Type", "application/octet-stream") + .body(axum::body::Body::from_stream(self.input_stream)) + .unwrap() + } +} + +impl IntoResponse for Streaming { + fn into_response(self) -> axum_core::response::Response { + let res = self.input_stream.map(|r| match r { + Ok(res) => match E::to_bytes(&res) { + Some(bytes) => Ok(bytes), + None => Err(StreamingError::Failed), + }, + Err(_err) => Err(StreamingError::Failed), + }); + + axum::response::Response::builder() + .header("Content-Type", E::stream_content_type()) + .body(axum::body::Body::from_stream(res)) + .unwrap() + } +} + +impl FromResponse for Streaming { + fn from_response(res: ClientResponse) -> impl Future> { + SendWrapper::new(async move { + let client_stream = Box::pin(res.bytes_stream().map(|byte| match byte { + Ok(bytes) => match String::from_utf8(bytes.to_vec()) { + Ok(string) => Ok(string), + Err(_) => Err(StreamingError::Decoding), + }, + Err(_) => Err(StreamingError::Failed), + })); + + Ok(Self { + output_stream: client_stream, + input_stream: Box::pin(futures::stream::empty()), + encoding: PhantomData, + }) + }) + } +} + +impl FromResponse for Streaming { + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let client_stream = Box::pin(SendWrapper::new(res.bytes_stream().map( + |byte| match byte { + Ok(bytes) => Ok(bytes), + Err(_) => Err(StreamingError::Failed), + }, + ))); + + Ok(Self { + output_stream: client_stream, + input_stream: Box::pin(futures::stream::empty()), + encoding: PhantomData, + }) + } + } +} + +impl FromResponse + for Streaming +{ + fn from_response(res: ClientResponse) -> impl Future> { + SendWrapper::new(async move { + let client_stream = Box::pin(SendWrapper::new(res.bytes_stream().map( + |byte| match byte { + Ok(bytes) => match E::from_bytes(bytes) { + Some(res) => Ok(res), + None => Err(StreamingError::Decoding), + }, + Err(_) => Err(StreamingError::Failed), + }, + ))); + + Ok(Self { + output_stream: client_stream, + input_stream: Box::pin(futures::stream::empty()), + encoding: PhantomData, + }) + }) + } +} + +impl FromRequest for Streaming { + type Rejection = ServerFnError; + + fn from_request( + req: Request, + _state: &S, + ) -> impl Future> + Send { + async move { + let (parts, body) = req.into_parts(); + let content_type = parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !content_type.starts_with("text/plain") { + HttpError::bad_request("Invalid content type")?; + } + + let stream = body.into_data_stream(); + + Ok(Self { + input_stream: Box::pin(futures::stream::empty()), + output_stream: Box::pin(stream.map(|byte| match byte { + Ok(bytes) => match String::from_utf8(bytes.to_vec()) { + Ok(string) => Ok(string), + Err(_) => Err(StreamingError::Decoding), + }, + Err(_) => Err(StreamingError::Failed), + })), + encoding: PhantomData, + }) + } + } +} + +impl FromRequest for ByteStream { + type Rejection = ServerFnError; + + fn from_request( + req: Request, + _state: &S, + ) -> impl Future> + Send { + async move { + let (parts, body) = req.into_parts(); + let content_type = parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !content_type.starts_with("application/octet-stream") { + HttpError::bad_request("Invalid content type")?; + } + + let stream = body.into_data_stream(); + + Ok(Self { + input_stream: Box::pin(futures::stream::empty()), + output_stream: Box::pin(stream.map(|byte| match byte { + Ok(bytes) => Ok(bytes), + Err(_) => Err(StreamingError::Failed), + })), + encoding: PhantomData, + }) + } + } +} + +impl FromRequest + for Streaming +{ + type Rejection = ServerFnError; + + fn from_request( + req: Request, + _state: &S, + ) -> impl Future> + Send { + async move { + let (parts, body) = req.into_parts(); + let content_type = parts + .headers + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if !content_type.starts_with(E::stream_content_type()) { + HttpError::bad_request("Invalid content type")?; + } + + let stream = body.into_data_stream(); + + Ok(Self { + input_stream: Box::pin(futures::stream::empty()), + output_stream: Box::pin(stream.map(|byte| match byte { + Ok(bytes) => match E::from_bytes(bytes) { + Some(res) => Ok(res), + None => Err(StreamingError::Decoding), + }, + Err(_) => Err(StreamingError::Failed), + })), + encoding: PhantomData, + }) + } + } +} + +impl IntoRequest for Streaming { + fn into_request( + self, + builder: ClientRequest, + ) -> impl Future> + 'static { + async move { + builder + .header("Content-Type", "text/plain; charset=utf-8")? + .send_body_stream(self.input_stream.map(|e| e.map(Bytes::from))) + .await + } + } +} + +impl IntoRequest for ByteStream { + fn into_request( + self, + builder: ClientRequest, + ) -> impl Future> + 'static { + async move { + builder + .header(ContentType::name(), "application/octet-stream")? + .send_body_stream(self.input_stream) + .await + } + } +} + +impl IntoRequest + for Streaming +{ + fn into_request( + self, + builder: ClientRequest, + ) -> impl Future> + 'static { + async move { + builder + .header("Content-Type", E::stream_content_type())? + .send_body_stream( + self.input_stream.map(|r| { + r.and_then(|item| E::to_bytes(&item).ok_or(StreamingError::Failed)) + }), + ) + .await + } + } +} + +impl std::fmt::Debug for Streaming { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_tuple("Streaming").finish() + } +} + +impl std::fmt::Debug for Streaming { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Streaming") + .field("encoding", &std::any::type_name::()) + .finish() + } +} diff --git a/packages/fullstack/src/payloads/text.rs b/packages/fullstack/src/payloads/text.rs new file mode 100644 index 000000000..0fffc5220 --- /dev/null +++ b/packages/fullstack/src/payloads/text.rs @@ -0,0 +1,38 @@ +use crate::{ClientResponse, FromResponse}; +use axum_core::response::{IntoResponse, Response}; +use dioxus_fullstack_core::ServerFnError; +use send_wrapper::SendWrapper; +use std::future::Future; + +/// A simple text response type. +/// +/// The `T` parameter can be anything that converts to and from `String`, such as `Rc` or `String`. +/// +/// Unlike `Json` or plain `String`, this uses the `text/plain` content type. The `text/plain` header +/// will be set on the request. +pub struct Text(pub T); + +impl Text { + /// Create a new text response. + pub fn new(text: T) -> Self { + Self(text) + } +} + +impl> IntoResponse for Text { + fn into_response(self) -> Response { + Response::builder() + .header("Content-Type", "text/plain; charset=utf-8") + .body(axum_core::body::Body::from(self.0.into())) + .unwrap() + } +} + +impl> FromResponse for Text { + fn from_response(res: ClientResponse) -> impl Future> { + SendWrapper::new(async move { + let text = res.text().await?; + Ok(Text::new(text.into())) + }) + } +} diff --git a/packages/fullstack/src/payloads/websocket.rs b/packages/fullstack/src/payloads/websocket.rs new file mode 100644 index 000000000..b0928b5be --- /dev/null +++ b/packages/fullstack/src/payloads/websocket.rs @@ -0,0 +1,1459 @@ +#![allow(unreachable_code)] +#![allow(unused_imports)] + +//! This module implements WebSocket support for Dioxus Fullstack applications. +//! +//! WebSockets provide a full-duplex communication channel over a single, long-lived connection. +//! +//! This makes them ideal for real-time applications where the server and the client need to communicate +//! frequently and with low latency. Unlike Server-Sent Events (SSE), WebSockets allow the direct +//! transport of binary data, enabling things like video and audio streaming as well as more efficient +//! zero-copy serialization formats. +//! +//! This module implements a variety of types: +//! - `Websocket`: Represents a WebSocket connection that can send messages of type `In` and receive messages of type `Out`, using the encoding `E`. +//! - `UseWebsocket`: A hook that provides a reactive interface to a WebSocket connection. +//! - `WebSocketOptions`: Configuration options for establishing a WebSocket connection. +//! - `TypedWebsocket`: A typed wrapper around an Axum WebSocket connection for server-side use. +//! - `WebsocketState`: An enum representing the state of the WebSocket connection. +//! - plus a variety of error types and traits for encoding/decoding messages. +//! +//! Dioxus Fullstack websockets are typed in both directions, letting the happy path (`.send()` and `.recv()`) +//! automatically serialize and deserialize messages for you. + +use crate::{ClientRequest, Encoding, FromResponse, IntoRequest, JsonEncoding, ServerFnError}; +use axum::{ + extract::{FromRequest, Request}, + http::StatusCode, +}; +use axum_core::response::{IntoResponse, Response}; +use bytes::Bytes; +use dioxus_core::{use_hook, CapturedError, Result}; +use dioxus_fullstack_core::{HttpError, RequestError}; +use dioxus_hooks::{use_resource, Resource, UseWaker}; +use dioxus_hooks::{use_signal, use_waker}; +use dioxus_signals::{ReadSignal, ReadableExt, ReadableOptionExt, Signal, WritableExt}; +use futures::StreamExt; +use futures::{ + stream::{SplitSink, SplitStream}, + SinkExt, TryFutureExt, +}; +use serde::{de::DeserializeOwned, Serialize}; +use std::{marker::PhantomData, prelude::rust_2024::Future}; + +#[cfg(feature = "web")] +use { + futures_util::lock::Mutex, + gloo_net::websocket::{futures::WebSocket as WsWebsocket, Message as WsMessage}, +}; + +/// A hook that provides a reactive interface to a WebSocket connection. +/// +/// WebSockets provide a full-duplex communication channel over a single, long-lived connection. +/// +/// This makes them ideal for real-time applications where the server and the client need to communicate +/// frequently and with low latency. Unlike Server-Sent Events (SSE), WebSockets allow the direct +/// transport of binary data, enabling things like video and audio streaming as well as more efficient +/// zero-copy serialization formats. +/// +/// This hook takes a function that returns a future which resolves to a `Websocket` - +/// usually a server function. +pub fn use_websocket< + In: 'static, + Out: 'static, + E: Into + 'static, + F: Future, E>> + 'static, + Enc: Encoding, +>( + mut connect_to_websocket: impl FnMut() -> F + 'static, +) -> UseWebsocket { + let mut waker = use_waker(); + let mut status = use_signal(|| WebsocketState::Connecting); + let status_read = use_hook(|| ReadSignal::new(status)); + + let connection = use_resource(move || { + let connection = connect_to_websocket().map_err(|e| CapturedError::from(e.into())); + async move { + let connection = connection.await; + + // Update the status based on the result of the connection attempt + match connection.as_ref() { + Ok(_) => status.set(WebsocketState::Open), + Err(_) => status.set(WebsocketState::FailedToConnect), + } + + // Wake up the `.recv()` calls waiting for the connection to be established + waker.wake(()); + + connection + } + }); + + UseWebsocket { + connection, + waker, + status, + status_read, + } +} + +/// The return type of the `use_websocket` hook. +/// +/// See the `use_websocket` documentation for more details. +/// +/// This handle provides methods to send and receive messages, check the connection status, +/// and wait for the connection to be established. +pub struct UseWebsocket +where + In: 'static, + Out: 'static, + Enc: 'static, +{ + connection: Resource, CapturedError>>, + waker: UseWaker<()>, + status: Signal, + status_read: ReadSignal, +} + +impl UseWebsocket { + /// Wait for the connection to be established. This guarantees that subsequent calls to methods like + /// `.try_recv()` will not fail due to the connection not being ready. + pub async fn connect(&self) -> WebsocketState { + // Wait for the connection to be established + while !self.connection.finished() { + _ = self.waker.wait().await; + } + + self.status.cloned() + } + + /// Returns true if the WebSocket is currently connecting. + /// + /// This can be useful to present a loading state to the user while the connection is being established. + pub fn connecting(&self) -> bool { + matches!(self.status.cloned(), WebsocketState::Connecting) + } + + /// Returns true if the Websocket is closed due to an error. + pub fn is_err(&self) -> bool { + matches!(self.status.cloned(), WebsocketState::FailedToConnect) + } + + /// Returns true if the WebSocket is currently shut down and cannot be used to send or receive messages. + pub fn is_closed(&self) -> bool { + matches!( + self.status.cloned(), + WebsocketState::Closed | WebsocketState::FailedToConnect + ) + } + + /// Get the current status of the WebSocket connection. + pub fn status(&self) -> ReadSignal { + self.status_read + } + + /// Send a raw message over the WebSocket connection + /// + /// To send a message with a particular type, see the `.send()` method instead. + pub async fn send_raw(&self, msg: Message) -> Result<(), WebsocketError> { + self.connect().await; + + self.connection + .as_ref() + .as_deref() + .ok_or_else(WebsocketError::closed_away)? + .as_ref() + .map_err(|_| WebsocketError::AlreadyClosed)? + .send_raw(msg) + .await + } + + /// Receive a raw message from the WebSocket connection + /// + /// To receive a message with a particular type, see the `.recv()` method instead. + pub async fn recv_raw(&mut self) -> Result { + self.connect().await; + + let result = self + .connection + .as_ref() + .as_deref() + .ok_or_else(WebsocketError::closed_away)? + .as_ref() + .map_err(|_| WebsocketError::AlreadyClosed)? + .recv_raw() + .await; + + if let Err(WebsocketError::ConnectionClosed { .. }) = result.as_ref() { + self.received_shutdown(); + } + + result + } + + pub async fn send(&self, msg: In) -> Result<(), WebsocketError> + where + In: Serialize, + E: Encoding, + { + self.send_raw(Message::Binary( + E::to_bytes(&msg).ok_or_else(WebsocketError::serialization)?, + )) + .await + } + + /// Receive the next message from the WebSocket connection, deserialized into the `Out` type. + /// + /// If the connection is still opening, this will wait until the connection is established. + /// If the connection fails to open or is killed while waiting, an error will be returned. + /// + /// This method returns an error if the connection is closed since we assume closed connections + /// are a "failure". + pub async fn recv(&mut self) -> Result + where + Out: DeserializeOwned, + E: Encoding, + { + self.connect().await; + + let result = self + .connection + .as_ref() + .as_deref() + .ok_or_else(WebsocketError::closed_away)? + .as_ref() + .map_err(|_| WebsocketError::AlreadyClosed)? + .recv() + .await; + + if let Err(WebsocketError::ConnectionClosed { .. }) = result.as_ref() { + self.received_shutdown(); + } + + result + } + + /// Mark the WebSocket as closed. This is called internally when the connection is closed. + fn received_shutdown(&self) { + let mut _self = *self; + _self.status.set(WebsocketState::Closed); + _self.waker.wake(()); + } +} + +impl Copy for UseWebsocket {} +impl Clone for UseWebsocket { + fn clone(&self) -> Self { + *self + } +} + +#[derive(Debug, Clone, PartialEq, Copy)] +pub enum WebsocketState { + /// The WebSocket is connecting. + Connecting, + + /// The WebSocket is open and ready to send and receive messages. + Open, + + /// The WebSocket is closing. + Closing, + + /// The WebSocket is closed and cannot be used to send or receive messages. + Closed, + + /// The WebSocket failed to connect + FailedToConnect, +} + +/// A WebSocket connection that can send and receive messages of type `In` and `Out`. +pub struct Websocket { + protocol: Option, + + #[allow(clippy::type_complexity)] + _in: std::marker::PhantomData (In, Out, E)>, + + #[cfg(not(target_arch = "wasm32"))] + native: Option, + + #[cfg(feature = "web")] + web: Option, + + response: Option, +} + +impl Websocket { + pub async fn recv(&self) -> Result + where + O: DeserializeOwned, + E: Encoding, + { + loop { + let msg = self.recv_raw().await?; + match msg { + Message::Text(text) => { + let e: O = + E::from_bytes(text.into()).ok_or_else(WebsocketError::deserialization)?; + return Ok(e); + } + Message::Binary(bytes) => { + let e: O = E::from_bytes(bytes).ok_or_else(WebsocketError::deserialization)?; + return Ok(e); + } + Message::Close { code, reason } => { + return Err(WebsocketError::ConnectionClosed { + code, + description: reason, + }); + } + + // todo - are we supposed to response to pings? + Message::Ping(_bytes) => continue, + Message::Pong(_bytes) => continue, + } + } + } + + /// Send a typed message over the WebSocket connection. + /// + /// This method serializes the message using the specified encoding `E` before sending it. + /// The message will always be sent as a binary message, even if the encoding is valid UTF-8 + /// like JSON. + pub async fn send(&self, msg: I) -> Result<(), WebsocketError> + where + I: Serialize, + E: Encoding, + { + let bytes = E::to_bytes(&msg).ok_or_else(WebsocketError::serialization)?; + self.send_raw(Message::Binary(bytes)).await + } + + /// Send a raw message over the WebSocket connection. + /// + /// This method allows sending text, binary, ping, pong, and close messages directly. + pub async fn send_raw(&self, message: Message) -> Result<(), WebsocketError> { + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + let mut sender = self + .web + .as_ref() + .ok_or_else(|| WebsocketError::Uninitialized)? + .sender + .lock() + .await; + + match message { + Message::Text(s) => { + sender.send(gloo_net::websocket::Message::Text(s)).await?; + } + Message::Binary(bytes) => { + sender + .send(gloo_net::websocket::Message::Bytes(bytes.into())) + .await?; + } + Message::Close { .. } => { + sender.close().await?; + } + Message::Ping(_bytes) => return Ok(()), + Message::Pong(_bytes) => return Ok(()), + } + + return Ok(()); + } + + #[cfg(not(target_arch = "wasm32"))] + { + let mut sender = self + .native + .as_ref() + .ok_or_else(|| WebsocketError::Uninitialized)? + .sender + .lock() + .await; + + sender + .send(message.into()) + .await + .map_err(WebsocketError::from)?; + } + + Ok(()) + } + + /// Receive a raw message from the WebSocket connection. + pub async fn recv_raw(&self) -> Result { + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + let mut conn = self.web.as_ref().unwrap().receiver.lock().await; + return match conn.next().await { + Some(Ok(WsMessage::Text(text))) => Ok(Message::Text(text)), + Some(Ok(WsMessage::Bytes(items))) => Ok(Message::Binary(items.into())), + Some(Err(e)) => Err(WebsocketError::from(e)), + None => Err(WebsocketError::closed_away()), + }; + } + + #[cfg(not(target_arch = "wasm32"))] + { + use tungstenite::Message as TMessage; + let mut conn = self.native.as_ref().unwrap().receiver.lock().await; + return match conn.next().await { + Some(Ok(res)) => match res { + TMessage::Text(utf8_bytes) => Ok(Message::Text(utf8_bytes.to_string())), + TMessage::Binary(bytes) => Ok(Message::Binary(bytes)), + TMessage::Close(Some(cf)) => Ok(Message::Close { + code: cf.code.into(), + reason: cf.reason.to_string(), + }), + TMessage::Close(None) => Ok(Message::Close { + code: CloseCode::Away, + reason: "Away".to_string(), + }), + TMessage::Ping(bytes) => Ok(Message::Ping(bytes)), + TMessage::Pong(bytes) => Ok(Message::Pong(bytes)), + TMessage::Frame(_frame) => Err(WebsocketError::Unexpected), + }, + Some(Err(e)) => Err(WebsocketError::from(e)), + None => Err(WebsocketError::closed_away()), + }; + } + + unimplemented!("Non web wasm32 clients are not supported yet") + } + + pub fn protocol(&self) -> Option<&str> { + self.protocol.as_deref() + } +} + +// no two websockets are ever equal +impl PartialEq for Websocket { + fn eq(&self, _other: &Self) -> bool { + false + } +} + +// Create a new WebSocket connection that uses the provided function to handle incoming messages +impl IntoResponse for Websocket { + fn into_response(self) -> Response { + let Some(response) = self.response else { + return HttpError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "WebSocket response not initialized", + ) + .into_response(); + }; + + response.into_response() + } +} + +impl FromResponse for Websocket { + fn from_response(res: UpgradingWebsocket) -> impl Future> { + async move { + #[cfg(not(target_arch = "wasm32"))] + let native = res.native; + + #[cfg(feature = "web")] + let web = res.web.map(|f| { + let (sender, receiver) = f.split(); + WebsysSocket { + sender: Mutex::new(sender), + receiver: Mutex::new(receiver), + } + }); + + Ok(Websocket { + protocol: res.protocol, + #[cfg(not(target_arch = "wasm32"))] + native, + #[cfg(feature = "web")] + web, + response: None, + _in: PhantomData, + }) + } + } +} + +pub struct WebSocketOptions { + protocols: Vec, + automatic_reconnect: bool, + #[cfg(feature = "server")] + upgrade: Option, + #[cfg(feature = "server")] + on_failed_upgrade: Option>, +} + +impl WebSocketOptions { + pub fn new() -> Self { + Self { + protocols: Vec::new(), + automatic_reconnect: false, + + #[cfg(feature = "server")] + upgrade: None, + + #[cfg(feature = "server")] + on_failed_upgrade: None, + } + } + + /// Automatically reconnect if the connection is lost. This uses an exponential backoff strategy. + pub fn with_automatic_reconnect(mut self) -> Self { + self.automatic_reconnect = true; + self + } + + #[cfg(feature = "server")] + pub fn on_failed_upgrade( + mut self, + callback: impl FnOnce(axum::Error) + Send + 'static, + ) -> Self { + self.on_failed_upgrade = Some(Box::new(callback)); + + self + } + + #[cfg(feature = "server")] + pub fn on_upgrade(mut self, callback: F) -> Websocket + where + F: FnOnce(TypedWebsocket) -> Fut + Send + 'static, + Fut: Future + 'static, + { + let on_failed_upgrade = self.on_failed_upgrade.take(); + let response = self + .upgrade + .unwrap() + .on_failed_upgrade(|e| { + if let Some(callback) = on_failed_upgrade { + callback(e); + } + }) + .on_upgrade(|socket| { + let res = crate::spawn_platform(move || { + callback(TypedWebsocket { + _in: PhantomData, + _out: PhantomData, + _enc: PhantomData, + inner: socket, + }) + }); + async move { + let _ = res.await; + } + }); + + Websocket { + // the protocol is none here since it won't be accessible until after the upgrade + protocol: None, + response: Some(response), + _in: PhantomData, + + #[cfg(not(target_arch = "wasm32"))] + native: None, + + #[cfg(feature = "web")] + web: None, + } + } +} + +impl Default for WebSocketOptions { + fn default() -> Self { + Self::new() + } +} + +impl IntoRequest for WebSocketOptions { + fn into_request( + self, + request: ClientRequest, + ) -> impl Future> + 'static { + async move { + #[cfg(feature = "web")] + if cfg!(target_arch = "wasm32") { + let url_path = request.url().path(); + let url_query = request.url().query(); + let url_fragment = request.url().fragment(); + let path_and_query = format!( + "{}{}{}", + url_path, + url_query.map_or("".to_string(), |q| format!("?{q}")), + url_fragment.map_or("".to_string(), |f| format!("#{f}")) + ); + + let socket = gloo_net::websocket::futures::WebSocket::open_with_protocols( + // ! very important we use the path here and not the full url on web. + // for as long as serverfns are meant to target the same origin, this is fine. + &path_and_query, + &self + .protocols + .iter() + .map(String::as_str) + .collect::>(), + ) + .unwrap(); + + return Ok(UpgradingWebsocket { + protocol: Some(socket.protocol()), + web: Some(socket), + #[cfg(not(target_arch = "wasm32"))] + native: None, + }); + } + + #[cfg(not(target_arch = "wasm32"))] + { + let response = native::send_request(request, &self.protocols) + .await + .unwrap(); + + let (inner, protocol) = response + .into_stream_and_protocol(self.protocols, None) + .await + .unwrap(); + + return Ok(UpgradingWebsocket { + protocol, + native: Some(inner), + #[cfg(feature = "web")] + web: None, + }); + } + + unimplemented!("Non web wasm32 clients are not supported yet") + } + } +} + +impl FromRequest for WebSocketOptions { + type Rejection = axum::response::Response; + + fn from_request( + _req: Request, + _: &S, + ) -> impl Future> + Send { + #[cfg(not(feature = "server"))] + return async move { Err(StatusCode::NOT_IMPLEMENTED.into_response()) }; + + #[cfg(feature = "server")] + async move { + let ws = match axum::extract::ws::WebSocketUpgrade::from_request(_req, &()).await { + Ok(ws) => ws, + Err(rejection) => return Err(rejection.into_response()), + }; + + Ok(WebSocketOptions { + protocols: vec![], + automatic_reconnect: false, + upgrade: Some(ws), + on_failed_upgrade: None, + }) + } + } +} + +#[doc(hidden)] +pub struct UpgradingWebsocket { + protocol: Option, + + #[cfg(feature = "web")] + web: Option, + + #[cfg(not(target_arch = "wasm32"))] + native: Option, +} + +unsafe impl Send for UpgradingWebsocket {} +unsafe impl Sync for UpgradingWebsocket {} + +#[cfg(feature = "server")] +pub struct TypedWebsocket { + _in: std::marker::PhantomData In>, + _out: std::marker::PhantomData Out>, + _enc: std::marker::PhantomData E>, + + inner: axum::extract::ws::WebSocket, +} + +#[cfg(feature = "server")] +impl TypedWebsocket { + /// Receive an incoming message from the client. + /// + /// Returns `None` if the stream has closed. + pub async fn recv(&mut self) -> Result { + use axum::extract::ws::Message as AxumMessage; + + loop { + let Some(res) = self.inner.next().await else { + return Err(WebsocketError::closed_away()); + }; + + match res { + Ok(res) => match res { + AxumMessage::Text(utf8_bytes) => { + let e: In = E::from_bytes(utf8_bytes.into()) + .ok_or_else(WebsocketError::deserialization)?; + return Ok(e); + } + AxumMessage::Binary(bytes) => { + let e: In = + E::from_bytes(bytes).ok_or_else(WebsocketError::deserialization)?; + return Ok(e); + } + + AxumMessage::Close(Some(close_frame)) => { + return Err(WebsocketError::ConnectionClosed { + code: close_frame.code.into(), + description: close_frame.reason.to_string(), + }); + } + AxumMessage::Close(None) => return Err(WebsocketError::AlreadyClosed), + + AxumMessage::Ping(_bytes) => continue, + AxumMessage::Pong(_bytes) => continue, + }, + Err(_res) => return Err(WebsocketError::closed_away()), + } + } + } + + /// Send an outgoing message. + pub async fn send(&mut self, msg: Out) -> Result<(), WebsocketError> { + use axum::extract::ws::Message; + + let to_bytes = E::to_bytes(&msg).ok_or_else(|| { + WebsocketError::Serialization(anyhow::anyhow!("Failed to serialize message").into()) + })?; + + self.inner + .send(Message::Binary(to_bytes)) + .await + .map_err(|_err| WebsocketError::AlreadyClosed) + } + + /// Receive another message. + /// + /// Returns `None` if the stream has closed. + pub async fn recv_raw(&mut self) -> Result { + use axum::extract::ws::Message as AxumMessage; + + let message = self + .inner + .next() + .await + .ok_or_else(WebsocketError::closed_away)? + .map_err(|_| WebsocketError::AlreadyClosed)?; + + Ok(match message { + AxumMessage::Text(utf8_bytes) => Message::Text(utf8_bytes.to_string()), + AxumMessage::Binary(bytes) => Message::Binary(bytes), + AxumMessage::Ping(bytes) => Message::Ping(bytes), + AxumMessage::Pong(bytes) => Message::Pong(bytes), + AxumMessage::Close(close_frame) => Message::Close { + code: close_frame + .clone() + .map_or(CloseCode::Away, |cf| cf.code.into()), + reason: close_frame.map_or("Away".to_string(), |cf| cf.reason.to_string()), + }, + }) + } + + /// Send a message. + pub async fn send_raw(&mut self, msg: Message) -> Result<(), WebsocketError> { + let real = match msg { + Message::Text(text) => axum::extract::ws::Message::Text(text.into()), + Message::Binary(bytes) => axum::extract::ws::Message::Binary(bytes), + Message::Ping(bytes) => axum::extract::ws::Message::Ping(bytes), + Message::Pong(bytes) => axum::extract::ws::Message::Pong(bytes), + Message::Close { code, reason } => { + axum::extract::ws::Message::Close(Some(axum::extract::ws::CloseFrame { + code: code.into(), + reason: reason.into(), + })) + } + }; + + self.inner + .send(real) + .await + .map_err(|_err| WebsocketError::AlreadyClosed) + } + + /// Return the selected WebSocket subprotocol, if one has been chosen. + pub fn protocol(&self) -> Option<&http::HeaderValue> { + self.inner.protocol() + } + + /// Get a mutable reference to the underlying Axum WebSocket. + pub fn socket(&mut self) -> &mut axum::extract::ws::WebSocket { + &mut self.inner + } +} + +#[derive(thiserror::Error, Debug)] +pub enum WebsocketError { + #[error("Connection closed")] + ConnectionClosed { + code: CloseCode, + description: String, + }, + + #[error("WebSocket already closed")] + AlreadyClosed, + + #[error("WebSocket capacity reached")] + Capacity, + + #[error("An unexpected internal error occurred")] + Unexpected, + + #[error("WebSocket is not initialized on this platform")] + Uninitialized, + + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(docsrs, doc(cfg(not(target_arch = "wasm32"))))] + #[error("websocket upgrade failed")] + Handshake(#[from] native::HandshakeError), + + #[error("reqwest error")] + Reqwest(#[from] reqwest::Error), + + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(docsrs, doc(cfg(not(target_arch = "wasm32"))))] + #[error("tungstenite error")] + Tungstenite(#[from] tungstenite::Error), + + /// Error during serialization/deserialization. + #[error("error during serialization/deserialization")] + Deserialization(Box), + + /// Error during serialization/deserialization. + #[error("error during serialization/deserialization")] + Serialization(Box), + + /// Error during serialization/deserialization. + #[error("serde_json error")] + #[cfg_attr(docsrs, doc(cfg(feature = "json")))] + Json(#[from] serde_json::Error), + + /// Error during serialization/deserialization. + #[error("ciborium error")] + Cbor(#[from] ciborium::de::Error), +} + +#[cfg(feature = "web")] +impl From for WebsocketError { + fn from(value: gloo_net::websocket::WebSocketError) -> Self { + use gloo_net::websocket::WebSocketError; + match value { + WebSocketError::ConnectionError => WebsocketError::AlreadyClosed, + WebSocketError::ConnectionClose(close_event) => WebsocketError::ConnectionClosed { + code: close_event.code.into(), + description: close_event.reason, + }, + WebSocketError::MessageSendError(_js_error) => WebsocketError::Unexpected, + _ => WebsocketError::Unexpected, + } + } +} + +impl WebsocketError { + pub fn closed_away() -> Self { + Self::ConnectionClosed { + code: CloseCode::Normal, + description: "Connection closed normally".into(), + } + } + + pub fn deserialization() -> Self { + Self::Deserialization(anyhow::anyhow!("Failed to deserialize message").into()) + } + + pub fn serialization() -> Self { + Self::Serialization(anyhow::anyhow!("Failed to serialize message").into()) + } +} + +#[cfg(feature = "web")] +struct WebsysSocket { + sender: Mutex>, + receiver: Mutex>, +} + +/// A `WebSocket` message, which can be a text string or binary data. +#[derive(Clone, Debug)] +pub enum Message { + /// A text `WebSocket` message. + // note: we can't use `tungstenite::Utf8String` here, since we don't have tungstenite on wasm. + Text(String), + + /// A binary `WebSocket` message. + Binary(Bytes), + + /// A ping message with the specified payload. + /// + /// The payload here must have a length less than 125 bytes. + /// + /// # WASM + /// + /// This variant is ignored for WASM targets. + Ping(Bytes), + + /// A pong message with the specified payload. + /// + /// The payload here must have a length less than 125 bytes. + /// + /// # WASM + /// + /// This variant is ignored for WASM targets. + Pong(Bytes), + + /// A close message. + /// + /// Sending this will not close the connection, though the remote peer will likely close the connection after receiving this. + Close { code: CloseCode, reason: String }, +} + +impl From for Message { + #[inline] + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&str> for Message { + #[inline] + fn from(value: &str) -> Self { + Self::from(value.to_owned()) + } +} + +impl From for Message { + #[inline] + fn from(value: Bytes) -> Self { + Self::Binary(value) + } +} + +impl From> for Message { + #[inline] + fn from(value: Vec) -> Self { + Self::from(Bytes::from(value)) + } +} + +impl From<&[u8]> for Message { + #[inline] + fn from(value: &[u8]) -> Self { + Self::from(Bytes::copy_from_slice(value)) + } +} + +/// Status code used to indicate why an endpoint is closing the `WebSocket` +/// connection.[1] +/// +/// [1]: https://datatracker.ietf.org/doc/html/rfc6455 +#[derive(Debug, Default, Eq, PartialEq, Clone, Copy)] +#[non_exhaustive] +pub enum CloseCode { + /// Indicates a normal closure, meaning that the purpose for + /// which the connection was established has been fulfilled. + #[default] + Normal, + + /// Indicates that an endpoint is "going away", such as a server + /// going down or a browser having navigated away from a page. + Away, + + /// Indicates that an endpoint is terminating the connection due + /// to a protocol error. + Protocol, + + /// Indicates that an endpoint is terminating the connection + /// because it has received a type of data it cannot accept (e.g., an + /// endpoint that understands only text data MAY send this if it + /// receives a binary message). + Unsupported, + + /// Indicates that no status code was included in a closing frame. This + /// close code makes it possible to use a single method, `on_close` to + /// handle even cases where no close code was provided. + Status, + + /// Indicates an abnormal closure. If the abnormal closure was due to an + /// error, this close code will not be used. Instead, the `on_error` method + /// of the handler will be called with the error. However, if the connection + /// is simply dropped, without an error, this close code will be sent to the + /// handler. + Abnormal, + + /// Indicates that an endpoint is terminating the connection + /// because it has received data within a message that was not + /// consistent with the type of the message (e.g., non-UTF-8 \[RFC3629\] + /// data within a text message). + Invalid, + + /// Indicates that an endpoint is terminating the connection + /// because it has received a message that violates its policy. This + /// is a generic status code that can be returned when there is no + /// other more suitable status code (e.g., Unsupported or Size) or if there + /// is a need to hide specific details about the policy. + Policy, + + /// Indicates that an endpoint is terminating the connection + /// because it has received a message that is too big for it to + /// process. + Size, + + /// Indicates that an endpoint (client) is terminating the + /// connection because it has expected the server to negotiate one or + /// more extension, but the server didn't return them in the response + /// message of the `WebSocket` handshake. The list of extensions that + /// are needed should be given as the reason for closing. + /// Note that this status code is not used by the server, because it + /// can fail the `WebSocket` handshake instead. + Extension, + + /// Indicates that a server is terminating the connection because + /// it encountered an unexpected condition that prevented it from + /// fulfilling the request. + Error, + + /// Indicates that the server is restarting. A client may choose to + /// reconnect, and if it does, it should use a randomized delay of 5-30 + /// seconds between attempts. + Restart, + + /// Indicates that the server is overloaded and the client should either + /// connect to a different IP (when multiple targets exist), or + /// reconnect to the same IP when a user has performed an action. + Again, + + /// Indicates that the connection was closed due to a failure to perform a + /// TLS handshake (e.g., the server certificate can't be verified). This + /// is a reserved value and MUST NOT be set as a status code in a Close + /// control frame by an endpoint. + Tls, + + /// Reserved status codes. + Reserved(u16), + + /// Reserved for use by libraries, frameworks, and applications. These + /// status codes are registered directly with IANA. The interpretation of + /// these codes is undefined by the `WebSocket` protocol. + Iana(u16), + + /// Reserved for private use. These can't be registered and can be used by + /// prior agreements between `WebSocket` applications. The interpretation of + /// these codes is undefined by the `WebSocket` protocol. + Library(u16), + + /// Unused / invalid status codes. + Bad(u16), +} + +impl CloseCode { + /// Check if this `CloseCode` is allowed. + #[must_use] + pub const fn is_allowed(self) -> bool { + !matches!( + self, + Self::Bad(_) | Self::Reserved(_) | Self::Status | Self::Abnormal | Self::Tls + ) + } +} + +impl std::fmt::Display for CloseCode { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let code: u16 = (*self).into(); + write!(f, "{code}") + } +} + +impl From for u16 { + fn from(code: CloseCode) -> Self { + match code { + CloseCode::Normal => 1000, + CloseCode::Away => 1001, + CloseCode::Protocol => 1002, + CloseCode::Unsupported => 1003, + CloseCode::Status => 1005, + CloseCode::Abnormal => 1006, + CloseCode::Invalid => 1007, + CloseCode::Policy => 1008, + CloseCode::Size => 1009, + CloseCode::Extension => 1010, + CloseCode::Error => 1011, + CloseCode::Restart => 1012, + CloseCode::Again => 1013, + CloseCode::Tls => 1015, + CloseCode::Reserved(code) + | CloseCode::Iana(code) + | CloseCode::Library(code) + | CloseCode::Bad(code) => code, + } + } +} + +impl From for CloseCode { + fn from(code: u16) -> Self { + match code { + 1000 => Self::Normal, + 1001 => Self::Away, + 1002 => Self::Protocol, + 1003 => Self::Unsupported, + 1005 => Self::Status, + 1006 => Self::Abnormal, + 1007 => Self::Invalid, + 1008 => Self::Policy, + 1009 => Self::Size, + 1010 => Self::Extension, + 1011 => Self::Error, + 1012 => Self::Restart, + 1013 => Self::Again, + 1015 => Self::Tls, + 1016..=2999 => Self::Reserved(code), + 3000..=3999 => Self::Iana(code), + 4000..=4999 => Self::Library(code), + _ => Self::Bad(code), + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::borrow::Cow; + + use crate::ClientRequest; + + use super::{CloseCode, Message, WebsocketError}; + use reqwest::{ + header::{HeaderName, HeaderValue}, + Response, StatusCode, Version, + }; + use tungstenite::protocol::WebSocketConfig; + + pub(crate) struct SplitSocket { + pub sender: futures_util::lock::Mutex< + async_tungstenite::WebSocketSender>, + >, + + pub receiver: futures_util::lock::Mutex< + async_tungstenite::WebSocketReceiver>, + >, + } + + pub async fn send_request( + request: ClientRequest, + protocols: &[String], + ) -> Result { + let request_builder = request.new_reqwest_request(); + let (client, request_result) = request_builder.build_split(); + let mut request = request_result?; + + // change the scheme from wss? to https? + let url = request.url_mut(); + match url.scheme() { + "ws" => { + url.set_scheme("http") + .expect("url should accept http scheme"); + } + "wss" => { + url.set_scheme("https") + .expect("url should accept https scheme"); + } + _ => {} + } + + // prepare request + let version = request.version(); + let nonce = match version { + Version::HTTP_10 | Version::HTTP_11 => { + // HTTP 1 requires us to set some headers. + let nonce_value = tungstenite::handshake::client::generate_key(); + let headers = request.headers_mut(); + headers.insert( + reqwest::header::CONNECTION, + HeaderValue::from_static("upgrade"), + ); + headers.insert( + reqwest::header::UPGRADE, + HeaderValue::from_static("websocket"), + ); + headers.insert( + reqwest::header::SEC_WEBSOCKET_KEY, + HeaderValue::from_str(&nonce_value).expect("nonce is a invalid header value"), + ); + headers.insert( + reqwest::header::SEC_WEBSOCKET_VERSION, + HeaderValue::from_static("13"), + ); + if !protocols.is_empty() { + headers.insert( + reqwest::header::SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_str(&protocols.join(", ")) + .expect("protocols is an invalid header value"), + ); + } + + Some(nonce_value) + } + Version::HTTP_2 => { + // TODO: Implement websocket upgrade for HTTP 2. + return Err(HandshakeError::UnsupportedHttpVersion(version).into()); + } + _ => { + return Err(HandshakeError::UnsupportedHttpVersion(version).into()); + } + }; + + // execute request + let response = client.execute(request).await?; + + Ok(WebSocketResponse { + response, + version, + nonce, + }) + } + + pub type WebSocketStream = + async_tungstenite::WebSocketStream>; + + /// Error during `Websocket` handshake. + #[derive(Debug, thiserror::Error, Clone)] + pub enum HandshakeError { + #[error("unsupported http version: {0:?}")] + UnsupportedHttpVersion(Version), + + #[error("the server responded with a different http version. this could be the case because reqwest silently upgraded the connection to http2. see: https://github.com/jgraef/reqwest-websocket/issues/2")] + ServerRespondedWithDifferentVersion, + + #[error("missing header {header}")] + MissingHeader { header: HeaderName }, + + #[error("unexpected value for header {header}: expected {expected}, but got {got:?}.")] + UnexpectedHeaderValue { + header: HeaderName, + got: HeaderValue, + expected: Cow<'static, str>, + }, + + #[error("expected the server to select a protocol.")] + ExpectedAProtocol, + + #[error("unexpected protocol: {got}")] + UnexpectedProtocol { got: String }, + + #[error("unexpected status code: {0}")] + UnexpectedStatusCode(StatusCode), + } + + pub struct WebSocketResponse { + pub response: Response, + pub version: Version, + pub nonce: Option, + } + + impl WebSocketResponse { + pub async fn into_stream_and_protocol( + self, + protocols: Vec, + web_socket_config: Option, + ) -> Result<(SplitSocket, Option), WebsocketError> { + let headers = self.response.headers(); + + if self.response.version() != self.version { + return Err(HandshakeError::ServerRespondedWithDifferentVersion.into()); + } + + if self.response.status() != reqwest::StatusCode::SWITCHING_PROTOCOLS { + tracing::debug!(status_code = %self.response.status(), "server responded with unexpected status code"); + return Err(HandshakeError::UnexpectedStatusCode(self.response.status()).into()); + } + + if let Some(header) = headers.get(reqwest::header::CONNECTION) { + if !header + .to_str() + .is_ok_and(|s| s.eq_ignore_ascii_case("upgrade")) + { + tracing::debug!("server responded with invalid Connection header: {header:?}"); + return Err(HandshakeError::UnexpectedHeaderValue { + header: reqwest::header::CONNECTION, + got: header.clone(), + expected: "upgrade".into(), + } + .into()); + } + } else { + tracing::debug!("missing Connection header"); + return Err(HandshakeError::MissingHeader { + header: reqwest::header::CONNECTION, + } + .into()); + } + + if let Some(header) = headers.get(reqwest::header::UPGRADE) { + if !header + .to_str() + .is_ok_and(|s| s.eq_ignore_ascii_case("websocket")) + { + tracing::debug!("server responded with invalid Upgrade header: {header:?}"); + return Err(HandshakeError::UnexpectedHeaderValue { + header: reqwest::header::UPGRADE, + got: header.clone(), + expected: "websocket".into(), + } + .into()); + } + } else { + tracing::debug!("missing Upgrade header"); + return Err(HandshakeError::MissingHeader { + header: reqwest::header::UPGRADE, + } + .into()); + } + + if let Some(nonce) = &self.nonce { + let expected_nonce = tungstenite::handshake::derive_accept_key(nonce.as_bytes()); + + if let Some(header) = headers.get(reqwest::header::SEC_WEBSOCKET_ACCEPT) { + if !header.to_str().is_ok_and(|s| s == expected_nonce) { + tracing::debug!( + "server responded with invalid Sec-Websocket-Accept header: {header:?}" + ); + return Err(HandshakeError::UnexpectedHeaderValue { + header: reqwest::header::SEC_WEBSOCKET_ACCEPT, + got: header.clone(), + expected: expected_nonce.into(), + } + .into()); + } + } else { + tracing::debug!("missing Sec-Websocket-Accept header"); + return Err(HandshakeError::MissingHeader { + header: reqwest::header::SEC_WEBSOCKET_ACCEPT, + } + .into()); + } + } + + let protocol = headers + .get(reqwest::header::SEC_WEBSOCKET_PROTOCOL) + .and_then(|v| v.to_str().ok()) + .map(ToOwned::to_owned); + + match (protocols.is_empty(), &protocol) { + (true, None) => { + // we didn't request any protocols, so we don't expect one + // in return + } + (false, None) => { + // server didn't reply with a protocol + return Err(HandshakeError::ExpectedAProtocol.into()); + } + (false, Some(protocol)) => { + if !protocols.contains(protocol) { + // the responded protocol is none which we requested + return Err(HandshakeError::UnexpectedProtocol { + got: protocol.clone(), + } + .into()); + } + } + (true, Some(protocol)) => { + // we didn't request any protocols but got one anyway + return Err(HandshakeError::UnexpectedProtocol { + got: protocol.clone(), + } + .into()); + } + } + + use tokio_util::compat::TokioAsyncReadCompatExt; + + let inner = WebSocketStream::from_raw_socket( + self.response.upgrade().await?.compat(), + tungstenite::protocol::Role::Client, + web_socket_config, + ) + .await; + + let split: ( + async_tungstenite::WebSocketSender>, + async_tungstenite::WebSocketReceiver>, + ) = inner.split(); + + let split_socket = SplitSocket { + sender: futures_util::lock::Mutex::new(split.0), + receiver: futures_util::lock::Mutex::new(split.1), + }; + + Ok((split_socket, protocol)) + } + } + + #[derive(Debug, thiserror::Error)] + #[error("could not convert message")] + pub struct FromTungsteniteMessageError { + pub original: tungstenite::Message, + } + + impl TryFrom for Message { + type Error = FromTungsteniteMessageError; + + fn try_from(value: tungstenite::Message) -> Result { + match value { + tungstenite::Message::Text(text) => Ok(Self::Text(text.as_str().to_owned())), + tungstenite::Message::Binary(data) => Ok(Self::Binary(data)), + tungstenite::Message::Ping(data) => Ok(Self::Ping(data)), + tungstenite::Message::Pong(data) => Ok(Self::Pong(data)), + tungstenite::Message::Close(Some(tungstenite::protocol::CloseFrame { + code, + reason, + })) => Ok(Self::Close { + code: code.into(), + reason: reason.as_str().to_owned(), + }), + tungstenite::Message::Close(None) => Ok(Self::Close { + code: CloseCode::default(), + reason: "".to_owned(), + }), + tungstenite::Message::Frame(_) => { + Err(FromTungsteniteMessageError { original: value }) + } + } + } + } + + impl From for tungstenite::Message { + fn from(value: Message) -> Self { + match value { + Message::Text(text) => Self::Text(tungstenite::Utf8Bytes::from(text)), + Message::Binary(data) => Self::Binary(data), + Message::Ping(data) => Self::Ping(data), + Message::Pong(data) => Self::Pong(data), + Message::Close { code, reason } => { + Self::Close(Some(tungstenite::protocol::CloseFrame { + code: code.into(), + reason: reason.into(), + })) + } + } + } + } + + impl From for CloseCode { + fn from(value: tungstenite::protocol::frame::coding::CloseCode) -> Self { + u16::from(value).into() + } + } + + impl From for tungstenite::protocol::frame::coding::CloseCode { + fn from(value: CloseCode) -> Self { + u16::from(value).into() + } + } +} diff --git a/packages/fullstack/src/request.rs b/packages/fullstack/src/request.rs new file mode 100644 index 000000000..690a4a6af --- /dev/null +++ b/packages/fullstack/src/request.rs @@ -0,0 +1,171 @@ +use dioxus_fullstack_core::{RequestError, ServerFnError}; +#[cfg(feature = "server")] +use headers::Header; +use http::response::Parts; +use std::{future::Future, pin::Pin}; + +use crate::{ClientRequest, ClientResponse}; + +/// The `IntoRequest` trait allows types to be used as the body of a request to a HTTP endpoint or server function. +/// +/// `IntoRequest` allows for types handle the calling of `ClientRequest::send` where the result is then +/// passed to `FromResponse` to decode the response. +/// +/// You can think of the `IntoRequest` and `FromResponse` traits are "inverse" traits of the axum +/// `FromRequest` and `IntoResponse` traits. Just like a type can be decoded from a request via `FromRequest`, +/// a type can be encoded into a request via `IntoRequest`. +/// +/// ## Generic State +/// +/// `IntoRequest` is generic over the response type `R` which defaults to `ClientResponse`. The default +/// `ClientResponse` is the base response type that internally wraps `reqwest::Response`. +/// +/// However, some responses might need state from the initial request to properly decode the response. +/// Most state can be extended via the `.extension()` method on `ClientRequest`. In some cases, like +/// websockets, the response needs to retain an initial connection from the request. Here, you can use +/// the `R` generic to specify a concrete response type. The resulting type that implements `FromResponse` +/// must also be generic over the same `R` type. +pub trait IntoRequest: Sized { + fn into_request( + self, + req: ClientRequest, + ) -> impl Future> + 'static; +} + +impl IntoRequest for (A,) +where + A: IntoRequest + 'static, +{ + fn into_request( + self, + req: ClientRequest, + ) -> impl Future> + 'static { + A::into_request(self.0, req) + } +} + +pub trait FromResponse: Sized { + fn from_response(res: R) -> impl Future>; +} + +impl
FromResponse for A +where + A: FromResponseParts, +{ + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let (parts, _body) = res.into_parts(); + let mut parts = parts; + A::from_response_parts(&mut parts) + } + } +} + +impl FromResponse for (A, B) +where + A: FromResponseParts, + B: FromResponse, +{ + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let mut parts = res.make_parts(); + let a = A::from_response_parts(&mut parts)?; + let b = B::from_response(res).await?; + Ok((a, b)) + } + } +} + +impl FromResponse for (A, B, C) +where + A: FromResponseParts, + B: FromResponseParts, + C: FromResponse, +{ + fn from_response(res: ClientResponse) -> impl Future> { + async move { + let mut parts = res.make_parts(); + let a = A::from_response_parts(&mut parts)?; + let b = B::from_response_parts(&mut parts)?; + let c = C::from_response(res).await?; + Ok((a, b, c)) + } + } +} + +pub trait FromResponseParts +where + Self: Sized, +{ + fn from_response_parts(parts: &mut Parts) -> Result; +} + +#[cfg(feature = "server")] +impl FromResponseParts for axum_extra::TypedHeader { + fn from_response_parts(parts: &mut Parts) -> Result { + use headers::HeaderMapExt; + + let t = parts + .headers + .typed_get::() + .ok_or_else(|| ServerFnError::Serialization("Invalid header value".into()))?; + + Ok(axum_extra::TypedHeader(t)) + } +} + +/* +todo: make the serverfns return ServerFnRequest which lets us control the future better +*/ +#[pin_project::pin_project] +#[must_use = "Requests do nothing unless you `.await` them"] +pub struct ServerFnRequest { + _phantom: std::marker::PhantomData, + #[pin] + fut: Pin + Send>>, +} + +impl ServerFnRequest { + pub fn new(res: impl Future + Send + 'static) -> Self { + ServerFnRequest { + _phantom: std::marker::PhantomData, + fut: Box::pin(res), + } + } +} + +impl std::future::Future for ServerFnRequest> { + type Output = Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + self.project().fut.poll(cx) + } +} + +#[doc(hidden)] +#[diagnostic::on_unimplemented( + message = "The return type of a server function must be `Result`", + note = "`T` is either `impl IntoResponse` *or* `impl Serialize`", + note = "`E` is either `From + Serialize`, `dioxus::Error` or `StatusCode`." +)] +pub trait AssertIsResult {} +impl AssertIsResult for Result {} + +#[doc(hidden)] +pub fn assert_is_result() {} + +#[diagnostic::on_unimplemented( + message = "The arguments to the server function must either be a single `impl FromRequest + IntoRequest` argument, or multiple `DeserializeOwned` arguments." +)] +pub trait AssertCanEncode {} + +pub struct CantEncode; + +pub struct EncodeIsVerified; +impl AssertCanEncode for EncodeIsVerified {} + +#[doc(hidden)] +pub fn assert_can_encode(_t: impl AssertCanEncode) {} diff --git a/packages/fullstack/src/spawn.rs b/packages/fullstack/src/spawn.rs new file mode 100644 index 000000000..694cc9128 --- /dev/null +++ b/packages/fullstack/src/spawn.rs @@ -0,0 +1,31 @@ +use std::future::Future; + +/// Spawn a task in the background. If wasm is enabled, this will use the single threaded tokio runtime +pub(crate) fn spawn_platform( + f: impl FnOnce() -> Fut + Send + 'static, +) -> tokio::task::JoinHandle +where + Fut: Future + 'static, + Fut::Output: Send + 'static, +{ + #[cfg(not(target_arch = "wasm32"))] + { + use tokio_util::task::LocalPoolHandle; + static TASK_POOL: std::sync::OnceLock = std::sync::OnceLock::new(); + + let pool = TASK_POOL.get_or_init(|| { + LocalPoolHandle::new( + std::thread::available_parallelism() + .map(usize::from) + .unwrap_or(1), + ) + }); + + pool.spawn_pinned(f) + } + + #[cfg(target_arch = "wasm32")] + { + tokio::task::spawn_local(f()) + } +} diff --git a/packages/fullstack/tests/compile-test.rs b/packages/fullstack/tests/compile-test.rs new file mode 100644 index 000000000..0534f6967 --- /dev/null +++ b/packages/fullstack/tests/compile-test.rs @@ -0,0 +1,344 @@ +#![allow(clippy::manual_async_fn)] +#![allow(unused_variables)] + +use anyhow::Result; +use axum::extract::FromRequest; +use axum::response::IntoResponse; +use axum::{response::Html, Json}; +use bytes::Bytes; +use dioxus::prelude::*; +use dioxus_fullstack::{get, FileStream, ServerFnError, Text, TextStream, Websocket}; +use futures::StreamExt; +use http::HeaderMap; +use http::StatusCode; +use http_body_util::BodyExt; +use serde::{Deserialize, Serialize}; +use std::future::Future; + +fn main() {} + +mod simple_extractors { + use super::*; + + /// We can extract the state and return anything thats IntoResponse + #[get("/home")] + async fn one() -> Result { + Ok("hello home".to_string()) + } + + /// We can extract the path arg and return anything thats IntoResponse + #[get("/home/{id}")] + async fn two(id: String) -> Result { + Ok(format!("hello home {}", id)) + } + + /// We can do basically nothing + #[get("/")] + async fn three() -> Result<()> { + Ok(()) + } + + /// We can do basically nothing, with args + #[get("/{one}/{two}?a&b&c")] + async fn four(one: String, two: String, a: String, b: String, c: String) -> Result<()> { + Ok(()) + } + + /// We can return anything that implements IntoResponse + #[get("/hello")] + async fn five() -> Result> { + Ok(Html("

Hello!

".to_string())) + } + + /// We can return anything that implements IntoResponse + #[get("/hello")] + async fn six() -> Result> { + Ok(Json("Hello!".to_string())) + } + + /// We can return our own custom `Text` type for sending plain text + #[get("/hello")] + async fn six_2() -> Result> { + Ok(Text("Hello!".to_string())) + } + + /// We can return our own custom TextStream type for sending plain text streams + #[get("/hello")] + async fn six_3() -> Result { + Ok(TextStream::new(futures::stream::iter(vec![ + "Hello 1".to_string(), + "Hello 2".to_string(), + "Hello 3".to_string(), + ]))) + } + + /// We can return a Result with anything that implements IntoResponse + #[get("/hello")] + async fn seven() -> Result { + Ok(Bytes::from_static(b"Hello!")) + } + + /// We can return a Result with anything that implements IntoResponse + #[get("/hello")] + async fn eight() -> Result { + Ok(Bytes::from_static(b"Hello!")) + } + + /// We can use the anyhow error type + #[get("/hello")] + async fn nine() -> Result { + Ok(Bytes::from_static(b"Hello!")) + } + + /// We can use the ServerFnError error type + #[get("/hello")] + async fn ten() -> Result { + Ok(Bytes::from_static(b"Hello!")) + } + + /// We can use the ServerFnError error type + #[get("/hello")] + async fn elevent() -> Result { + Ok(Bytes::from_static(b"Hello!")) + } + + /// We can use multiple args that are Deserialize + #[get("/hello")] + async fn twelve(a: i32, b: i32, c: i32) -> Result { + Ok(format!("Hello! {} {} {}", a, b, c).into()) + } + + // How should we handle generics? Doesn't make a lot of sense with distributed registration? + // I think we should just not support them for now. Reworking it will be a big change though. + // + // /// We can use generics + // #[get("/hello")] + // async fn thirteen(a: S) -> Result { + // Ok(format!("Hello! {}", serde_json::to_string(&a)?).into()) + // } + + // /// We can use impl-style generics + // #[get("/hello")] + // async fn fourteen(a: impl Serialize + DeserializeOwned) -> Result { + // Ok(format!("Hello! {}", serde_json::to_string(&a)?).into()) + // } +} + +mod custom_serialize { + use super::*; + + #[derive(Serialize, Deserialize)] + struct YourObject { + id: i32, + amount: Option, + offset: Option, + } + + /// Directly return the object, and it will be serialized to JSON + #[get("/item/{id}?amount&offset")] + async fn get_item1( + id: i32, + amount: Option, + offset: Option, + ) -> Result> { + Ok(Json(YourObject { id, amount, offset })) + } + + #[get("/item/{id}?amount&offset")] + async fn get_item2( + id: i32, + amount: Option, + offset: Option, + ) -> Result> { + Ok(Json(YourObject { id, amount, offset })) + } + + #[get("/item/{id}?amount&offset")] + async fn get_item3(id: i32, amount: Option, offset: Option) -> Result { + Ok(YourObject { id, amount, offset }) + } + + #[get("/item/{id}?amount&offset")] + async fn get_item4( + id: i32, + amount: Option, + offset: Option, + ) -> Result { + Ok(YourObject { id, amount, offset }) + } +} + +mod custom_types { + use axum::response::Response; + // use axum_core::response::Response; + use dioxus_fullstack::{ + ClientRequest, ClientResponse, FromResponse, IntoRequest, RequestError, WebSocketOptions, + }; + + use super::*; + + /// We can extract the path arg and return anything thats IntoResponse + #[get("/upload/image/")] + async fn streaming_file(body: FileStream) -> Result> { + unimplemented!() + } + + /// We can extract the path arg and return anything thats IntoResponse + #[get("/upload/image/?name&size&ftype")] + async fn streaming_file_args( + name: String, + size: usize, + ftype: String, + body: FileStream, + ) -> Result> { + unimplemented!() + } + + #[get("/")] + async fn ws_endpoint(options: WebSocketOptions) -> Result> { + unimplemented!() + } + + struct MyCustomPayload {} + impl FromResponse for MyCustomPayload { + fn from_response(res: ClientResponse) -> impl Future> { + async move { Ok(MyCustomPayload {}) } + } + } + impl IntoResponse for MyCustomPayload { + fn into_response(self) -> Response { + unimplemented!() + } + } + impl FromRequest for MyCustomPayload { + type Rejection = ServerFnError; + #[allow(clippy::manual_async_fn)] + fn from_request( + _req: axum::extract::Request, + _state: &T, + ) -> impl Future> + Send { + async move { Ok(MyCustomPayload {}) } + } + } + impl IntoRequest for MyCustomPayload { + fn into_request( + self, + request_builder: ClientRequest, + ) -> impl Future> + 'static { + async move { unimplemented!() } + } + } + + #[get("/myendpoint")] + async fn my_custom_handler1(payload: MyCustomPayload) -> Result { + Ok(payload) + } + + #[get("/myendpoint2")] + async fn my_custom_handler2(payload: MyCustomPayload) -> Result { + Ok(payload) + } +} + +mod overlap { + use super::*; + + #[derive(Serialize, Deserialize)] + struct MyCustomPayload {} + impl IntoResponse for MyCustomPayload { + fn into_response(self) -> axum::response::Response { + unimplemented!() + } + } + impl FromRequest for MyCustomPayload { + type Rejection = ServerFnError; + #[allow(clippy::manual_async_fn)] + fn from_request( + _req: axum::extract::Request, + _state: &T, + ) -> impl Future> + Send { + async move { Ok(MyCustomPayload {}) } + } + } + + /// When we have overlapping serialize + IntoResponse impls, the autoref logic will only pick Serialize + /// if IntoResponse is not available. Otherwise, IntoResponse is preferred. + #[get("/myendpoint")] + async fn my_custom_handler3(payload: MyCustomPayload) -> Result { + Ok(payload) + } + + /// Same, but with the anyhow::Error path + #[get("/myendpoint")] + async fn my_custom_handler4(payload: MyCustomPayload) -> Result { + Ok(payload) + } +} + +mod http_ext { + use dioxus::Result; + + use super::*; + + /// Extract requests directly for full control + #[get("/myendpoint")] + async fn my_custom_handler1(request: axum::extract::Request) -> Result<()> { + let mut data = request.into_data_stream(); + while let Some(chunk) = data.next().await { + let _ = chunk.unwrap(); + } + Ok(()) + } +} + +mod input_types { + use super::*; + + #[derive(Serialize, Deserialize)] + struct CustomPayload { + name: String, + age: u32, + } + + /// We can take `()` as input + #[post("/")] + async fn zero(a: (), b: (), c: ()) -> Result<()> { + Ok(()) + } + + /// We can take `()` as input in serde types + #[post("/")] + async fn zero_1(a: Json<()>) -> Result<()> { + Ok(()) + } + + /// We can take regular axum extractors as input + #[post("/")] + async fn one(data: Json) -> Result<()> { + Ok(()) + } + + /// We can take Deserialize types as input, and they will be deserialized from JSON + #[post("/")] + async fn two(name: String, age: u32) -> Result<()> { + Ok(()) + } + + /// We can take Deserialize types as input, with custom server extensions + #[post("/", headers: HeaderMap)] + async fn three(name: String) -> Result<()> { + Ok(()) + } + + /// We can take a regular axum-like mix with extractors and Deserialize types + #[post("/", headers: HeaderMap)] + async fn four(data: Json) -> Result<()> { + Ok(()) + } + + /// We can even accept string in the final position. + #[post("/")] + async fn five(age: u32, name: String) -> Result<()> { + Ok(()) + } +} diff --git a/packages/hooks/Cargo.toml b/packages/hooks/Cargo.toml index 33a42e82b..747e4ad36 100644 --- a/packages/hooks/Cargo.toml +++ b/packages/hooks/Cargo.toml @@ -19,7 +19,7 @@ dioxus-signals = { workspace = true } futures-channel = { workspace = true } tracing = { workspace = true } slab = { workspace = true } -futures-util = { workspace = true} +futures-util = { workspace = true, features = ["std"] } generational-box = { workspace = true } rustversion = { workspace = true } warnings = { workspace = true } diff --git a/packages/hooks/src/lib.rs b/packages/hooks/src/lib.rs index be9c07b84..716a53baf 100644 --- a/packages/hooks/src/lib.rs +++ b/packages/hooks/src/lib.rs @@ -98,3 +98,9 @@ pub use use_set_compare::*; mod use_after_suspense_resolved; pub use use_after_suspense_resolved::*; + +mod use_action; +pub use use_action::*; + +mod use_waker; +pub use use_waker::*; diff --git a/packages/hooks/src/use_action.rs b/packages/hooks/src/use_action.rs new file mode 100644 index 000000000..5bc93cc58 --- /dev/null +++ b/packages/hooks/src/use_action.rs @@ -0,0 +1,288 @@ +use crate::{use_callback, use_signal}; +use dioxus_core::{use_hook, Callback, CapturedError, Result, Task}; +use dioxus_signals::{ReadSignal, ReadableBoxExt, ReadableExt, Signal, WritableExt}; +use futures_channel::oneshot::Receiver; +use futures_util::{future::Shared, FutureExt}; +use std::{marker::PhantomData, prelude::rust_2024::Future}; + +pub fn use_action(mut user_fn: C) -> Action +where + E: Into + 'static, + C: ActionCallback, + M: 'static, + C::Input: 'static, + C::Output: 'static, + C: 'static, +{ + let mut value = use_signal(|| None as Option); + let mut error = use_signal(|| None as Option); + let mut task = use_signal(|| None as Option); + let mut state = use_signal(|| ActionState::Unset); + let callback = use_callback(move |input: C::Input| { + // Cancel any existing task + if let Some(task) = task.take() { + task.cancel(); + } + + let (tx, rx) = futures_channel::oneshot::channel(); + let rx = rx.shared(); + + // Spawn a new task, and *then* fire off the async + let result = user_fn.call(input); + let new_task = dioxus_core::spawn(async move { + // Set the state to pending + state.set(ActionState::Pending); + + // Create a new task + let result = result.await; + match result { + Ok(res) => { + error.set(None); + value.set(Some(res)); + state.set(ActionState::Ready); + } + Err(err) => { + error.set(Some(err.into())); + value.set(None); + state.set(ActionState::Errored); + } + } + + tx.send(()).ok(); + }); + + task.set(Some(new_task)); + + rx + }); + + // Create a reader that maps the Option to T, unwrapping the Option + // This should only be handed out if we know the value is Some. We never set the value back to None, only modify the state of the action + let reader = use_hook(|| value.boxed().map(|v| v.as_ref().unwrap()).boxed()); + + Action { + value, + error, + task, + callback, + reader, + _phantom: PhantomData, + state, + } +} + +pub struct Action { + reader: ReadSignal, + error: Signal>, + value: Signal>, + task: Signal>, + callback: Callback>>, + state: Signal, + _phantom: PhantomData<*const I>, +} + +impl Action { + pub fn value(&self) -> Option> { + if self.value.read().is_none() { + return None; + } + + Some(self.reader) + } + + pub fn result(&self) -> Option, CapturedError>> { + if !matches!( + *self.state.read(), + ActionState::Ready | ActionState::Errored + ) { + return None; + } + + if let Some(err) = self.error.cloned() { + return Some(Err(err)); + } + + if self.value.read().is_none() { + return None; + } + + Some(Ok(self.reader)) + } + + pub fn is_pending(&self) -> bool { + *self.state.read() == ActionState::Pending + } + + /// Clear the current value and error, setting the state to Reset + pub fn reset(&mut self) { + self.state.set(ActionState::Reset); + if let Some(t) = self.task.take() { + t.cancel() + } + } + + pub fn cancel(&mut self) { + if let Some(t) = self.task.take() { + t.cancel() + } + self.state.set(ActionState::Reset); + } +} + +impl std::fmt::Debug for Action +where + T: std::fmt::Debug + 'static, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if f.alternate() { + f.debug_struct("Action") + .field("state", &self.state.read()) + .field("value", &self.value.read()) + .field("error", &self.error.read()) + .finish() + } else { + std::fmt::Debug::fmt(&self.value.read().as_ref(), f) + } + } +} +pub struct Dispatching { + _phantom: PhantomData<*const I>, + receiver: Shared>, +} + +impl Dispatching { + pub(crate) fn new(receiver: Shared>) -> Self { + Self { + _phantom: PhantomData, + receiver, + } + } +} + +impl std::future::Future for Dispatching { + type Output = (); + + fn poll( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match self.receiver.poll_unpin(_cx) { + std::task::Poll::Ready(_) => std::task::Poll::Ready(()), + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} + +impl Copy for Action {} +impl Clone for Action { + fn clone(&self) -> Self { + *self + } +} + +/// The state of an action +/// +/// We can never reset the state to Unset, only to Reset, otherwise the value reader would panic. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)] +enum ActionState { + Unset, + Pending, + Ready, + Errored, + Reset, +} + +pub trait ActionCallback { + type Input; + type Output; + fn call( + &mut self, + input: Self::Input, + ) -> impl Future> + 'static; +} + +impl ActionCallback<(O,), E> for F +where + F: FnMut() -> G, + G: Future> + 'static, +{ + type Input = (); + type Output = O; + fn call( + &mut self, + _input: Self::Input, + ) -> impl Future> + 'static { + (self)() + } +} + +impl ActionCallback<(A, O), E> for F +where + F: FnMut(A) -> G, + G: Future> + 'static, +{ + type Input = (A,); + type Output = O; + fn call( + &mut self, + input: Self::Input, + ) -> impl Future> + 'static { + let (a,) = input; + (self)(a) + } +} + +impl ActionCallback<(A, B, O), E> for F +where + F: FnMut(A, B) -> G, + G: Future> + 'static, +{ + type Input = (A, B); + type Output = O; + fn call( + &mut self, + input: Self::Input, + ) -> impl Future> + 'static { + let (a, b) = input; + (self)(a, b) + } +} + +impl ActionCallback<(A, B, C, O), E> for F +where + F: FnMut(A, B, C) -> G, + G: Future> + 'static, +{ + type Input = (A, B, C); + type Output = O; + fn call( + &mut self, + input: Self::Input, + ) -> impl Future> + 'static { + let (a, b, c) = input; + (self)(a, b, c) + } +} + +impl Action<(), O> { + pub fn call(&mut self) -> Dispatching<()> { + Dispatching::new((self.callback).call(())) + } +} + +impl Action<(A,), O> { + pub fn call(&mut self, _a: A) -> Dispatching<()> { + Dispatching::new((self.callback).call((_a,))) + } +} + +impl Action<(A, B), O> { + pub fn call(&mut self, _a: A, _b: B) -> Dispatching<()> { + Dispatching::new((self.callback).call((_a, _b))) + } +} +impl Action<(A, B, C), O> { + pub fn call(&mut self, _a: A, _b: B, _c: C) -> Dispatching<()> { + Dispatching::new((self.callback).call((_a, _b, _c))) + } +} diff --git a/packages/hooks/src/use_callback.rs b/packages/hooks/src/use_callback.rs index 6fd200a6f..29c9acb9a 100644 --- a/packages/hooks/src/use_callback.rs +++ b/packages/hooks/src/use_callback.rs @@ -11,7 +11,13 @@ pub fn use_callback(f: impl FnMut(T) -> O + 'static) -> // Create a copyvalue with no contents // This copyvalue is generic over F so that it can be sized properly - let mut inner = use_hook(|| Callback::new(callback.take().unwrap())); + let mut inner = use_hook(|| { + Callback::new( + callback + .take() + .expect("Callback cannot be None on first call"), + ) + }); if let Some(callback) = callback.take() { // Every time this hook is called replace the inner callback with the new callback diff --git a/packages/hooks/src/use_resource.rs b/packages/hooks/src/use_resource.rs index ab645c785..194a24c79 100644 --- a/packages/hooks/src/use_resource.rs +++ b/packages/hooks/src/use_resource.rs @@ -1,13 +1,16 @@ #![allow(missing_docs)] -use crate::{use_callback, use_signal}; +use crate::{use_callback, use_signal, use_waker, UseWaker}; use dioxus_core::{ spawn, use_hook, Callback, IntoAttributeValue, IntoDynNode, ReactiveContext, RenderError, Subscribers, SuspendedFuture, Task, }; use dioxus_signals::*; -use futures_util::{future, pin_mut, FutureExt, StreamExt}; +use futures_util::{ + future::{self}, + pin_mut, FutureExt, StreamExt, +}; use std::ops::Deref; use std::{cell::Cell, future::Future, rc::Rc}; @@ -31,6 +34,8 @@ where (rc, Rc::new(Cell::new(Some(changed)))) }); + let mut waker = use_waker::<()>(); + let cb = use_callback(move |_| { // Set the state to Pending when the task is restarted state.set(UseResourceState::Pending); @@ -57,6 +62,9 @@ where // Set the value and state state.set(UseResourceState::Ready); value.set(Some(res)); + + // Notify that the value has changed + waker.wake(()); }) }); @@ -82,6 +90,7 @@ where task, value, state, + waker, callback: cb, } } @@ -113,6 +122,7 @@ where /// ``` #[derive(Debug)] pub struct Resource { + waker: UseWaker<()>, value: Signal>, task: Signal, state: Signal, @@ -486,3 +496,17 @@ impl Deref for Resource { unsafe { ReadableExt::deref_impl(self) } } } + +impl std::future::Future for Resource { + type Output = (); + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + match self.waker.clone().poll_unpin(cx) { + std::task::Poll::Ready(_) => std::task::Poll::Ready(()), + std::task::Poll::Pending => std::task::Poll::Pending, + } + } +} diff --git a/packages/hooks/src/use_waker.rs b/packages/hooks/src/use_waker.rs new file mode 100644 index 000000000..699de4ad7 --- /dev/null +++ b/packages/hooks/src/use_waker.rs @@ -0,0 +1,72 @@ +use dioxus_core::use_hook; +use dioxus_signals::{ReadableExt, Signal, WritableExt}; +use futures_channel::oneshot::{Canceled, Receiver, Sender}; +use futures_util::{future::Shared, FutureExt}; + +/// A hook that provides a waker for other hooks to provide async/await capabilities. +/// +/// This hook is a reactive wrapper over the `Shared` future from the `futures` crate. +/// It allows multiple awaiters to wait on the same value, similar to a broadcast channel from Tokio. +/// +/// Calling `.await` on the waker will consume the waker, so you'll need to call `.wait()` on the +/// source to get a new waker. +pub fn use_waker() -> UseWaker { + // We use a oneshot channel to send the value to the awaiter. + // The shared future allows multiple awaiters to wait on the same value. + let (task_tx, task_rx) = use_hook(|| { + let (tx, rx) = futures_channel::oneshot::channel::(); + let shared = rx.shared(); + (Signal::new(tx), Signal::new(shared)) + }); + + UseWaker { task_tx, task_rx } +} + +#[derive(Debug)] +pub struct UseWaker { + task_tx: Signal>, + task_rx: Signal>>, +} + +impl UseWaker { + /// Wake the current task with the provided value. + /// All awaiters will receive a clone of the value. + pub fn wake(&mut self, value: T) { + // We ignore the error because it means the task has already been woken. + let (tx, rx) = futures_channel::oneshot::channel::(); + let shared = rx.shared(); + + // Swap out the old sender and receiver with the new ones. + let tx = self.task_tx.replace(tx); + let _rx = self.task_rx.replace(shared); + + // And then send out the oneshot value, waking up all awaiters. + let _ = tx.send(value); + } + + /// Returns a future that resolves when the task is woken. + pub async fn wait(&self) -> Result { + self.task_rx.cloned().await + } +} + +// Can await the waker to be woken. +// We use `.peek()` here to avoid reacting to changes in the underlying task_rx which could lead +// to an effect/future loop. +impl std::future::Future for UseWaker { + type Output = Result; + + fn poll( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll { + self.task_rx.peek().clone().poll_unpin(cx) + } +} + +impl Copy for UseWaker {} +impl Clone for UseWaker { + fn clone(&self) -> Self { + *self + } +} diff --git a/packages/html/Cargo.toml b/packages/html/Cargo.toml index 86305f1cc..074e9e237 100644 --- a/packages/html/Cargo.toml +++ b/packages/html/Cargo.toml @@ -24,11 +24,13 @@ keyboard-types = { workspace = true, default-features = false } async-trait = { workspace = true } tokio = { workspace = true, features = ["fs", "io-util"], optional = true } futures-channel = { workspace = true } +futures-util = { workspace = true } serde_json = { workspace = true, optional = true } tracing = { workspace = true } rustversion = { workspace = true } euclid = "0.22.11" enumset = "1.1.6" +bytes = { workspace = true } [build-dependencies] lazy-js-bundle = { workspace = true } @@ -48,12 +50,9 @@ serialize = [ "dep:serde_repr", "euclid/serde", "keyboard-types/serde", - "dioxus-core/serialize" + "dioxus-core/serialize", + "bytes/serde" ] -# TODO: Remove the mounted feature flag in the next major release. It no longer activates any extra code -mounted = [] -# TODO: Remove the file engine feature flag in the next major release. It no longer activates any extra code -file_engine = [] hot-reload-context = ["dep:dioxus-rsx"] html-to-rsx = [] diff --git a/packages/html/src/events/drag.rs b/packages/html/src/events/drag.rs index 26f2ebe99..1d6d27d67 100644 --- a/packages/html/src/events/drag.rs +++ b/packages/html/src/events/drag.rs @@ -59,7 +59,7 @@ impl DragData { } impl crate::HasFileData for DragData { - fn files(&self) -> Option> { + fn files(&self) -> Vec { self.inner.files() } } @@ -111,8 +111,7 @@ impl PointerInteraction for DragData { pub struct SerializedDragData { pub mouse: crate::point_interaction::SerializedPointInteraction, - #[serde(default)] - files: Option, + pub files: Vec, } #[cfg(feature = "serialize")] @@ -120,7 +119,7 @@ impl SerializedDragData { fn new(drag: &DragData) -> Self { Self { mouse: crate::point_interaction::SerializedPointInteraction::from(drag), - files: None, + files: vec![], } } } @@ -134,10 +133,11 @@ impl HasDragData for SerializedDragData { #[cfg(feature = "serialize")] impl crate::file_data::HasFileData for SerializedDragData { - fn files(&self) -> Option> { + fn files(&self) -> Vec { self.files - .as_ref() - .map(|files| std::sync::Arc::new(files.clone()) as _) + .iter() + .map(|f| FileData::new(f.clone())) + .collect() } } diff --git a/packages/html/src/events/form.rs b/packages/html/src/events/form.rs index 16444f871..b405829d6 100644 --- a/packages/html/src/events/form.rs +++ b/packages/html/src/events/form.rs @@ -1,6 +1,10 @@ use crate::file_data::HasFileData; +use crate::FileData; use std::{collections::HashMap, fmt::Debug, ops::Deref}; +#[cfg(feature = "serialize")] +use crate::SerializedFileData; + use dioxus_core::Event; pub type FormEvent = Event; @@ -105,7 +109,7 @@ impl FormData { } /// Get the files of the form event - pub fn files(&self) -> Option> { + pub fn files(&self) -> Vec { self.inner.files() } @@ -146,20 +150,24 @@ impl FormData { where T: serde::de::DeserializeOwned, { - use serde::Serialize; + let values = &self.values(); - fn convert_hashmap_to_json(hashmap: &HashMap) -> serde_json::Result - where - K: Serialize + std::hash::Hash + Eq, - V: Serialize, - { - serde_json::to_string(hashmap) + let mut map = serde_json::Map::new(); + for (key, value) in values { + if value.0.len() == 1 { + map.insert(key.clone(), serde_json::Value::String(value.0[0].clone())); + } else { + let arr = value + .0 + .iter() + .cloned() + .map(serde_json::Value::String) + .collect(); + map.insert(key.clone(), serde_json::Value::Array(arr)); + } } - let parsed_json = - convert_hashmap_to_json(&self.values()).expect("Failed to parse values to JSON"); - - serde_json::from_str(&parsed_json) + serde_json::from_value(serde_json::Value::Object(map)) } } @@ -177,7 +185,7 @@ pub struct SerializedFormData { valid: bool, #[serde(default)] - files: Option, + files: Vec, } #[cfg(feature = "serialize")] @@ -188,40 +196,37 @@ impl SerializedFormData { value, values, valid: true, - files: None, + files: vec![], } } /// Add files to the serialized form data object - pub fn with_files(mut self, files: crate::file_data::SerializedFileEngine) -> Self { - self.files = Some(files); + pub fn with_files(mut self, files: Vec) -> Self { + self.files = files; self } /// Create a new serialized form data object from a traditional form data object - pub async fn async_from(data: &FormData) -> Self { - Self { + pub async fn async_from(data: &FormData) -> Result { + Ok(Self { value: data.value(), values: data.values(), valid: data.valid(), files: { - match data.files() { - Some(files) => { - let mut resolved_files = HashMap::new(); - - for file in files.files() { - let bytes = files.read_file(&file).await; - resolved_files.insert(file, bytes.unwrap_or_default()); - } - - Some(crate::file_data::SerializedFileEngine { - files: resolved_files, - }) - } - None => None, + let mut out = vec![]; + for file in data.files() { + out.push(SerializedFileData { + name: file.name(), + size: file.size(), + content_type: file.content_type(), + last_modified: file.last_modified(), + path: file.pathbuf(), + contents: file.read_bytes().await?, + }); } + out }, - } + }) } fn from_lossy(data: &FormData) -> Self { @@ -229,7 +234,7 @@ impl SerializedFormData { value: data.value(), values: data.values(), valid: data.valid(), - files: None, + files: vec![], } } } @@ -255,15 +260,16 @@ impl HasFormData for SerializedFormData { #[cfg(feature = "serialize")] impl HasFileData for SerializedFormData { - fn files(&self) -> Option> { + fn files(&self) -> Vec { self.files - .as_ref() - .map(|files| std::sync::Arc::new(files.clone()) as _) + .iter() + .map(|f| FileData::new(f.clone())) + .collect() } } impl HasFileData for FormData { - fn files(&self) -> Option> { + fn files(&self) -> Vec { self.inner.files() } } diff --git a/packages/html/src/events/mounted.rs b/packages/html/src/events/mounted.rs index ea538358d..b19c0bcdb 100644 --- a/packages/html/src/events/mounted.rs +++ b/packages/html/src/events/mounted.rs @@ -61,13 +61,16 @@ impl RenderedElementBacking for () { } /// The way that scrolling should be performed +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] #[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] #[doc(alias = "ScrollIntoViewOptions")] pub enum ScrollBehavior { /// Scroll to the element immediately #[cfg_attr(feature = "serialize", serde(rename = "instant"))] Instant, + /// Scroll to the element smoothly + #[default] #[cfg_attr(feature = "serialize", serde(rename = "smooth"))] Smooth, } diff --git a/packages/html/src/file_data.rs b/packages/html/src/file_data.rs index cad64f8f6..7af8da924 100644 --- a/packages/html/src/file_data.rs +++ b/packages/html/src/file_data.rs @@ -1,59 +1,156 @@ +use std::{path::PathBuf, pin::Pin, prelude::rust_2024::Future}; + +use bytes::Bytes; +use futures_util::Stream; + +#[derive(Clone)] +pub struct FileData { + inner: std::sync::Arc, +} + +impl FileData { + pub fn new(inner: impl NativeFileData + 'static) -> Self { + Self { + inner: std::sync::Arc::new(inner), + } + } + + pub fn content_type(&self) -> Option { + self.inner.content_type() + } + + pub fn name(&self) -> String { + self.inner.name() + } + + pub fn size(&self) -> u64 { + self.inner.size() + } + + pub fn last_modified(&self) -> u64 { + self.inner.last_modified() + } + + pub async fn read_bytes(&self) -> Result { + self.inner.read_bytes().await + } + + pub async fn read_string(&self) -> Result { + self.inner.read_string().await + } + + pub fn byte_stream( + &self, + ) -> Pin> + Send + 'static>> { + self.inner.byte_stream() + } + + pub fn inner(&self) -> &dyn std::any::Any { + self.inner.inner() + } + + pub fn pathbuf(&self) -> PathBuf { + self.inner.path() + } +} + +pub trait NativeFileData: Send + Sync { + fn name(&self) -> String; + fn size(&self) -> u64; + fn last_modified(&self) -> u64; + fn path(&self) -> PathBuf; + fn content_type(&self) -> Option; + fn read_bytes( + &self, + ) -> Pin> + 'static>>; + fn byte_stream( + &self, + ) -> Pin> + 'static + Send>>; + fn read_string( + &self, + ) -> Pin> + 'static>>; + fn inner(&self) -> &dyn std::any::Any; +} + +impl std::fmt::Debug for FileData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FileData") + .field("name", &self.inner.name()) + .field("size", &self.inner.size()) + .field("last_modified", &self.inner.last_modified()) + .finish() + } +} + +impl std::cmp::PartialEq for FileData { + fn eq(&self, other: &Self) -> bool { + self.inner.name() == other.inner.name() + && self.inner.size() == other.inner.size() + && self.inner.last_modified() == other.inner.last_modified() + } +} + pub trait HasFileData: std::any::Any { - fn files(&self) -> Option> { - None - } + fn files(&self) -> Vec; } +/// A serializable representation of file data\ #[cfg(feature = "serialize")] -/// A file engine that serializes files to bytes #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Clone)] -pub struct SerializedFileEngine { - pub files: std::collections::HashMap>, +pub struct SerializedFileData { + pub name: String, + pub path: PathBuf, + pub size: u64, + pub last_modified: u64, + pub content_type: Option, + pub contents: bytes::Bytes, } #[cfg(feature = "serialize")] -#[async_trait::async_trait(?Send)] -impl FileEngine for SerializedFileEngine { - fn files(&self) -> Vec { - self.files.keys().cloned().collect() +impl NativeFileData for SerializedFileData { + fn name(&self) -> String { + self.name.clone() } - async fn file_size(&self, file: &str) -> Option { - let file = self.files.get(file)?; - Some(file.len() as u64) + fn size(&self) -> u64 { + self.size } - async fn read_file(&self, file: &str) -> Option> { - self.files.get(file).cloned() + fn last_modified(&self) -> u64 { + self.last_modified } - async fn read_file_to_string(&self, file: &str) -> Option { - self.read_file(file) - .await - .map(|bytes| String::from_utf8_lossy(&bytes).to_string()) + fn read_bytes( + &self, + ) -> Pin> + 'static>> { + let contents = self.contents.clone(); + Box::pin(async move { Ok(contents) }) } - async fn get_native_file(&self, file: &str) -> Option> { - self.read_file(file) - .await - .map(|val| Box::new(val) as Box) + fn read_string( + &self, + ) -> Pin> + 'static>> { + let contents = self.contents.clone(); + Box::pin(async move { Ok(String::from_utf8(contents.to_vec())?) }) + } + + fn byte_stream( + &self, + ) -> Pin> + 'static + Send>> + { + let contents = self.contents.clone(); + Box::pin(futures_util::stream::once(async move { Ok(contents) })) + } + + fn inner(&self) -> &dyn std::any::Any { + self + } + + fn path(&self) -> PathBuf { + self.path.clone() + } + + fn content_type(&self) -> Option { + self.content_type.clone() } } - -#[async_trait::async_trait(?Send)] -pub trait FileEngine { - // get a list of file names - fn files(&self) -> Vec; - - // get the size of a file - async fn file_size(&self, file: &str) -> Option; - - // read a file to bytes - async fn read_file(&self, file: &str) -> Option>; - - // read a file to string - async fn read_file_to_string(&self, file: &str) -> Option; - - // returns a file in platform's native representation - async fn get_native_file(&self, file: &str) -> Option>; -} diff --git a/packages/html/src/lib.rs b/packages/html/src/lib.rs index 090b4783d..e4d09748d 100644 --- a/packages/html/src/lib.rs +++ b/packages/html/src/lib.rs @@ -30,6 +30,8 @@ pub mod input_data; pub mod point_interaction; mod render_template; +pub use bytes; + #[cfg(feature = "serialize")] mod transit; diff --git a/packages/html/src/transit.rs b/packages/html/src/transit.rs index ace2d4bcd..270a20317 100644 --- a/packages/html/src/transit.rs +++ b/packages/html/src/transit.rs @@ -37,7 +37,7 @@ impl<'de> Deserialize<'de> for HtmlEvent { // in debug mode let's try and be helpful as to why the deserialization failed let data = deserialize_raw(&name, &data).map_err(|e| { serde::de::Error::custom(format!( - "Failed to deserialize event data for event {}: {:#?}\n'{:#?}'", + "Failed to deserialize event data for event {}: {}\n'{:#?}'", name, e, data, )) })?; diff --git a/packages/interpreter/src/js/common.js b/packages/interpreter/src/js/common.js index 3bbd96df3..28a205686 100644 --- a/packages/interpreter/src/js/common.js +++ b/packages/interpreter/src/js/common.js @@ -1 +1 @@ -function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.tagName==="OPTION")setAttributeDefault(node,field,value);else if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;case"style":let existingStyles={};for(let i=0;i{if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}export{setAttributeInner,retrieveFormValues}; +function setAttributeInner(node,field,value,ns){if(ns==="style"){node.style.setProperty(field,value);return}if(ns){node.setAttributeNS(ns,field,value);return}switch(field){case"value":if(node.tagName==="OPTION")setAttributeDefault(node,field,value);else if(node.value!==value)node.value=value;break;case"initial_value":node.defaultValue=value;break;case"checked":node.checked=truthy(value);break;case"initial_checked":node.defaultChecked=truthy(value);break;case"selected":node.selected=truthy(value);break;case"initial_selected":node.defaultSelected=truthy(value);break;case"dangerous_inner_html":node.innerHTML=value;break;case"style":let existingStyles={};for(let i=0;i{if(value instanceof File){contents[key]=[value.name];return}if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}export{setAttributeInner,retrieveFormValues}; diff --git a/packages/interpreter/src/js/hash.txt b/packages/interpreter/src/js/hash.txt index 407f37e41..4825e8b9f 100644 --- a/packages/interpreter/src/js/hash.txt +++ b/packages/interpreter/src/js/hash.txt @@ -1 +1 @@ -[6449103750905854967, 17669692872757955279, 13069001215487072322, 11420464406527728232, 3770103091118609057, 5444526391971481782, 14692042304579321130, 5052021921702764563, 7041565893588933645, 11339769846046015954] \ No newline at end of file +[6449103750905854967, 17669692872757955279, 4701826235737143966, 11420464406527728232, 3770103091118609057, 5444526391971481782, 14692042304579321130, 5052021921702764563, 7041565893588933645, 11339769846046015954] \ No newline at end of file diff --git a/packages/interpreter/src/js/native.js b/packages/interpreter/src/js/native.js index f08b6f006..48118876e 100644 --- a/packages/interpreter/src/js/native.js +++ b/packages/interpreter/src/js/native.js @@ -1 +1 @@ -function retrieveValues(event,target){let contents={values:{}},form=target.closest("form");if(form){if(event.type==="input"||event.type==="change"||event.type==="submit"||event.type==="reset"||event.type==="click")contents=retrieveFormValues(form)}return contents}function retrieveFormValues(form){let formData=new FormData(form),contents={};return formData.forEach((value,key)=>{if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;icontents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent!=="undefined"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent);if(event.type==="scroll")extend(serializeScrollEvent(event));return contents}function toSerializableResizeObserverSize(size,is_inline_width){return[is_inline_width?size.inlineSize:size.blockSize,is_inline_width?size.blockSize:size.inlineSize]}function serializeResizeEventDetail(detail){let is_inline_width=!0;if(detail.target instanceof HTMLElement){if(window.getComputedStyle(detail.target).getPropertyValue("writing-mode")!=="horizontal-tb")is_inline_width=!1}return{border_box_size:detail.borderBoxSize!==void 0?toSerializableResizeObserverSize(detail.borderBoxSize[0],is_inline_width):detail.contentRect,content_box_size:detail.contentBoxSize!==void 0?toSerializableResizeObserverSize(detail.contentBoxSize[0],is_inline_width):detail.contentRect,content_rect:detail.contentRect}}function serializeIntersectionEventDetail(detail){return{bounding_client_rect:detail.boundingClientRect,intersection_ratio:detail.intersectionRatio,intersection_rect:detail.intersectionRect,is_intersecting:detail.isIntersecting,root_bounds:detail.rootBounds,time_ms:Math.floor(Date.now()+detail.time)}}function serializeInputEvent(event,target){let contents={};if(target instanceof HTMLElement){let values=retrieveValues(event,target);contents.values=values.values,contents.valid=values.valid}if(event.target instanceof HTMLInputElement){let target2=event.target,value=target2.value??target2.textContent??"";if(target2.type==="checkbox")value=target2.checked?"true":"false";else if(target2.type==="radio")value=target2.value;contents.value=value}if(event.target instanceof HTMLTextAreaElement)contents.value=event.target.value;if(event.target instanceof HTMLSelectElement)contents.value=retrieveSelectValue(event.target).join(",");if(contents.value===void 0)contents.value="";return contents}function serializeWheelEvent(event){return{delta_x:event.deltaX,delta_y:event.deltaY,delta_z:event.deltaZ,delta_mode:event.deltaMode}}function serializeTouchEvent(event){return{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,changed_touches:serializeTouchList(event.changedTouches),target_touches:serializeTouchList(event.targetTouches),touches:serializeTouchList(event.touches)}}function serializePointerEvent(event){return{alt_key:event.altKey,button:event.button,buttons:event.buttons,client_x:event.clientX,client_y:event.clientY,ctrl_key:event.ctrlKey,meta_key:event.metaKey,page_x:event.pageX,page_y:event.pageY,screen_x:event.screenX,screen_y:event.screenY,shift_key:event.shiftKey,pointer_id:event.pointerId,width:event.width,height:event.height,pressure:event.pressure,tangential_pressure:event.tangentialPressure,tilt_x:event.tiltX,tilt_y:event.tiltY,twist:event.twist,pointer_type:event.pointerType,is_primary:event.isPrimary}}function serializeTouchList(touchList){let serializedTouches=[];for(let i=0;i0)files={files:{placeholder:[]}};return{mouse:{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,...serializeMouseEvent(event)},files}}function serializeScrollEvent(event){let scrollLeft=0,scrollTop=0,scrollWidth=0,scrollHeight=0,clientWidth=0,clientHeight=0;if(event.target instanceof Element)scrollLeft=event.target.scrollLeft,scrollTop=event.target.scrollTop,scrollWidth=event.target.scrollWidth,scrollHeight=event.target.scrollHeight,clientWidth=event.target.clientWidth,clientHeight=event.target.clientHeight;else if(event.target===document)scrollLeft=window.scrollX||document.documentElement.scrollLeft,scrollTop=window.scrollY||document.documentElement.scrollTop,scrollWidth=document.documentElement.scrollWidth,scrollHeight=document.documentElement.scrollHeight,clientWidth=document.documentElement.clientWidth,clientHeight=document.documentElement.clientHeight;return{scroll_left:scrollLeft,scroll_top:scrollTop,scroll_width:scrollWidth,scroll_height:scrollHeight,client_width:clientWidth,client_height:clientHeight}}var JSChannel_;if(RawInterpreter!==void 0&&RawInterpreter!==null)JSChannel_=RawInterpreter;class NativeInterpreter extends JSChannel_{intercept_link_redirects;ipc;edits;eventsPath;headless;kickStylesheets;queuedBytes=[];liveview;constructor(eventsPath,headless){super();this.eventsPath=eventsPath,this.kickStylesheets=!1,this.headless=headless}initialize(root){this.intercept_link_redirects=!0,this.liveview=!1,window.addEventListener("dragover",function(e){if(e.target instanceof Element&&e.target.tagName!="INPUT")e.preventDefault()},!1),window.addEventListener("drop",function(e){if(!(e.target instanceof Element))return;e.preventDefault()},!1),window.addEventListener("click",(event)=>{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){let message=this.serializeIpcMessage("file_dialog",{event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles});this.ipc.postMessage(message),event.preventDefault()}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,!0);super.initialize(root,handler)}serializeIpcMessage(method,params={}){return JSON.stringify({method,params})}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;i{if(value instanceof File){contents[key]=[value.name];return}if(contents[key])contents[key].push(value);else contents[key]=[value]}),{valid:form.checkValidity(),values:contents}}function retrieveSelectValue(target){let options=target.selectedOptions,values=[];for(let i=0;icontents={...contents,...obj};if(event instanceof WheelEvent)extend(serializeWheelEvent(event));if(event instanceof MouseEvent)extend(serializeMouseEvent(event));if(event instanceof KeyboardEvent)extend(serializeKeyboardEvent(event));if(event instanceof InputEvent)extend(serializeInputEvent(event,target));if(event instanceof PointerEvent)extend(serializePointerEvent(event));if(event instanceof AnimationEvent)extend(serializeAnimationEvent(event));if(event instanceof TransitionEvent)extend({property_name:event.propertyName,elapsed_time:event.elapsedTime,pseudo_element:event.pseudoElement});if(event instanceof CompositionEvent)extend({data:event.data});if(event instanceof DragEvent)extend(serializeDragEvent(event));if(event instanceof FocusEvent)extend({});if(event instanceof ClipboardEvent)extend({});if(event instanceof CustomEvent){let detail=event.detail;if(detail instanceof ResizeObserverEntry)extend(serializeResizeEventDetail(detail));else if(detail instanceof IntersectionObserverEntry)extend(serializeIntersectionEventDetail(detail))}if(typeof TouchEvent!=="undefined"&&event instanceof TouchEvent)extend(serializeTouchEvent(event));if(event.type==="submit"||event.type==="reset"||event.type==="click"||event.type==="change"||event.type==="input")extend(serializeInputEvent(event,target));if(event instanceof DragEvent);if(event.type==="scroll")extend(serializeScrollEvent(event));return contents}function toSerializableResizeObserverSize(size,is_inline_width){return[is_inline_width?size.inlineSize:size.blockSize,is_inline_width?size.blockSize:size.inlineSize]}function serializeResizeEventDetail(detail){let is_inline_width=!0;if(detail.target instanceof HTMLElement){if(window.getComputedStyle(detail.target).getPropertyValue("writing-mode")!=="horizontal-tb")is_inline_width=!1}return{border_box_size:detail.borderBoxSize!==void 0?toSerializableResizeObserverSize(detail.borderBoxSize[0],is_inline_width):detail.contentRect,content_box_size:detail.contentBoxSize!==void 0?toSerializableResizeObserverSize(detail.contentBoxSize[0],is_inline_width):detail.contentRect,content_rect:detail.contentRect}}function serializeIntersectionEventDetail(detail){return{bounding_client_rect:detail.boundingClientRect,intersection_ratio:detail.intersectionRatio,intersection_rect:detail.intersectionRect,is_intersecting:detail.isIntersecting,root_bounds:detail.rootBounds,time_ms:Math.floor(Date.now()+detail.time)}}function serializeInputEvent(event,target){let contents={};if(target instanceof HTMLElement){let values=retrieveValues(event,target);contents.values=values.values,contents.valid=values.valid}if(event.target instanceof HTMLInputElement){let target2=event.target,value=target2.value??target2.textContent??"";if(target2.type==="checkbox")value=target2.checked?"true":"false";else if(target2.type==="radio")value=target2.value;contents.value=value}if(event.target instanceof HTMLTextAreaElement)contents.value=event.target.value;if(event.target instanceof HTMLSelectElement)contents.value=retrieveSelectValue(event.target).join(",");if(contents.value===void 0)contents.value="";return contents}function serializeWheelEvent(event){return{delta_x:event.deltaX,delta_y:event.deltaY,delta_z:event.deltaZ,delta_mode:event.deltaMode}}function serializeTouchEvent(event){return{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,changed_touches:serializeTouchList(event.changedTouches),target_touches:serializeTouchList(event.targetTouches),touches:serializeTouchList(event.touches)}}function serializePointerEvent(event){return{alt_key:event.altKey,button:event.button,buttons:event.buttons,client_x:event.clientX,client_y:event.clientY,ctrl_key:event.ctrlKey,meta_key:event.metaKey,page_x:event.pageX,page_y:event.pageY,screen_x:event.screenX,screen_y:event.screenY,shift_key:event.shiftKey,pointer_id:event.pointerId,width:event.width,height:event.height,pressure:event.pressure,tangential_pressure:event.tangentialPressure,tilt_x:event.tiltX,tilt_y:event.tiltY,twist:event.twist,pointer_type:event.pointerType,is_primary:event.isPrimary}}function serializeTouchList(touchList){let serializedTouches=[];for(let i=0;i0)files={files:{placeholder:[]}};return{mouse:{alt_key:event.altKey,ctrl_key:event.ctrlKey,meta_key:event.metaKey,shift_key:event.shiftKey,...serializeMouseEvent(event)},files}}function serializeScrollEvent(event){let scrollLeft=0,scrollTop=0,scrollWidth=0,scrollHeight=0,clientWidth=0,clientHeight=0;if(event.target instanceof Element)scrollLeft=event.target.scrollLeft,scrollTop=event.target.scrollTop,scrollWidth=event.target.scrollWidth,scrollHeight=event.target.scrollHeight,clientWidth=event.target.clientWidth,clientHeight=event.target.clientHeight;else if(event.target===document)scrollLeft=window.scrollX||document.documentElement.scrollLeft,scrollTop=window.scrollY||document.documentElement.scrollTop,scrollWidth=document.documentElement.scrollWidth,scrollHeight=document.documentElement.scrollHeight,clientWidth=document.documentElement.clientWidth,clientHeight=document.documentElement.clientHeight;return{scroll_left:scrollLeft,scroll_top:scrollTop,scroll_width:scrollWidth,scroll_height:scrollHeight,client_width:clientWidth,client_height:clientHeight}}var JSChannel_;if(RawInterpreter!==void 0&&RawInterpreter!==null)JSChannel_=RawInterpreter;class NativeInterpreter extends JSChannel_{intercept_link_redirects;ipc;edits;eventsPath;headless;kickStylesheets;queuedBytes=[];liveview;constructor(eventsPath,headless){super();this.eventsPath=eventsPath,this.kickStylesheets=!1,this.headless=headless}initialize(root){this.intercept_link_redirects=!0,this.liveview=!1,window.addEventListener("dragover",function(e){if(e.target instanceof Element&&e.target.tagName!="INPUT")e.preventDefault()},!1),window.addEventListener("drop",function(e){if(!(e.target instanceof Element))return;e.preventDefault()},!1),window.addEventListener("click",(event)=>{let target=event.target;if(target instanceof HTMLInputElement&&target.getAttribute("type")==="file"){let target_id=getTargetId(target);if(target_id!==null){let message=this.serializeIpcMessage("file_dialog",{event:"change&input",accept:target.getAttribute("accept"),directory:target.getAttribute("webkitdirectory")==="true",multiple:target.hasAttribute("multiple"),target:target_id,bubbles:event.bubbles});this.ipc.postMessage(message),event.preventDefault()}}}),this.ipc=window.ipc;let handler=(event)=>this.handleEvent(event,event.type,!0);super.initialize(root,handler)}serializeIpcMessage(method,params={}){return JSON.stringify({method,params})}scrollTo(id,options){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollIntoView(options),!0;return!1}scroll(id,x,y,behavior){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scroll({top:y,left:x,behavior}),!0;return!1}getScrollHeight(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollHeight}getScrollLeft(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollLeft}getScrollTop(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollTop}getScrollWidth(id){let node=this.nodes[id];if(node instanceof HTMLElement)return node.scrollWidth}getClientRect(id){let node=this.nodes[id];if(node instanceof HTMLElement){let rect=node.getBoundingClientRect();return{type:"GetClientRect",origin:[rect.x,rect.y],size:[rect.width,rect.height]}}}setFocus(id,focus){let node=this.nodes[id];if(node instanceof HTMLElement)if(focus)node.focus();else node.blur()}handleWindowsDragDrop(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent);let data=new DataTransfer,file=new File(["content"],"file.txt",{type:"text/plain"});data.items.add(file);let dragDropEvent=new DragEvent("drop",{bubbles:!0,cancelable:!0,dataTransfer:data});window.dxDragLastElement.dispatchEvent(dragDropEvent),window.dxDragLastElement=null}}handleWindowsDragOver(xPos,yPos){let displayScaleFactor=window.devicePixelRatio||1;xPos/=displayScaleFactor,yPos/=displayScaleFactor;let element=document.elementFromPoint(xPos,yPos);if(element!=window.dxDragLastElement){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent)}let dragOverEvent=new DragEvent("dragover",{bubbles:!0,cancelable:!0});element.dispatchEvent(dragOverEvent),window.dxDragLastElement=element}}handleWindowsDragLeave(){if(window.dxDragLastElement){let dragLeaveEvent=new DragEvent("dragleave",{bubbles:!0,cancelable:!0});window.dxDragLastElement.dispatchEvent(dragLeaveEvent),window.dxDragLastElement=null}}loadChild(array){let node=this.stack[this.stack.length-1];for(let i=0;i0;end--)node=node.nextSibling}return node}appendChildren(id,many){let root=this.nodes[id],els=this.stack.splice(this.stack.length-many);for(let k=0;k{this.flushQueuedBytes(),this.markEditsFinished()})}waitForRequest(editsPath,required_server_key){this.edits=new WebSocket(editsPath);let authenticated=!1;this.edits.onclose=()=>{setTimeout(()=>{if(this.edits.url!=editsPath)return;this.waitForRequest(editsPath,required_server_key)},100)},this.edits.onmessage=(event)=>{let data=event.data;if(data instanceof Blob){if(!authenticated)return;data.arrayBuffer().then((buffer)=>{this.rafEdits(buffer)})}else if(typeof data==="string"){if(data===required_server_key){authenticated=!0;return}}}}markEditsFinished(){this.edits.send(new ArrayBuffer(0))}kickAllStylesheetsOnPage(){let stylesheets=document.querySelectorAll("link[rel=stylesheet]");for(let i=0;i { + if (value instanceof File) { + contents[key] = [value.name]; + return; + } + if (contents[key]) { contents[key].push(value); } else { diff --git a/packages/isrg/Cargo.toml b/packages/isrg/Cargo.toml deleted file mode 100644 index 184e23d97..000000000 --- a/packages/isrg/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "dioxus-isrg" -edition = "2021" -version.workspace = true -authors = ["Jonathan Kelley", "Evan Almloff"] -license = "MIT OR Apache-2.0" -repository = "https://github.com/DioxusLabs/dioxus/" -homepage = "https://dioxuslabs.com" -description = "CLI Configuration for dioxus-cli" -keywords = ["dom", "ui", "gui", "react"] - -[dependencies] -chrono = { workspace = true } -http = { workspace = true } -thiserror = { workspace = true } -rustc-hash = { workspace = true } -lru = { workspace = true } -tracing = { workspace = true } -walkdir = { workspace = true } diff --git a/packages/native-dom/src/events.rs b/packages/native-dom/src/events.rs index ad987a349..208a4a7cd 100644 --- a/packages/native-dom/src/events.rs +++ b/packages/native-dom/src/events.rs @@ -19,7 +19,7 @@ pub struct NativeConverter {} impl HtmlEventConverter for NativeConverter { fn convert_cancel_data(&self, _event: &PlatformEventData) -> CancelData { - todo!("todo: convert_cancel_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_cancel_data in dioxus-native. requires support in blitz") } fn convert_form_data(&self, event: &PlatformEventData) -> FormData { @@ -43,67 +43,67 @@ impl HtmlEventConverter for NativeConverter { } fn convert_animation_data(&self, _event: &PlatformEventData) -> AnimationData { - todo!("todo: convert_animation_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_animation_data in dioxus-native. requires support in blitz") } fn convert_clipboard_data(&self, _event: &PlatformEventData) -> ClipboardData { - todo!("todo: convert_clipboard_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_clipboard_data in dioxus-native. requires support in blitz") } fn convert_composition_data(&self, _event: &PlatformEventData) -> CompositionData { - todo!("todo: convert_composition_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_composition_data in dioxus-native. requires support in blitz") } fn convert_drag_data(&self, _event: &PlatformEventData) -> DragData { - todo!("todo: convert_drag_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_drag_data in dioxus-native. requires support in blitz") } fn convert_image_data(&self, _event: &PlatformEventData) -> ImageData { - todo!("todo: convert_image_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_image_data in dioxus-native. requires support in blitz") } fn convert_media_data(&self, _event: &PlatformEventData) -> MediaData { - todo!("todo: convert_media_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_media_data in dioxus-native. requires support in blitz") } fn convert_mounted_data(&self, _event: &PlatformEventData) -> MountedData { - todo!("todo: convert_mounted_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_mounted_data in dioxus-native. requires support in blitz") } fn convert_pointer_data(&self, _event: &PlatformEventData) -> PointerData { - todo!("todo: convert_pointer_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_pointer_data in dioxus-native. requires support in blitz") } fn convert_scroll_data(&self, _event: &PlatformEventData) -> ScrollData { - todo!("todo: convert_scroll_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_scroll_data in dioxus-native. requires support in blitz") } fn convert_selection_data(&self, _event: &PlatformEventData) -> SelectionData { - todo!("todo: convert_selection_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_selection_data in dioxus-native. requires support in blitz") } fn convert_toggle_data(&self, _event: &PlatformEventData) -> ToggleData { - todo!("todo: convert_toggle_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_toggle_data in dioxus-native. requires support in blitz") } fn convert_touch_data(&self, _event: &PlatformEventData) -> TouchData { - todo!("todo: convert_touch_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_touch_data in dioxus-native. requires support in blitz") } fn convert_transition_data(&self, _event: &PlatformEventData) -> TransitionData { - todo!("todo: convert_transition_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_transition_data in dioxus-native. requires support in blitz") } fn convert_wheel_data(&self, _event: &PlatformEventData) -> WheelData { - todo!("todo: convert_wheel_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_wheel_data in dioxus-native. requires support in blitz") } fn convert_resize_data(&self, _event: &PlatformEventData) -> ResizeData { - todo!("todo: convert_resize_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_resize_data in dioxus-native. requires support in blitz") } fn convert_visible_data(&self, _event: &PlatformEventData) -> VisibleData { - todo!("todo: convert_visible_data in dioxus-native. requires support in blitz") + unimplemented!("todo: convert_visible_data in dioxus-native. requires support in blitz") } } @@ -127,7 +127,11 @@ impl HasFormData for NativeFormData { } } -impl HasFileData for NativeFormData {} +impl HasFileData for NativeFormData { + fn files(&self) -> Vec { + vec![] + } +} #[derive(Clone, Debug)] pub(crate) struct BlitzKeyboardData(pub(crate) BlitzKeyEvent); @@ -184,7 +188,7 @@ impl InteractionLocation for NativeClickData { impl InteractionElementOffset for NativeClickData { fn element_coordinates(&self) -> ElementPoint { - todo!() + unimplemented!() } } diff --git a/packages/playwright-tests/cli-optimization.spec.js b/packages/playwright-tests/cli-optimization.spec.js index 9cad95fc6..28b83d84c 100644 --- a/packages/playwright-tests/cli-optimization.spec.js +++ b/packages/playwright-tests/cli-optimization.spec.js @@ -4,10 +4,10 @@ const { test, expect } = require("@playwright/test"); test("optimized scripts run", async ({ page }) => { await page.goto("http://localhost:8989"); - // Expect the page to load the script after optimizations have been applied. The script - // should add an editor to the page that shows a main function - const main = page.locator("#main"); - await expect(main).toContainText("hi"); + // // Expect the page to load the script after optimizations have been applied. The script + // // should add an editor to the page that shows a main function + // const main = page.locator("#main"); + // await expect(main).toContainText("hi"); // Expect the page to contain an image with the id "some_image" const image = page.locator("#some_image"); diff --git a/packages/playwright-tests/cli-optimization/Cargo.toml b/packages/playwright-tests/cli-optimization/Cargo.toml index 9fed02332..29d7ae2e4 100644 --- a/packages/playwright-tests/cli-optimization/Cargo.toml +++ b/packages/playwright-tests/cli-optimization/Cargo.toml @@ -11,7 +11,7 @@ dioxus = { workspace = true, features = ["web"] } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true -[build-dependencies] -reqwest = { workspace = true, features = ["blocking"] } -flate2 = "1.1.2" -tar = "0.4.44" +# [build-dependencies] +# reqwest = { workspace = true, features = ["blocking"] } +# flate2 = "1.1.2" +# tar = "0.4.44" diff --git a/packages/playwright-tests/cli-optimization/build.rs b/packages/playwright-tests/cli-optimization/build.rs index 5f93ba74c..bc050c102 100644 --- a/packages/playwright-tests/cli-optimization/build.rs +++ b/packages/playwright-tests/cli-optimization/build.rs @@ -1,16 +1,16 @@ -use std::path::PathBuf; - fn main() { - // If the monaco editor folder doesn't exist, download it - let monaco_path = PathBuf::from("monaco-editor"); - if monaco_path.exists() { - return; - } + // use std::path::PathBuf; - let url = "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz"; - let bytes = reqwest::blocking::get(url).unwrap().bytes().unwrap(); - let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_ref())); - let monaco_path_partial = PathBuf::from("partial-monaco-editor"); - archive.unpack(&monaco_path_partial).unwrap(); - std::fs::rename(monaco_path_partial, monaco_path).unwrap(); + // // If the monaco editor folder doesn't exist, download it + // let monaco_path = PathBuf::from("monaco-editor"); + // if monaco_path.exists() { + // return; + // } + + // let url = "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz"; + // let bytes = reqwest::blocking::get(url).unwrap().bytes().unwrap(); + // let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(bytes.as_ref())); + // let monaco_path_partial = PathBuf::from("partial-monaco-editor"); + // archive.unpack(&monaco_path_partial).unwrap(); + // std::fs::rename(monaco_path_partial, monaco_path).unwrap(); } diff --git a/packages/playwright-tests/cli-optimization/src/main.rs b/packages/playwright-tests/cli-optimization/src/main.rs index f47da783d..cd7f590c3 100644 --- a/packages/playwright-tests/cli-optimization/src/main.rs +++ b/packages/playwright-tests/cli-optimization/src/main.rs @@ -2,7 +2,6 @@ use dioxus::prelude::*; -const MONACO_FOLDER: Asset = asset!("/monaco-editor/package/min/vs"); const SOME_IMAGE: Asset = asset!("/images/toasts.png", AssetOptions::image().with_avif()); const SOME_IMAGE_WITH_THE_SAME_URL: Asset = asset!("/images/toasts.png", AssetOptions::image().with_jpg()); @@ -24,26 +23,29 @@ fn main() { #[component] fn App() -> Element { - let script = format!("(() => {{ - require.config({{ paths: {{ vs: '{MONACO_FOLDER}' }} }}); + // todo: test monaco more.... + // const MONACO_FOLDER: Asset = asset!("/monaco-editor/package/min/vs"); + // let script = format!("(() => {{ + // require.config({{ paths: {{ vs: '{MONACO_FOLDER}' }} }}); + + // require(['vs/editor/editor.main'], () => {{ + // var model = monaco.editor.createModel('fn main() {{\\n\\tprintln!(\\\"hi\\\")\\n}}', 'rust'); + // var editor = monaco.editor.create(document.getElementById('editor')); + // editor.setModel(model); + // }}) + // }})()"); - require(['vs/editor/editor.main'], () => {{ - var model = monaco.editor.createModel('fn main() {{\\n\\tprintln!(\\\"hi\\\")\\n}}', 'rust'); - var editor = monaco.editor.create(document.getElementById('editor')); - editor.setModel(model); - }}) -}})()"); rsx! { div { id: "editor", width: "100vw", height: "100vw", } - // Monaco script - script { - src: "{MONACO_FOLDER}/loader.js", - "onload": script - } + // // Monaco script + // script { + // src: "{MONACO_FOLDER}/loader.js", + // "onload": script + // } img { id: "some_image", src: "{SOME_IMAGE}" diff --git a/packages/playwright-tests/fullstack-errors.spec.js b/packages/playwright-tests/fullstack-errors.spec.js index 432f61295..b87b1f9ce 100644 --- a/packages/playwright-tests/fullstack-errors.spec.js +++ b/packages/playwright-tests/fullstack-errors.spec.js @@ -4,6 +4,10 @@ const { test, expect } = require("@playwright/test"); test("errors", async ({ page }) => { await page.goto("http://localhost:3232"); + // give the page time to finish loading resources + await page.waitForLoadState("networkidle"); + await page.waitForTimeout(2000); + // Make sure the error that was thrown on the server is shown in the error boundary on the client const errors = page.locator("#error-fallback-button"); await expect(errors).toContainText("Error fallback button clicked 0 times"); diff --git a/packages/playwright-tests/fullstack-errors/src/main.rs b/packages/playwright-tests/fullstack-errors/src/main.rs index 2c2f7c70a..b016f3acb 100644 --- a/packages/playwright-tests/fullstack-errors/src/main.rs +++ b/packages/playwright-tests/fullstack-errors/src/main.rs @@ -6,7 +6,7 @@ use dioxus::prelude::*; fn main() { - launch(app); + dioxus::launch(app); } fn app() -> Element { diff --git a/packages/playwright-tests/fullstack-routing/src/main.rs b/packages/playwright-tests/fullstack-routing/src/main.rs index 19588081a..1ea1b2b63 100644 --- a/packages/playwright-tests/fullstack-routing/src/main.rs +++ b/packages/playwright-tests/fullstack-routing/src/main.rs @@ -5,12 +5,12 @@ // - 500 Routes #![allow(non_snake_case)] -use dioxus::{prelude::*, CapturedError}; +use dioxus::prelude::*; fn main() { dioxus::LaunchBuilder::new() .with_cfg(server_only! { - dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + dioxus::server::ServeConfig::builder().enable_out_of_order_streaming() }) .launch(app); } @@ -50,9 +50,7 @@ fn Blog(id: i32) -> Element { #[component] fn ThrowsError() -> Element { - return Err(RenderError::Aborted(CapturedError::from_display( - "This route tests uncaught errors in the server", - ))); + dioxus::core::bail!("This route tests uncaught errors in the server",) } #[component] diff --git a/packages/playwright-tests/fullstack.spec.js b/packages/playwright-tests/fullstack.spec.js index a47ff2cf7..9453341c7 100644 --- a/packages/playwright-tests/fullstack.spec.js +++ b/packages/playwright-tests/fullstack.spec.js @@ -95,7 +95,7 @@ test("assets cache correctly", async ({ page }) => { // Wait for the asset image to be loaded const assetImageResponse = await assetImageFuture; - console.log("Asset Image Response:", assetImageResponse); + // console.log("Asset Image Response:", assetImageResponse); // Make sure the asset image cache control header does not contain immutable const assetCacheControl = assetImageResponse.headers()["cache-control"]; console.log("Cache-Control header:", assetCacheControl); diff --git a/packages/playwright-tests/fullstack/assets/image.png b/packages/playwright-tests/fullstack/assets/image.png index 4d577904815105de1161eb680c3343ff9c452feb..4ad62768f29309905b53a1181894cd0111f8e83c 100644 GIT binary patch literal 14861 zcmeHuRX~(c*Dgp(gEUeS(jg&?q)4|&cX!Fq-3mH%3n&dDozftsltYKK3`0scoXz*0 zbFR<%Ls=4w*g)dBU6&SF z!s}cKL)+^?b91bBBBR* zQ_H+BQ^_#tTmIQGtysLkRw#_wvAKh%K^B63LyN0r;36&I^69Jw`k}xeZ2!xb(f+wy8Icdh^6qI|B*p_zG1?M*O6>1=D0W*i)4Y1F_jge5yvt*@Ju;_%J` z$MEQ;_oGo7D=ovUUiy}~xzf1P9R=%774*Sjb~_w3aoEP3BRZ0Oc)4w=@YeeC@O=8;wmRq zd?}wV0=HO{At)EhL^^{te3L!hZ$dxXAh8uUt`Ms0JTEApn{%o3f`Q9N?6a`nD2@I3 zbZ@LSrJEkE3EclaxqL><$|oU-&Gg{yAhXQb4Pp?JK*9ENQZ@MGirA*VBY&UQZJmxA zMXSbu$fZC$Dzfx}DsK16&nPI$VG2V)jtI+FO7bF{MLonFZ&VR}B221%^@@0dV|*1` zi=Rt{%qd6+n`y`#oKaHnE;wh%vxqFhxKk_=%rn2pLd%F$a>ef^rEWHpW`0c2=@omM z>v_mPUPOH@c|sBsmkg7-HMFlbwOchb%F1xc=bRRa2ju{pbGWtZ>JtW*Q)o_PGrZ=Z0&zWJS~-AI zCGLLHoA-xPZNA}B@kBSn>7!%G4rx_K3%CHi^f}G%nonm)UMUKn}c7~MN+|?@KiopDpZQR z%)FZQdtGnuo>CqLW-O_kT|MjG5!zX8o|WPG#0kD~z715o zVtUTsmdx`B=Iu)_vPjR;$c1t(+o>Gaj+7&-`LvU&bd}{58d8ay z!48)f-$0cbkZxC5z$Au%qVzA%mF^GVG)DB8#wQethf~#aKtxdY)%(J*1ECiV#e@i) zNbpM+&sHkQyY;M?rzyyP4tbHOaj5V}ZII(bWNGH}DQDE>E9<|}(Qyedse;T@1WlY% z5`nZWaQERW!}V`zumq<7m?Hd{773hK%>u3cZqXmaFj;&d$3iA%Lw`Be^71cmezwn- z#Xo*O?M;QbCpwHD-kdFNgY%VD;{V8ImCs;iDQ@(ud{N2&O)6l&4Xm5zlV<=4qcM=% zPy{>r{v=-;(#=lNi32>s3wWirnE8W-{kU@^r8z5nV!=J~+S#HbA5JXc^ecF(H?vS? zY!(Q|I$sI$vSzl_-mclL4jeu`_E=~A#5oD(PC|2g*M5bHtEH_RbQl9gfBv|29S7J4 z-*Zzp1+WtNWUpR2U1#?jU4|S>y2oUpw5$E`3-0<$mTV(+1(u(}MtR#pbvd-T1D$Z4 z{41d!tYVALdNeH~z$ziP><}{#_g|1zo+j{bDe{goLv>L++>`bzCh6rMa7dl z&*X)BHp$XpZ*gd_f2>hRnwgh5{W+8(-3aBU|69k!GW>7R*~e;v&ZunWWiBCCS!^3~ zcIYR?6ap#R->n`(A%?&z4E;M$Mcf9-uw4m$v>&a94JC{4jEY3DXUOx5j=wBRyejvG zED$-~WJ9svg2-3Xc>7%r5@zUStWEynRH-+v&?CN;e?`!;Z=l_DcWVh3l`@^YzeK{B z@{j!R?{)j@74MJo7I4M^D)OSAQ`}29^NR$kG_15Nvj$jJ(cbEoQ6R$-@*uCZ_*TA6*4v11l&GGVl%rsHBG0$sTN4EQk88P5WqhD zPu~Oktf108_x+I3sUH{85@fX$r)cXIj$a;J#bcv{_|LGfl^2gtP8RFbUqRkFi@8Vs~&$y>yeRYA-6YuimT9ZwoB0 z<(to+hQ#3AVB!xXj;usY7Lk3*GNU&)_mh@!#%mz|WIyX3*=V-Oa_xjU)U9wXjKp&( z75=u&wQ)pjlH6+1Gsh6vRm11*XJ*3vd8Ai@7(bm2tTs8Lj(c^TN&Z@%VFH_H3ZF*9(UX^^>*F7Q)hqX+=@N-%yghxL=}R0(z-2Q z6!*z#b17+LB^6!cLdy6<@qrH|7@iZ@N-liMK-(jfsq>Ea&xH_@a^mRL-{CY|n%Y=G zgQ7(j+>!L;MHJk=PSsF!-H)72Xw4X*%~hCP2WLFt)gik#Ns3#*Ij0m6pO1iS^6y?n`>^?5TcMPU3$qo+T`-%9&L>o4;6sF*l0L6Sh+o|e12>dY?Ek!HXkZZ_hxP<$q^^YLGy;mbMa8AUYj~JMuPpDT&mnPC ze^n@>ai;PN+BP$WR{uIVHt;$!5@}5{#S`s}wcA#akuJ2q24^hUCYS3zLP6hh`zPyJ z{rS(tWhN_a$jEr(owo!}Hf4$J%hKr6=W#bk+WGodFCjRdUGEm1EwyCE)X!|$tA0PV zV&de6wI1uJ;M2?6lp*e}G6nKC?hGvrQlUimTdMrUjlI^Q-cD0M(fNXsS2Db-J{W*d z#l)gnwoTiW4O{~AH@U({kv}zab$i?&GnW!aq{X9)Q+$j)k|Gzb%?Ido*l2rI<=^ti zHML{1_rrjdF)K**G+5f}wM){nXMR+^YEY8j;@F-l+sJBYtiwW7MW8GTs7TIK?R^Pp zvj}^|ORGA)z)TySSUx=UPtt}p2%gMNTd3@0c0fJ;tk6Sa)WZByy7!SGkvjSw>6r|L zDGV)N@(FFY(LW~-OpdH8rGB@apmwEIWiFMD5}#{EBsNKA#Rb=D+m%8;1I0DemUT$y zB=^az)G(T7_4NIOmgbkeiMi#33v!q!b5}MNh<*tsFyEMst)$Axww8u$J&yBRDAh)BM3H-8y*z z;|tp|C}|@tT}Jexzv||&DqNH8{c1zhm$rF+R^wq^Rh_JixEPrLb?c8SH^5dWtIh-gb` z1k!`RYV%>58x7nYNiat1>2<0qOloi)OM!jG`EC^6RrB3A>>e)jI1?6{dH*gh(9%dO zuxo%u5DM&njlEHTptAr+ubh%g>yk6eEq+XC_%3zDvcOV<1W#j!G|6H7d)m`PbTlx; ztMyM5zTp>9gVZ} z8!5vM)ENT^;rqHpxMm4WyIU6{OO7#+cK%n@ubL37=D66Pu)Jm@_TeEn9v%5+;M$zP zGjnSMgU6AdT?6%K_vS*ykXQ^ab##Fxl&`~NGN9))^;Al76`O+0T~NP5yGP~q#<(9MUY`Y+U!@DngY**3I<#m=lx8Lcal2(zz-{+M2U=d#LV7* z$*h(udhtVqRUk2Si~e3_6THFiH0Jq{Y*1=Hw$S;v7U{yvCD3HG$s$i>{fPh-2-e^V zwt(HKV7-OP?TaR}87Cwgd^PZ@=lk-@vMz!PwprJfB+|@Ak;N8o);zm-I zm9X&#=~-=d{fRwCpDa3gr9W|Pi+s!+r&S|eUyNkX9nLXxQ{#V&J6B!vHU&y*kNYIU zx>o+g{}m+q3H7~f#dUj0^wLpnU4qH9epN(9b@HNYC1)S;uajr$>0bcPW|xi1IdD5z zUP9gE&6dSDo9wl@^HTn0gM3_o3G}i1CqwFq$;X37?RX5@5Ew~~hY}x82OVdskb=DX zJhRL+r}76_O{)qd7H73{^|0h31WNE43s+!M_?xSoOK9ZIC&$8kwkHu?JvN>+CM^dS-EE0H6U1}(UFS#YZE)Af*-oIt$kx`WnxYtjW6qDB~o(6~WKMHt$Si#0mXQiJrglXdYd9QOHIA^9_ z`muMGsdN7y4_BN^ezW3#=JsC6)(KyhSi!WyM4YjirpY7HTNdWEWzz`fES#kP{O>^`iy_pPj%*SUl}dzMc)k_21Nr zPF=Rtlw8cV>2#;zwv-@1CTQ!0Sf$~ssCo^F%Hwh>&v@^@!IMVtmPb8}vR4c5hLAd* zF7*^47axSG3c(9Hp zQ_^?*95Xf3&-saI@H1~?(oyBBJ_OenjpAl(R{Ut@<;)wJQmv(o%BWWg~qP4)>I3l`41>$w>%w?_Xq3IY2LwQ+p zC;kbz4I?F6+uFk(K7A~nWVlJ6^!1ymnlVZTMw*o%F*TS76&VxMs=T+6Zz7(^bS$r| zcJZQOD_d)P(x(=Z>_wzb&~mA~b(wC^Zu(x1-6;##OcoNcogye%;77xW+`Ri$3_|*gr!bvPIQ=)DkoNoDPh68yT#Q|S)y~Sx0{|?A)Of@IVRGFI zGpU}aB=1MM!0cG=s>4jJ15a!wJ>m)d)K|iNi%Tx3*2Il&1<0vVUVQEWh+g^qT z)0U&5#I<~XvDqCj23y)ggs6@E#HSyR6xj4dSd_)TerX^MHvZ!t>Y4&ueMqh3hDfRm z^oj9;ig35({qV`LMyA)p>s&1KUq5qKO7%0D^qA*!{|>uFYu0Rew6FdGz_ypLmc;{8EZG6By#Gf z5@=^lo#~~nldBEBJ`{Z3t6|#mSW8)QH0dt8x}HeKMwYMS1f`GOPaXn;13Uf=qE98j zh9Y|D$wtGf!%gck9F#qhzs6NRiDu@l6?S1X9WRp`1r#VkV2Mu4k%ncN9ZmU#ZSoD& z%x}XY6$f=s7KGQy%jLb)M3u)gBFMWv`m`04>nvZEn?PW%ofr(Lg>1hGaaa6c)ve|m zqy_Ov&ZG+ykIsO=F2HLqWnw>C|Yqw!Vn?55=B(vYH zUc?HZKz=0}E+*jUr87qt&T=1U;H28BziJ`xF1Vo~3gf0_*VjueAO#<9aScD9ofYkc zYqr|#b}App-NhaZ8h;IJ44cAiNCU^paGQDE! zd50?go0ax&K)SBZ-uY%$wXlo(tDh^l>jki=In^frk;#RsHh+_$oDg-@0HWcxXNCDt zU4(#gDobgMR!u?bMv=|LG*3@*Ms8%$NeXODsKuYKGb)I}JgWJp`3S}?M^Hz1&wUeKPB zvdza6jE0FwKCr8*s0D6d75J*qvb#(vf1`goU8fdGlve>(eqOFO6|&GxP51IoV5KdL z+!W6W4LFEz5A`^NjU;4K%x&$#Vd@JC_5P-ArH~g7Jg5KVxBOGt?P$I=s?Fz8&bMJF z3}&rd#ZGFqw*EU0d`Z^~FBg@;7y_eE-a<^Bw0IEXJC90`AQM=zV=J~Txf)+ z#NyZmD#ME+3)g%SvSLGlUoLjD%p-Q~>?wkMl%Nvef>I4$BDP~yuJxDD!>i-{CK+?q zmXq8o1#a4GAeBFKcU`X@fMLeNs{9kv5+6$iVo{ybp^q#5xWzCl&pOa^XWB5^c209H zt9QXS>eiY^Uve%(U%|mQ&cmwQdbQUS1Dqe2Jj{qC$N!Aac(VnG?-*Fs_r?2{9JK17 z&2_#2Dcy8Xe%ZyDIAwyuV_u&1!06Mb8m6_>H<*vR|7l)?^z};pabvD0)WMH2TTjTQ zoIf=24p!MY_=weh+0*gE1UUd!x+{!?9vzbxa`nph0+s|bgnjVnG?@W@G=81RU+U=Ywx1Bxa{obDU%O1T(?Cjk zyFd4{dBWU71Q5vPeaf&Ng-(hd5T$sVYRE^jWPJVP^T$Nkspg$`|tK{YQ#k&T(1I$Ukg4znF;cH#j!>f9^jbxWjlqX4-U$7 zlEJHYIrBAS^rBS{gQ-8o<+F?F)XoDmK9QlN*>QU|Q@b|7Quj)tN4sWS*|OU{gVScC zwE*?P_R_ZDWKhn2C9dk*D8?Ru1#%1Gwj2OTD`7DzvBLNU(>Rnqm|7#*$45>_ts3b2 z%8Swtrf(_M+Kw6bB9C(UiiSSn8ydz&%{jx(-z7#yDz-oC8q;$86@4>mCjPfp?e^mM z2mcVC?#iFzFEa~q^P^t5si7b{mv!m#14zgag0K0kif`~aOtRL9{K3zb!?V}B=oL%a zAK$!yl}V>P<^6nD_~eDHaEQaibUP<)R7o+=0tG0+O^d(&r2Pf;(f2C>*4?ELNAJoW znj4<5Qjeum7aYb)H`WkuP16=l2%e_;Y99SztF7_}e^#1HZ@)v^Akjl^F;}D6B*|w! zt6zFS6qU3%g0y?RZ0{sy^_{{R<&?K72x==1KArS8>2=J=Z>HP~h}gkinq-zVH&1*T z_zz}{eGYvV6y(~{=D|PKUZ>KU9wJXHiko;_QmV^$ zF(hj&C{Jt++p{FlpOar>d1FE(7s%d;#WJZJMyy$1;Gk3saAVcgyV-s|I=iiqI!I;{ zp4>?wkeMLkQh99xP`0Q^Dut5mg_$h_4<>}(0JD!`;e#^kw^x(33rcZf;gO0;S2oOI z2f$p}LcYNDB|3Yh#PcXVFy8+!(Em@r??2dX7fL?-rRLLs{6f0%kNGOg-ouNe*09FD zf`W^Lp)=&2wlFlr4rH;Wh(DW z>eo^Qss-w}SKJ`+qM-4!MmQ+a&t*-~n>JsYRs)a(LZy|t<*>?vfOj*L=m|&_6R%BD zMa=ofQrmm}+4Nx}MNE1krS5UYdmkXf_McgV=gLeQDzzP+Af}3u; zo$8^wSbMnocPRODvi&R0nz-HmpVGuXWUjY7rRD8M))#A;KQ0-()dmXntrOua+w$@r zBo{p==aD=oZyewLZo(H8;Fhytg5)DrF*=s15M6m!kpR~(aNhJL-UwP(%lS5I-SEBz zp|Qn5gc; z;I!Izmq+cA2qnP@VP&i@%b<{}RHhhJp_@uMiZ+lol9y-V<&SWDN^@x(;eAN}f+LH5 zI;0kx#xb~uHgo`Fol2RH8d|bgvt_M;&(wNVbXIFnJ79hQBN^u+(_d%C;cw^mEz&+j z3a$r#N-Qj}&wO!+VDB_SJ~lB&Gwh>UDqo;!wh4ujtG{=QJ!oFwRqAKBTfzb3VUAOrD7#k_Na-R>=L5 zxfqcs>%60>l#(C`l%M;mvJHTfFT(x~+CT4nzHpvZRlQ*{o#d;_(MQH3Z9eFRWLQ^! zZ2mfql2&b7SJUF!uPW!z9?4avjN-Uo89Utct?c5$YmC?f)2(3w2EX?MOE?TW*QvL~*uw9d{?3xD%`-iF-2n{j%PY;_ z#1$TW?GlX~;I`}g`O^T&woV9A;)X(&zGDlSJ6;~T7XmbP`ql;lKl5!yKR`3H<}^)f zNwt)i#`wO=2NmU7BaCACAL&?X_qM<_2d;d+V&H_`5U=@ z$Hl@E2Iq?uO2q11$ltl!>ZMJBckJtAAL0pKF80exPFRCVo~EWnYghjF5eILOV^+=8 zQ72s*?ipP(^pP#5PAshu()_y-#mIh6^XktJVph-DT9!Tmu3xnd9UU%rX-Oujw0jv> zs!rwl$;V4;foliZcUR3&>_cIRo&ugyHvV>hbGfawLJzhf(0t0hk(*ppnhsj~`I90c zi_nU2O~=pnQB6NnBSREG!v*>A_h6*@UJ!#(w*tt=IJIRejyC!eVrNn4;?93-$hcIo zYCMX}hayt+w5zotIKG6qNsqzdi5um+c~QrJt8Sh<%&4cF9Y4seFtdjccVoyDgYYC^ z=K^7)jcNsv903h@np=YTFY;nsAovYM%c*Hu=ClG9l56gf8NIFmdTmRA)UFD@97 zA|{jcY0Z%Zs-vv@VU(2SQVT9hI;oke+fqZ_@bxXO+-gNHugrK;=EcxlwfT+bdDrl- z!tnU#|LRE~`A=l%HJvs=f09kft=%sN?Dv)oH3IBGverY+! z)bq5Ly_FAr`jezzJ`>l5)M7q$36xay9_|J2*;4vFtc-^0fVf$Fo~Gb4B>7?Yj#QHV z>DOo9<|PigM_hO7Nfzf!cE@g+d=8#>?1^a!yoi1-U7RHPkBQRDeSZY4<^4zq#VZJ4 z9Zl1-kHk1g!Jy3c0A zXyQE63cZ6As6#6SXr|H%cg(?B5DVxunn<=7CE1JVoR^>+0hd-#eQXY28j6c39->P* zh=3BLfC>(6ZQceNT-0x9G5h@*!v2tGYy5W|-zENBsOPU~5b;jH#gxrT^*zX+c}Q15 zO~ct>2~=4n6GwipQ9E!i2q5G-D-vOO1&ZUYJ?Rg{5GiUv?P|D8D_Fi((@V~<`r8IB zhagjkJbYrhVfo6#HR2=aY9;5J3rgIKF2%#7w?GBQQ>}MVkYh~~`M9hqbhl9RD5_0o z^&K>|z`?~qI&gTO&w4|}CI5_ZR#caf&LB)x$iFZ@9wO3>C!*F1>cbWw{mFc8>Fi%n z+mr`NJZD2~n8GUH@V^g`!6rkc`VeAVfn^age-g(?d*hC|x-cPCebW|!de|J869W3qo*q1-gA^G>7|U za_ZkJ5Ecni|GImR29nG#UV_qlz1_GQVpa5h7_LCbhJ1IzU{p{Rlz5Sb^lwAQ{5W~{Pd$1k5jahYftDLX&b5E@##HjIyP+lf zC+Q=3Nt>NdURukpffrYf*6xp)?0s`E`vJ+1wF_kZ37Cq$!ao$snVZyPRtB{x?jbN)6O6b7eDA=x)6lRIkqT~$qvU+)T$0bTc;zpL74y-6|Xiyq_Clu+o*qd{PH-;DDrF%v(8A6ZkI|) z>_J)~Bkrt>x23i9Hz?^_LQ~Fz=8FWkS7yeW8TiPP7AMGL~%DMKcSVE zUnfC{-pVga9s~(XlC(>wl+F-LVnJa%fee=OS(KNE;zG;&X`R{HtU+(_Ef(&0v0U^x z+$AnzKV`%{Z!0{ofJuXp>NqWpc(F+b5`_az9=&P+6rKB#74#WF_#xGBQMC!T2;Pn> zMYUtasiGT&n84XNcLU=(rLltMNS(obt4bH!AKc_O3`CJ-;$wC1J z-V+eCXL(Q2qK#)%vaRq+OrudND-&Uil>v0}84~?p(??aF9sjeEF=faO53}A>?vSf& zdr@dP0OupC-B3+{Ne=>2Mg|DM&)?7(dnx>O0tAy+^iB?+>l+p26SO7v{Q*tQS1(=S zZ2;6a3kn;jilc% zEz+KxWBcDgAF)P4^eh%kB?_kEmn4CGczAaC6#3Ye1Rj!i8ybAWKooV0EHzUfhPCh5 ze8~9xV^C_MwrOhVDaeKUg?fcK&Z`#Lg-zyic>o!K>+j6(k#47p;I)GMfw8)v0qRo% z9qm>g+DJcg`E-dY1`@hrRHZg=UkLrM2x#oj6PSPoF#{eA%yI=Y5E zj>Vz;p#_Ch3E}pn*!#sTDd3vy3(hDkwPRfIwvb?B{Ck#fum683Iyr!i)35pr+Jo87 zmEBX?9n|mkr?Ur8eK9U29#NE+CBqn_E7Q*jaL3nxVoZ@@{9Q6G!|xe8=qXSzq^B;^ zKJU-5(#k|dm5N>dGJPPTyGoygXWSu9a?28ehvYIQhKGFZk$*v$xCkwvAWS~x#}~fb z2?8#}jk6It-uwuDu#-Qk-2XX;xKq6fURU#pI|MO*3if9<0z52L zfU*Aq{3!_+Ey-#>>d5Umf8X^2cc`IpxyU_Dq5tzyKf8XM0p-Kdq>*LT|28HRk&mHt zLH`O(J=-0K1v7l9U+{R%1`g!@!iHj?|=WB0Qa8XvM1U0t;2KUz(iXClH zWK)5GK(-#OryPU626s}%P8m?_EmX2NS1GY-JxJ8{>z!Gn>$N~G6+(_@$D_)&ZsU31 zDG7g^Cea5V4N)xM{6?uU`i&zA1k#A;NF+a?&v89 z2D)v!min`KNsK#gY4brv_b_CWS@b%ta))4IwW#xa$^RDZ{SPH1Wd%GwPTw>O80I|_ zixJG2VA<0q@ie=K`>X190)jejLK*RydFH^2Cdc=f{y`&3$#p8jpMRIYPGmrN#1@d= zN>19p&&a?T+1_*H%!K(o(c8CnNRNUWiZ{QY01{_Whz%6(H83@@G$?`+DAlmU-S~IP zWt39t5*5&xngV*;kAq%`H9#D*_9*{Iqk|<)B%cAqpwFceC({1g&rEE|M}#9g)QF@` zvnVPcc@gp&g6T<%JI=&P;}`FB`d(tmC*tWOmMr+p7bEmL>M1UH5f(r(N2ur1iK5_CQNo)Ry z7%UQrnuH%%1jTFw7j6E6mH6jWF~3SEIz{1=-VIhvI=~-axK{A73eo8|I6K0wTsF8& z`kQSlkJ(%98`?~KzmL@lys@nr^nWBvZFo)i+hMi+F2%y^=Rs=?6En2bW6ZM}p!)G`7@{6-*jC6K8{8pwC?uHAhME zz8)Bame;l?AnJ*d7j^eVj30)*)i?%>Ifsw-`fyPN)a~2zJSh5f0rLZa@O3OO*L@sU zLgMk*-&_=h6DmhD$d95~B|H>aj#LVo53(*qO62uC=VeGdyQUN*;Q}z#9>&B7)pn?9+9+u`DPAW~) zB1k-2%Z8$5ik8cj!W*Q*XJrgu4*ZpzpR$V-i%r5JkyJ72DKM!KHzgfSzzvi^<4G8C zXZ>|{(v`L})JK1ICVp4Ld>KFV+!TYv9Z9)2R)?+RI-CfTMgU6igp~|0X8e=vHE59e zY0u3(R=3i>HJQPLw>xZ?ZJ?GH<^P&QtAJ^&`5>ma1XL5&DK^ip;0LjPZUDy+CE|HS z0&F=4ZYavVjmo7Ws|vtRyVSluyN?wXy-?x{h}&7vx1@z$V)rXvm!;5~C)u08#972=Z>q5l+02 z7_;!QJ+4#)jETWFZa(8p+uh})4Sm42mL2ke@5P5~K26AjY`|rfezZhy&cWn@Hu=~% zF~o%X>bI?JJ`~UU!zjW-rMH@IvS1>6qeX|Bp#MUXkqWSJ0L@e;{PpvnP!1CeG66{v zx+2t`0*_Cd!+`k>h~&=}|1i*YaqFk^1y(_^Ap+z@7J|$9a4Ly%B&D|ovuI$Wt8~dp zI#|K3NdasDu*CU$qbe4*nX%?B_!&hrv!yOi8w)I(m~1B$kl0Y0RuT@Iys$_rgh@3s z*IL^!I2`}Z>TfvlJqsL%>XF*^`MxG|_FB)e#!JWp45JQiV4(~AZrH_EWI$;Ho=jIJ z1*o%dQ3)qE1F43mOxDONl>%D3R9HelJ}*5G$_U_O6X7=*L3)rMw#Hd$L$Q&n`wH?3 z68JKOOk){97q6W**9rwbjciIdD}-{>hCis-Kjj9i!~$HWEcG!1jj(-u^I#6m$M) z#W%1wzyK!&bN5E=y%%Q|cjv<((jRe^9q zbGu3}qyi~QW;S*o+xV22r#Lpw9@`fJ^&n}8T-Ke)x*6h*){bz+n3D`^ja?*D*p677rgNI3xz2W@37K&I5O9yx5kv1O1^!Gnv zn02Z5M-SbB)iOrU5_ioU+dG7P@WHD)F}C$PcKzJkgl$)XX#dYt-~nGnHwmDDP4Sk+ z8Z%}yW$0w-5Wy^-Ja^Wcf6zI-L}Q0K^C|S*yLZ=s$@M?a0smj$KwjU0mz{TaSn^C; UF*ZCk;13iX+uL$Nkc;* zP;zf(X>4Tx07!|IR|i;A$rhelQb}k51f+!C1Q96#q=e7{NH2nokOT-N#z0W9ql*fz zT|~f!u8JsjK*&GAUd5M4^8p~rd5bCe5;=wy=tyCJ5HFPD3?;6T<1{7Sqp*pO zqERym=_eMve_t|6sz)u)~8E3Y!!cwCsp{B`+6YDPGGC_UuO!rDt%17 zFf%?0Vk*Rz1){`Ah}9u>%FB)mSJ{jz%;ZE4*hpou^7t(wjunbS0+jk#LAo?JR%Kt3 zn>R3DY-6T~8>zzk@-yQ@mHEM1_&i1Ya4y(op_rX8pd-v16EQFsfiN&gId`l}C{Bn| z>3opn2gEAJ;#5gaw8|bg7v_Y>d2@(e^9tfrWAVg%xFD7Kc)BD%I$jwkUYNxTi&n;i zF9Q*P0|EgP$Y4zalRy>_we>Xgz&mA6FyH|x$N)m%sA2|2Bq@5t@EQjS!JiNbLEhlF z07b756hQv#AgjDOf^gGM(VzqB2di?C_G2z^YpCgl2jD(X9V+8`xFK-FU7;T~?gu@)L6)P6 zTb>o>PuhQ*!4FSEWk+$Qpp+pLe~RtTBh~HI-3C?Ux&ARf%2?9|^UWFT`Po0`hwKYr z8~(E;wh-HooyAUK=dmML17Kpuu|}*FJ1WP6tLuqkbq#onRm77GbwvO0H}GWTnap#p zb?$clqU_5TPL;o-0cVnAnpBjLna^ayFM^QC74w~jF95ML%OP;$8ZLV#}x4Y<<`fC;bw zws1Z!z#Vu4e-H%1Kop1v$zTGEJ`=8UDVPFgfVrR;l!9eoHK+g^z-F)=>;n5h12_Us zf-~SExD2j?yKwb%f)}6{yaS&R1R)`Ggn<|!ric~dfVdzm#1{!dB9K@l8A(Ml5HTV{ zW+3yBQe*{Efowu*kiEzuVnP{VE8!sFG~7#_gm*+DQIBXt^niOYnV3bKPFze}N8C+3L2M^JCiam? zBmS^jd>KipRHFGsjwHP&# z+8niVwLNO*)E=sRq-oRaX&l-(nvAxDwvBdzcANHwPN!Sb{pl(6eEJf44gD1T0sVtI zL)}T8t1eKVtzM~KuYN`Sr3OXAN`tL2L8DNkOk=;sC5;|Uil((DN0X;HOS4k*kmgOz zJ}rjU2(2isT&=}gJG9Pgb!n5dt+j)+1={nqH*25Le$2oa77Px9&zQ&9!Z^+7)FJBF z=x}wibV_x0>9pze>T2t{>L%(=)veS$s@tK5=~?S>^>Xx<>h06Jq4#Ns@euY9;gCf` z>V{ky@?L+azQ4XuzeInx{&oFN226t>gKUH41`P)H4RJ$z!x+OUhU*Pa8}xgjdT(uGon*bhy1}~3M&E{OQ)p9bbI+D;>t{RJw%YcZ z9np?umuf zw&xMgk6!Lx`Cj|H{_=M8&i3Bn-8IT)lyKCRQICDheRw{bd^&uYzNx+&eLMWj{CIxV zevkYu{0077{hzVz*jenI?3V$~0n&hj0q;3poSB>xfmmQjU`b$Gkakc)P({##VDsRN z;M(Bc5Vw%&Atyo!q2Zy+LvM18xqNO7w>QiqY-U(XI4wLbydwNjgnfiGqG>cbI&AdH z(f1;)BE^vnQ6P#NwKD2{v`zHn=)*C@nCO_wn5VHLVrRsjjbp@3jN1|SE_Ja-Jg?`vpbiZo0@w_ ztS!zKw@fmbG<8zD#6eOl>6q*_dG+L8X}EN29+EdcuQ6W-{>Zn=Y-PX59v1i(R2F=g zk~n4mRIRDvsjbuOr!AWHtT4Fnx9RwF{`BSfG$PzZW?dttfgsFKJ%WeB=3Z=0E-=~{IHY~&!iWasl8o6lg zqHiUFk_)BIrDdgG7V{Th_;tju<-hhXNng^o)P3ptWyEFTWjB`lE#JOEYenIT$16v# ztY5`kRkG^s>WQn*m${a0SVLVSThmbo)PWj_tAA zTWUONYIYdySXztL%4)lJj@x;8SKzM3I>)+AyY+UL?g4vbd!Fx2-FstS_`c@-to?Nd ztPWHiWE?E1N9w25_cmlUbTlS6wjT;R)ZFCVbnvj#;cZ9Ej#M7iJ-YN5_1OGl{l^QB z_nnZO=sB5j@^SOT=DVlHoVwN$)6#Z2{Pejq!DmjLWuHBM&ga~b^IqoY8T{(F*@amas+-n!FM_zBgk#OVI&2cw7 zZV7I6-OjoF>Q2F(&v)nCBit*wuX(@hfzgBNKkWY4-QnJG^kLw`)<2m2h(jDA=`T5xAoi8LWKKB&=sr6^oOWT(R{__8;wRcQ!=PT)} z{@07&7`)l`*7a?3Uv%Gtce(FAzc2Y<@L~H$*2lA-l0H5AJmm}NOZiv3uT9^=zuoK4 z?eCZHq&&s100t|Xo({n4Z2-`d0bs!M8Fj4k{HG|0^31A0;6GvI8BtN(0jPuj?=d~_ z@8%`|d$z#i7u44n2S79(fOt=UjvP5y2hM7WbEUix;W=Pq)Yr7!H1KbV^33@|+&{NK zuIdVYZvO=l0(@2T=%Kg(001~;SV?A0O#mtY000O80f%V-1ONa40RR918UO$Q0007@ z0ssU6000310007@0{{d700031000620002a(4LS0000w7L_t&t9b=T2m;cWI1WX_T Z1OOBG0bqAaE;0ZB002ovPDHLkV1iKSREhuq diff --git a/packages/playwright-tests/fullstack/assets/nested/image.png b/packages/playwright-tests/fullstack/assets/nested/image.png index 4d577904815105de1161eb680c3343ff9c452feb..4ad62768f29309905b53a1181894cd0111f8e83c 100644 GIT binary patch literal 14861 zcmeHuRX~(c*Dgp(gEUeS(jg&?q)4|&cX!Fq-3mH%3n&dDozftsltYKK3`0scoXz*0 zbFR<%Ls=4w*g)dBU6&SF z!s}cKL)+^?b91bBBBR* zQ_H+BQ^_#tTmIQGtysLkRw#_wvAKh%K^B63LyN0r;36&I^69Jw`k}xeZ2!xb(f+wy8Icdh^6qI|B*p_zG1?M*O6>1=D0W*i)4Y1F_jge5yvt*@Ju;_%J` z$MEQ;_oGo7D=ovUUiy}~xzf1P9R=%774*Sjb~_w3aoEP3BRZ0Oc)4w=@YeeC@O=8;wmRq zd?}wV0=HO{At)EhL^^{te3L!hZ$dxXAh8uUt`Ms0JTEApn{%o3f`Q9N?6a`nD2@I3 zbZ@LSrJEkE3EclaxqL><$|oU-&Gg{yAhXQb4Pp?JK*9ENQZ@MGirA*VBY&UQZJmxA zMXSbu$fZC$Dzfx}DsK16&nPI$VG2V)jtI+FO7bF{MLonFZ&VR}B221%^@@0dV|*1` zi=Rt{%qd6+n`y`#oKaHnE;wh%vxqFhxKk_=%rn2pLd%F$a>ef^rEWHpW`0c2=@omM z>v_mPUPOH@c|sBsmkg7-HMFlbwOchb%F1xc=bRRa2ju{pbGWtZ>JtW*Q)o_PGrZ=Z0&zWJS~-AI zCGLLHoA-xPZNA}B@kBSn>7!%G4rx_K3%CHi^f}G%nonm)UMUKn}c7~MN+|?@KiopDpZQR z%)FZQdtGnuo>CqLW-O_kT|MjG5!zX8o|WPG#0kD~z715o zVtUTsmdx`B=Iu)_vPjR;$c1t(+o>Gaj+7&-`LvU&bd}{58d8ay z!48)f-$0cbkZxC5z$Au%qVzA%mF^GVG)DB8#wQethf~#aKtxdY)%(J*1ECiV#e@i) zNbpM+&sHkQyY;M?rzyyP4tbHOaj5V}ZII(bWNGH}DQDE>E9<|}(Qyedse;T@1WlY% z5`nZWaQERW!}V`zumq<7m?Hd{773hK%>u3cZqXmaFj;&d$3iA%Lw`Be^71cmezwn- z#Xo*O?M;QbCpwHD-kdFNgY%VD;{V8ImCs;iDQ@(ud{N2&O)6l&4Xm5zlV<=4qcM=% zPy{>r{v=-;(#=lNi32>s3wWirnE8W-{kU@^r8z5nV!=J~+S#HbA5JXc^ecF(H?vS? zY!(Q|I$sI$vSzl_-mclL4jeu`_E=~A#5oD(PC|2g*M5bHtEH_RbQl9gfBv|29S7J4 z-*Zzp1+WtNWUpR2U1#?jU4|S>y2oUpw5$E`3-0<$mTV(+1(u(}MtR#pbvd-T1D$Z4 z{41d!tYVALdNeH~z$ziP><}{#_g|1zo+j{bDe{goLv>L++>`bzCh6rMa7dl z&*X)BHp$XpZ*gd_f2>hRnwgh5{W+8(-3aBU|69k!GW>7R*~e;v&ZunWWiBCCS!^3~ zcIYR?6ap#R->n`(A%?&z4E;M$Mcf9-uw4m$v>&a94JC{4jEY3DXUOx5j=wBRyejvG zED$-~WJ9svg2-3Xc>7%r5@zUStWEynRH-+v&?CN;e?`!;Z=l_DcWVh3l`@^YzeK{B z@{j!R?{)j@74MJo7I4M^D)OSAQ`}29^NR$kG_15Nvj$jJ(cbEoQ6R$-@*uCZ_*TA6*4v11l&GGVl%rsHBG0$sTN4EQk88P5WqhD zPu~Oktf108_x+I3sUH{85@fX$r)cXIj$a;J#bcv{_|LGfl^2gtP8RFbUqRkFi@8Vs~&$y>yeRYA-6YuimT9ZwoB0 z<(to+hQ#3AVB!xXj;usY7Lk3*GNU&)_mh@!#%mz|WIyX3*=V-Oa_xjU)U9wXjKp&( z75=u&wQ)pjlH6+1Gsh6vRm11*XJ*3vd8Ai@7(bm2tTs8Lj(c^TN&Z@%VFH_H3ZF*9(UX^^>*F7Q)hqX+=@N-%yghxL=}R0(z-2Q z6!*z#b17+LB^6!cLdy6<@qrH|7@iZ@N-liMK-(jfsq>Ea&xH_@a^mRL-{CY|n%Y=G zgQ7(j+>!L;MHJk=PSsF!-H)72Xw4X*%~hCP2WLFt)gik#Ns3#*Ij0m6pO1iS^6y?n`>^?5TcMPU3$qo+T`-%9&L>o4;6sF*l0L6Sh+o|e12>dY?Ek!HXkZZ_hxP<$q^^YLGy;mbMa8AUYj~JMuPpDT&mnPC ze^n@>ai;PN+BP$WR{uIVHt;$!5@}5{#S`s}wcA#akuJ2q24^hUCYS3zLP6hh`zPyJ z{rS(tWhN_a$jEr(owo!}Hf4$J%hKr6=W#bk+WGodFCjRdUGEm1EwyCE)X!|$tA0PV zV&de6wI1uJ;M2?6lp*e}G6nKC?hGvrQlUimTdMrUjlI^Q-cD0M(fNXsS2Db-J{W*d z#l)gnwoTiW4O{~AH@U({kv}zab$i?&GnW!aq{X9)Q+$j)k|Gzb%?Ido*l2rI<=^ti zHML{1_rrjdF)K**G+5f}wM){nXMR+^YEY8j;@F-l+sJBYtiwW7MW8GTs7TIK?R^Pp zvj}^|ORGA)z)TySSUx=UPtt}p2%gMNTd3@0c0fJ;tk6Sa)WZByy7!SGkvjSw>6r|L zDGV)N@(FFY(LW~-OpdH8rGB@apmwEIWiFMD5}#{EBsNKA#Rb=D+m%8;1I0DemUT$y zB=^az)G(T7_4NIOmgbkeiMi#33v!q!b5}MNh<*tsFyEMst)$Axww8u$J&yBRDAh)BM3H-8y*z z;|tp|C}|@tT}Jexzv||&DqNH8{c1zhm$rF+R^wq^Rh_JixEPrLb?c8SH^5dWtIh-gb` z1k!`RYV%>58x7nYNiat1>2<0qOloi)OM!jG`EC^6RrB3A>>e)jI1?6{dH*gh(9%dO zuxo%u5DM&njlEHTptAr+ubh%g>yk6eEq+XC_%3zDvcOV<1W#j!G|6H7d)m`PbTlx; ztMyM5zTp>9gVZ} z8!5vM)ENT^;rqHpxMm4WyIU6{OO7#+cK%n@ubL37=D66Pu)Jm@_TeEn9v%5+;M$zP zGjnSMgU6AdT?6%K_vS*ykXQ^ab##Fxl&`~NGN9))^;Al76`O+0T~NP5yGP~q#<(9MUY`Y+U!@DngY**3I<#m=lx8Lcal2(zz-{+M2U=d#LV7* z$*h(udhtVqRUk2Si~e3_6THFiH0Jq{Y*1=Hw$S;v7U{yvCD3HG$s$i>{fPh-2-e^V zwt(HKV7-OP?TaR}87Cwgd^PZ@=lk-@vMz!PwprJfB+|@Ak;N8o);zm-I zm9X&#=~-=d{fRwCpDa3gr9W|Pi+s!+r&S|eUyNkX9nLXxQ{#V&J6B!vHU&y*kNYIU zx>o+g{}m+q3H7~f#dUj0^wLpnU4qH9epN(9b@HNYC1)S;uajr$>0bcPW|xi1IdD5z zUP9gE&6dSDo9wl@^HTn0gM3_o3G}i1CqwFq$;X37?RX5@5Ew~~hY}x82OVdskb=DX zJhRL+r}76_O{)qd7H73{^|0h31WNE43s+!M_?xSoOK9ZIC&$8kwkHu?JvN>+CM^dS-EE0H6U1}(UFS#YZE)Af*-oIt$kx`WnxYtjW6qDB~o(6~WKMHt$Si#0mXQiJrglXdYd9QOHIA^9_ z`muMGsdN7y4_BN^ezW3#=JsC6)(KyhSi!WyM4YjirpY7HTNdWEWzz`fES#kP{O>^`iy_pPj%*SUl}dzMc)k_21Nr zPF=Rtlw8cV>2#;zwv-@1CTQ!0Sf$~ssCo^F%Hwh>&v@^@!IMVtmPb8}vR4c5hLAd* zF7*^47axSG3c(9Hp zQ_^?*95Xf3&-saI@H1~?(oyBBJ_OenjpAl(R{Ut@<;)wJQmv(o%BWWg~qP4)>I3l`41>$w>%w?_Xq3IY2LwQ+p zC;kbz4I?F6+uFk(K7A~nWVlJ6^!1ymnlVZTMw*o%F*TS76&VxMs=T+6Zz7(^bS$r| zcJZQOD_d)P(x(=Z>_wzb&~mA~b(wC^Zu(x1-6;##OcoNcogye%;77xW+`Ri$3_|*gr!bvPIQ=)DkoNoDPh68yT#Q|S)y~Sx0{|?A)Of@IVRGFI zGpU}aB=1MM!0cG=s>4jJ15a!wJ>m)d)K|iNi%Tx3*2Il&1<0vVUVQEWh+g^qT z)0U&5#I<~XvDqCj23y)ggs6@E#HSyR6xj4dSd_)TerX^MHvZ!t>Y4&ueMqh3hDfRm z^oj9;ig35({qV`LMyA)p>s&1KUq5qKO7%0D^qA*!{|>uFYu0Rew6FdGz_ypLmc;{8EZG6By#Gf z5@=^lo#~~nldBEBJ`{Z3t6|#mSW8)QH0dt8x}HeKMwYMS1f`GOPaXn;13Uf=qE98j zh9Y|D$wtGf!%gck9F#qhzs6NRiDu@l6?S1X9WRp`1r#VkV2Mu4k%ncN9ZmU#ZSoD& z%x}XY6$f=s7KGQy%jLb)M3u)gBFMWv`m`04>nvZEn?PW%ofr(Lg>1hGaaa6c)ve|m zqy_Ov&ZG+ykIsO=F2HLqWnw>C|Yqw!Vn?55=B(vYH zUc?HZKz=0}E+*jUr87qt&T=1U;H28BziJ`xF1Vo~3gf0_*VjueAO#<9aScD9ofYkc zYqr|#b}App-NhaZ8h;IJ44cAiNCU^paGQDE! zd50?go0ax&K)SBZ-uY%$wXlo(tDh^l>jki=In^frk;#RsHh+_$oDg-@0HWcxXNCDt zU4(#gDobgMR!u?bMv=|LG*3@*Ms8%$NeXODsKuYKGb)I}JgWJp`3S}?M^Hz1&wUeKPB zvdza6jE0FwKCr8*s0D6d75J*qvb#(vf1`goU8fdGlve>(eqOFO6|&GxP51IoV5KdL z+!W6W4LFEz5A`^NjU;4K%x&$#Vd@JC_5P-ArH~g7Jg5KVxBOGt?P$I=s?Fz8&bMJF z3}&rd#ZGFqw*EU0d`Z^~FBg@;7y_eE-a<^Bw0IEXJC90`AQM=zV=J~Txf)+ z#NyZmD#ME+3)g%SvSLGlUoLjD%p-Q~>?wkMl%Nvef>I4$BDP~yuJxDD!>i-{CK+?q zmXq8o1#a4GAeBFKcU`X@fMLeNs{9kv5+6$iVo{ybp^q#5xWzCl&pOa^XWB5^c209H zt9QXS>eiY^Uve%(U%|mQ&cmwQdbQUS1Dqe2Jj{qC$N!Aac(VnG?-*Fs_r?2{9JK17 z&2_#2Dcy8Xe%ZyDIAwyuV_u&1!06Mb8m6_>H<*vR|7l)?^z};pabvD0)WMH2TTjTQ zoIf=24p!MY_=weh+0*gE1UUd!x+{!?9vzbxa`nph0+s|bgnjVnG?@W@G=81RU+U=Ywx1Bxa{obDU%O1T(?Cjk zyFd4{dBWU71Q5vPeaf&Ng-(hd5T$sVYRE^jWPJVP^T$Nkspg$`|tK{YQ#k&T(1I$Ukg4znF;cH#j!>f9^jbxWjlqX4-U$7 zlEJHYIrBAS^rBS{gQ-8o<+F?F)XoDmK9QlN*>QU|Q@b|7Quj)tN4sWS*|OU{gVScC zwE*?P_R_ZDWKhn2C9dk*D8?Ru1#%1Gwj2OTD`7DzvBLNU(>Rnqm|7#*$45>_ts3b2 z%8Swtrf(_M+Kw6bB9C(UiiSSn8ydz&%{jx(-z7#yDz-oC8q;$86@4>mCjPfp?e^mM z2mcVC?#iFzFEa~q^P^t5si7b{mv!m#14zgag0K0kif`~aOtRL9{K3zb!?V}B=oL%a zAK$!yl}V>P<^6nD_~eDHaEQaibUP<)R7o+=0tG0+O^d(&r2Pf;(f2C>*4?ELNAJoW znj4<5Qjeum7aYb)H`WkuP16=l2%e_;Y99SztF7_}e^#1HZ@)v^Akjl^F;}D6B*|w! zt6zFS6qU3%g0y?RZ0{sy^_{{R<&?K72x==1KArS8>2=J=Z>HP~h}gkinq-zVH&1*T z_zz}{eGYvV6y(~{=D|PKUZ>KU9wJXHiko;_QmV^$ zF(hj&C{Jt++p{FlpOar>d1FE(7s%d;#WJZJMyy$1;Gk3saAVcgyV-s|I=iiqI!I;{ zp4>?wkeMLkQh99xP`0Q^Dut5mg_$h_4<>}(0JD!`;e#^kw^x(33rcZf;gO0;S2oOI z2f$p}LcYNDB|3Yh#PcXVFy8+!(Em@r??2dX7fL?-rRLLs{6f0%kNGOg-ouNe*09FD zf`W^Lp)=&2wlFlr4rH;Wh(DW z>eo^Qss-w}SKJ`+qM-4!MmQ+a&t*-~n>JsYRs)a(LZy|t<*>?vfOj*L=m|&_6R%BD zMa=ofQrmm}+4Nx}MNE1krS5UYdmkXf_McgV=gLeQDzzP+Af}3u; zo$8^wSbMnocPRODvi&R0nz-HmpVGuXWUjY7rRD8M))#A;KQ0-()dmXntrOua+w$@r zBo{p==aD=oZyewLZo(H8;Fhytg5)DrF*=s15M6m!kpR~(aNhJL-UwP(%lS5I-SEBz zp|Qn5gc; z;I!Izmq+cA2qnP@VP&i@%b<{}RHhhJp_@uMiZ+lol9y-V<&SWDN^@x(;eAN}f+LH5 zI;0kx#xb~uHgo`Fol2RH8d|bgvt_M;&(wNVbXIFnJ79hQBN^u+(_d%C;cw^mEz&+j z3a$r#N-Qj}&wO!+VDB_SJ~lB&Gwh>UDqo;!wh4ujtG{=QJ!oFwRqAKBTfzb3VUAOrD7#k_Na-R>=L5 zxfqcs>%60>l#(C`l%M;mvJHTfFT(x~+CT4nzHpvZRlQ*{o#d;_(MQH3Z9eFRWLQ^! zZ2mfql2&b7SJUF!uPW!z9?4avjN-Uo89Utct?c5$YmC?f)2(3w2EX?MOE?TW*QvL~*uw9d{?3xD%`-iF-2n{j%PY;_ z#1$TW?GlX~;I`}g`O^T&woV9A;)X(&zGDlSJ6;~T7XmbP`ql;lKl5!yKR`3H<}^)f zNwt)i#`wO=2NmU7BaCACAL&?X_qM<_2d;d+V&H_`5U=@ z$Hl@E2Iq?uO2q11$ltl!>ZMJBckJtAAL0pKF80exPFRCVo~EWnYghjF5eILOV^+=8 zQ72s*?ipP(^pP#5PAshu()_y-#mIh6^XktJVph-DT9!Tmu3xnd9UU%rX-Oujw0jv> zs!rwl$;V4;foliZcUR3&>_cIRo&ugyHvV>hbGfawLJzhf(0t0hk(*ppnhsj~`I90c zi_nU2O~=pnQB6NnBSREG!v*>A_h6*@UJ!#(w*tt=IJIRejyC!eVrNn4;?93-$hcIo zYCMX}hayt+w5zotIKG6qNsqzdi5um+c~QrJt8Sh<%&4cF9Y4seFtdjccVoyDgYYC^ z=K^7)jcNsv903h@np=YTFY;nsAovYM%c*Hu=ClG9l56gf8NIFmdTmRA)UFD@97 zA|{jcY0Z%Zs-vv@VU(2SQVT9hI;oke+fqZ_@bxXO+-gNHugrK;=EcxlwfT+bdDrl- z!tnU#|LRE~`A=l%HJvs=f09kft=%sN?Dv)oH3IBGverY+! z)bq5Ly_FAr`jezzJ`>l5)M7q$36xay9_|J2*;4vFtc-^0fVf$Fo~Gb4B>7?Yj#QHV z>DOo9<|PigM_hO7Nfzf!cE@g+d=8#>?1^a!yoi1-U7RHPkBQRDeSZY4<^4zq#VZJ4 z9Zl1-kHk1g!Jy3c0A zXyQE63cZ6As6#6SXr|H%cg(?B5DVxunn<=7CE1JVoR^>+0hd-#eQXY28j6c39->P* zh=3BLfC>(6ZQceNT-0x9G5h@*!v2tGYy5W|-zENBsOPU~5b;jH#gxrT^*zX+c}Q15 zO~ct>2~=4n6GwipQ9E!i2q5G-D-vOO1&ZUYJ?Rg{5GiUv?P|D8D_Fi((@V~<`r8IB zhagjkJbYrhVfo6#HR2=aY9;5J3rgIKF2%#7w?GBQQ>}MVkYh~~`M9hqbhl9RD5_0o z^&K>|z`?~qI&gTO&w4|}CI5_ZR#caf&LB)x$iFZ@9wO3>C!*F1>cbWw{mFc8>Fi%n z+mr`NJZD2~n8GUH@V^g`!6rkc`VeAVfn^age-g(?d*hC|x-cPCebW|!de|J869W3qo*q1-gA^G>7|U za_ZkJ5Ecni|GImR29nG#UV_qlz1_GQVpa5h7_LCbhJ1IzU{p{Rlz5Sb^lwAQ{5W~{Pd$1k5jahYftDLX&b5E@##HjIyP+lf zC+Q=3Nt>NdURukpffrYf*6xp)?0s`E`vJ+1wF_kZ37Cq$!ao$snVZyPRtB{x?jbN)6O6b7eDA=x)6lRIkqT~$qvU+)T$0bTc;zpL74y-6|Xiyq_Clu+o*qd{PH-;DDrF%v(8A6ZkI|) z>_J)~Bkrt>x23i9Hz?^_LQ~Fz=8FWkS7yeW8TiPP7AMGL~%DMKcSVE zUnfC{-pVga9s~(XlC(>wl+F-LVnJa%fee=OS(KNE;zG;&X`R{HtU+(_Ef(&0v0U^x z+$AnzKV`%{Z!0{ofJuXp>NqWpc(F+b5`_az9=&P+6rKB#74#WF_#xGBQMC!T2;Pn> zMYUtasiGT&n84XNcLU=(rLltMNS(obt4bH!AKc_O3`CJ-;$wC1J z-V+eCXL(Q2qK#)%vaRq+OrudND-&Uil>v0}84~?p(??aF9sjeEF=faO53}A>?vSf& zdr@dP0OupC-B3+{Ne=>2Mg|DM&)?7(dnx>O0tAy+^iB?+>l+p26SO7v{Q*tQS1(=S zZ2;6a3kn;jilc% zEz+KxWBcDgAF)P4^eh%kB?_kEmn4CGczAaC6#3Ye1Rj!i8ybAWKooV0EHzUfhPCh5 ze8~9xV^C_MwrOhVDaeKUg?fcK&Z`#Lg-zyic>o!K>+j6(k#47p;I)GMfw8)v0qRo% z9qm>g+DJcg`E-dY1`@hrRHZg=UkLrM2x#oj6PSPoF#{eA%yI=Y5E zj>Vz;p#_Ch3E}pn*!#sTDd3vy3(hDkwPRfIwvb?B{Ck#fum683Iyr!i)35pr+Jo87 zmEBX?9n|mkr?Ur8eK9U29#NE+CBqn_E7Q*jaL3nxVoZ@@{9Q6G!|xe8=qXSzq^B;^ zKJU-5(#k|dm5N>dGJPPTyGoygXWSu9a?28ehvYIQhKGFZk$*v$xCkwvAWS~x#}~fb z2?8#}jk6It-uwuDu#-Qk-2XX;xKq6fURU#pI|MO*3if9<0z52L zfU*Aq{3!_+Ey-#>>d5Umf8X^2cc`IpxyU_Dq5tzyKf8XM0p-Kdq>*LT|28HRk&mHt zLH`O(J=-0K1v7l9U+{R%1`g!@!iHj?|=WB0Qa8XvM1U0t;2KUz(iXClH zWK)5GK(-#OryPU626s}%P8m?_EmX2NS1GY-JxJ8{>z!Gn>$N~G6+(_@$D_)&ZsU31 zDG7g^Cea5V4N)xM{6?uU`i&zA1k#A;NF+a?&v89 z2D)v!min`KNsK#gY4brv_b_CWS@b%ta))4IwW#xa$^RDZ{SPH1Wd%GwPTw>O80I|_ zixJG2VA<0q@ie=K`>X190)jejLK*RydFH^2Cdc=f{y`&3$#p8jpMRIYPGmrN#1@d= zN>19p&&a?T+1_*H%!K(o(c8CnNRNUWiZ{QY01{_Whz%6(H83@@G$?`+DAlmU-S~IP zWt39t5*5&xngV*;kAq%`H9#D*_9*{Iqk|<)B%cAqpwFceC({1g&rEE|M}#9g)QF@` zvnVPcc@gp&g6T<%JI=&P;}`FB`d(tmC*tWOmMr+p7bEmL>M1UH5f(r(N2ur1iK5_CQNo)Ry z7%UQrnuH%%1jTFw7j6E6mH6jWF~3SEIz{1=-VIhvI=~-axK{A73eo8|I6K0wTsF8& z`kQSlkJ(%98`?~KzmL@lys@nr^nWBvZFo)i+hMi+F2%y^=Rs=?6En2bW6ZM}p!)G`7@{6-*jC6K8{8pwC?uHAhME zz8)Bame;l?AnJ*d7j^eVj30)*)i?%>Ifsw-`fyPN)a~2zJSh5f0rLZa@O3OO*L@sU zLgMk*-&_=h6DmhD$d95~B|H>aj#LVo53(*qO62uC=VeGdyQUN*;Q}z#9>&B7)pn?9+9+u`DPAW~) zB1k-2%Z8$5ik8cj!W*Q*XJrgu4*ZpzpR$V-i%r5JkyJ72DKM!KHzgfSzzvi^<4G8C zXZ>|{(v`L})JK1ICVp4Ld>KFV+!TYv9Z9)2R)?+RI-CfTMgU6igp~|0X8e=vHE59e zY0u3(R=3i>HJQPLw>xZ?ZJ?GH<^P&QtAJ^&`5>ma1XL5&DK^ip;0LjPZUDy+CE|HS z0&F=4ZYavVjmo7Ws|vtRyVSluyN?wXy-?x{h}&7vx1@z$V)rXvm!;5~C)u08#972=Z>q5l+02 z7_;!QJ+4#)jETWFZa(8p+uh})4Sm42mL2ke@5P5~K26AjY`|rfezZhy&cWn@Hu=~% zF~o%X>bI?JJ`~UU!zjW-rMH@IvS1>6qeX|Bp#MUXkqWSJ0L@e;{PpvnP!1CeG66{v zx+2t`0*_Cd!+`k>h~&=}|1i*YaqFk^1y(_^Ap+z@7J|$9a4Ly%B&D|ovuI$Wt8~dp zI#|K3NdasDu*CU$qbe4*nX%?B_!&hrv!yOi8w)I(m~1B$kl0Y0RuT@Iys$_rgh@3s z*IL^!I2`}Z>TfvlJqsL%>XF*^`MxG|_FB)e#!JWp45JQiV4(~AZrH_EWI$;Ho=jIJ z1*o%dQ3)qE1F43mOxDONl>%D3R9HelJ}*5G$_U_O6X7=*L3)rMw#Hd$L$Q&n`wH?3 z68JKOOk){97q6W**9rwbjciIdD}-{>hCis-Kjj9i!~$HWEcG!1jj(-u^I#6m$M) z#W%1wzyK!&bN5E=y%%Q|cjv<((jRe^9q zbGu3}qyi~QW;S*o+xV22r#Lpw9@`fJ^&n}8T-Ke)x*6h*){bz+n3D`^ja?*D*p677rgNI3xz2W@37K&I5O9yx5kv1O1^!Gnv zn02Z5M-SbB)iOrU5_ioU+dG7P@WHD)F}C$PcKzJkgl$)XX#dYt-~nGnHwmDDP4Sk+ z8Z%}yW$0w-5Wy^-Ja^Wcf6zI-L}Q0K^C|S*yLZ=s$@M?a0smj$KwjU0mz{TaSn^C; UF*ZCk;13iX+uL$Nkc;* zP;zf(X>4Tx07!|IR|i;A$rhelQb}k51f+!C1Q96#q=e7{NH2nokOT-N#z0W9ql*fz zT|~f!u8JsjK*&GAUd5M4^8p~rd5bCe5;=wy=tyCJ5HFPD3?;6T<1{7Sqp*pO zqERym=_eMve_t|6sz)u)~8E3Y!!cwCsp{B`+6YDPGGC_UuO!rDt%17 zFf%?0Vk*Rz1){`Ah}9u>%FB)mSJ{jz%;ZE4*hpou^7t(wjunbS0+jk#LAo?JR%Kt3 zn>R3DY-6T~8>zzk@-yQ@mHEM1_&i1Ya4y(op_rX8pd-v16EQFsfiN&gId`l}C{Bn| z>3opn2gEAJ;#5gaw8|bg7v_Y>d2@(e^9tfrWAVg%xFD7Kc)BD%I$jwkUYNxTi&n;i zF9Q*P0|EgP$Y4zalRy>_we>Xgz&mA6FyH|x$N)m%sA2|2Bq@5t@EQjS!JiNbLEhlF z07b756hQv#AgjDOf^gGM(VzqB2di?C_G2z^YpCgl2jD(X9V+8`xFK-FU7;T~?gu@)L6)P6 zTb>o>PuhQ*!4FSEWk+$Qpp+pLe~RtTBh~HI-3C?Ux&ARf%2?9|^UWFT`Po0`hwKYr z8~(E;wh-HooyAUK=dmML17Kpuu|}*FJ1WP6tLuqkbq#onRm77GbwvO0H}GWTnap#p zb?$clqU_5TPL;o-0cVnAnpBjLna^ayFM^QC74w~jF95ML%OP;$8ZLV#}x4Y<<`fC;bw zws1Z!z#Vu4e-H%1Kop1v$zTGEJ`=8UDVPFgfVrR;l!9eoHK+g^z-F)=>;n5h12_Us zf-~SExD2j?yKwb%f)}6{yaS&R1R)`Ggn<|!ric~dfVdzm#1{!dB9K@l8A(Ml5HTV{ zW+3yBQe*{Efowu*kiEzuVnP{VE8!sFG~7#_gm*+DQIBXt^niOYnV3bKPFze}N8C+3L2M^JCiam? zBmS^jd>KipRHFGsjwHP&# z+8niVwLNO*)E=sRq-oRaX&l-(nvAxDwvBdzcANHwPN!Sb{pl(6eEJf44gD1T0sVtI zL)}T8t1eKVtzM~KuYN`Sr3OXAN`tL2L8DNkOk=;sC5;|Uil((DN0X;HOS4k*kmgOz zJ}rjU2(2isT&=}gJG9Pgb!n5dt+j)+1={nqH*25Le$2oa77Px9&zQ&9!Z^+7)FJBF z=x}wibV_x0>9pze>T2t{>L%(=)veS$s@tK5=~?S>^>Xx<>h06Jq4#Ns@euY9;gCf` z>V{ky@?L+azQ4XuzeInx{&oFN226t>gKUH41`P)H4RJ$z!x+OUhU*Pa8}xgjdT(uGon*bhy1}~3M&E{OQ)p9bbI+D;>t{RJw%YcZ z9np?umuf zw&xMgk6!Lx`Cj|H{_=M8&i3Bn-8IT)lyKCRQICDheRw{bd^&uYzNx+&eLMWj{CIxV zevkYu{0077{hzVz*jenI?3V$~0n&hj0q;3poSB>xfmmQjU`b$Gkakc)P({##VDsRN z;M(Bc5Vw%&Atyo!q2Zy+LvM18xqNO7w>QiqY-U(XI4wLbydwNjgnfiGqG>cbI&AdH z(f1;)BE^vnQ6P#NwKD2{v`zHn=)*C@nCO_wn5VHLVrRsjjbp@3jN1|SE_Ja-Jg?`vpbiZo0@w_ ztS!zKw@fmbG<8zD#6eOl>6q*_dG+L8X}EN29+EdcuQ6W-{>Zn=Y-PX59v1i(R2F=g zk~n4mRIRDvsjbuOr!AWHtT4Fnx9RwF{`BSfG$PzZW?dttfgsFKJ%WeB=3Z=0E-=~{IHY~&!iWasl8o6lg zqHiUFk_)BIrDdgG7V{Th_;tju<-hhXNng^o)P3ptWyEFTWjB`lE#JOEYenIT$16v# ztY5`kRkG^s>WQn*m${a0SVLVSThmbo)PWj_tAA zTWUONYIYdySXztL%4)lJj@x;8SKzM3I>)+AyY+UL?g4vbd!Fx2-FstS_`c@-to?Nd ztPWHiWE?E1N9w25_cmlUbTlS6wjT;R)ZFCVbnvj#;cZ9Ej#M7iJ-YN5_1OGl{l^QB z_nnZO=sB5j@^SOT=DVlHoVwN$)6#Z2{Pejq!DmjLWuHBM&ga~b^IqoY8T{(F*@amas+-n!FM_zBgk#OVI&2cw7 zZV7I6-OjoF>Q2F(&v)nCBit*wuX(@hfzgBNKkWY4-QnJG^kLw`)<2m2h(jDA=`T5xAoi8LWKKB&=sr6^oOWT(R{__8;wRcQ!=PT)} z{@07&7`)l`*7a?3Uv%Gtce(FAzc2Y<@L~H$*2lA-l0H5AJmm}NOZiv3uT9^=zuoK4 z?eCZHq&&s100t|Xo({n4Z2-`d0bs!M8Fj4k{HG|0^31A0;6GvI8BtN(0jPuj?=d~_ z@8%`|d$z#i7u44n2S79(fOt=UjvP5y2hM7WbEUix;W=Pq)Yr7!H1KbV^33@|+&{NK zuIdVYZvO=l0(@2T=%Kg(001~;SV?A0O#mtY000O80f%V-1ONa40RR918UO$Q0007@ z0ssU6000310007@0{{d700031000620002a(4LS0000w7L_t&t9b=T2m;cWI1WX_T Z1OOBG0bqAaE;0ZB002ovPDHLkV1iKSREhuq diff --git a/packages/playwright-tests/fullstack/src/main.rs b/packages/playwright-tests/fullstack/src/main.rs index f76a3ba61..b36fc8e02 100644 --- a/packages/playwright-tests/fullstack/src/main.rs +++ b/packages/playwright-tests/fullstack/src/main.rs @@ -5,14 +5,13 @@ // - Hydration #![allow(non_snake_case)] -use dioxus::fullstack::{codec::JsonEncoding, commit_initial_chunk, BoxedStream, Websocket}; -use dioxus::prelude::*; -use futures::{channel::mpsc, SinkExt, StreamExt}; +use dioxus::fullstack::{commit_initial_chunk, Websocket}; +use dioxus::{fullstack::WebSocketOptions, prelude::*}; fn main() { dioxus::LaunchBuilder::new() .with_cfg(server_only! { - dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + dioxus::server::ServeConfig::builder().enable_out_of_order_streaming() }) .with_context(1234u32) .launch(app); @@ -23,7 +22,7 @@ fn app() -> Element { let mut text = use_signal(|| "...".to_string()); rsx! { - document::Title { "hello axum! {count}" } + Title { "hello axum! {count}" } h1 { "hello axum! {count}" } button { class: "increment-button", onclick: move |_| count += 1, "Increment" } button { @@ -75,12 +74,14 @@ fn DefaultServerFnCodec() -> Element { #[cfg(feature = "server")] async fn assert_server_context_provided() { - use dioxus::server::{extract, FromContext}; - let FromContext(i): FromContext = extract().await.unwrap(); - assert_eq!(i, 1234u32); + // todo!("replace server context....") + // use dioxus::server::{extract, FromContext}; + // let FromContext(i): FromContext = extract().await.unwrap(); + // assert_eq!(i, 1234u32); } -#[server(PostServerData)] +// #[server(PostServerData)] +#[server] async fn post_server_data(data: String) -> ServerFnResult { assert_server_context_provided().await; println!("Server received: {}", data); @@ -88,7 +89,8 @@ async fn post_server_data(data: String) -> ServerFnResult { Ok(()) } -#[server(GetServerData)] +// #[server(GetServerData)] +#[server] async fn get_server_data() -> ServerFnResult { assert_server_context_provided().await; Ok("Hello from the server!".to_string()) @@ -105,9 +107,10 @@ async fn get_server_data_empty_vec(empty_vec: Vec) -> ServerFnResult ServerFnResult { + use dioxus_core::AnyhowContext; assert_server_context_provided().await; tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await; - Err(ServerFnError::new("the server threw an error!")) + Err(None.context("Server error occurred")?) } #[component] @@ -165,49 +168,50 @@ fn DocumentElements() -> Element { fn Assets() -> Element { #[used] static _ASSET: Asset = asset!("/assets/image.png"); + #[used] - static _OTHER_ASSET: Asset = asset!("/assets/nested"); + static _STATIC_NO_HASH: Asset = asset!( + "/assets/image.png", + AssetOptions::image().with_hash_suffix(false) + ); + + #[used] + static _UNHASHED_FOLDER: Asset = asset!( + "/assets/nested/", + AssetOptions::folder().with_hash_suffix(false) + ); + + #[used] + static _EMBEDDED_FOLDER: Asset = asset!("/assets/nested"); + rsx! { img { src: asset!("/assets/image.png"), } img { - src: "/assets/image.png", + src: "{_EMBEDDED_FOLDER}/image.png", } img { - src: "/assets/nested/image.png", + src: "{_UNHASHED_FOLDER}/image.png", + } + img { + src: "/assets/image.png", } } } -#[server(protocol = Websocket)] -async fn echo_ws( - input: BoxedStream, -) -> ServerFnResult> { - let mut input = input; - - let (mut tx, rx) = mpsc::channel(1); - - tokio::spawn(async move { - while let Some(msg) = input.next().await { - let _ = tx.send(msg.map(|msg| msg.to_ascii_uppercase())).await; - } - }); - - Ok(rx.into()) -} - /// This component tests websocket server functions #[component] fn WebSockets() -> Element { let mut received = use_signal(String::new); + use_future(move || async move { - let (mut tx, rx) = mpsc::channel(1); - let mut receiver = echo_ws(rx.into()).await.unwrap(); - tx.send(Ok("hello world".to_string())).await.unwrap(); - while let Some(Ok(msg)) = receiver.next().await { - println!("Received: {}", msg); - received.set(msg); + let socket = echo_ws(WebSocketOptions::default()).await.unwrap(); + + socket.send("hello world".to_string()).await.unwrap(); + + while let Ok(msg) = socket.recv().await { + received.write().push_str(&msg); } }); @@ -218,3 +222,16 @@ fn WebSockets() -> Element { } } } + +#[get("/api/echo_ws")] +async fn echo_ws(options: WebSocketOptions) -> Result { + info!("Upgrading to websocket"); + + Ok(options.on_upgrade( + |mut tx: dioxus::fullstack::TypedWebsocket| async move { + while let Ok(msg) = tx.recv().await { + let _ = tx.send(msg.to_ascii_uppercase()).await; + } + }, + )) +} diff --git a/packages/playwright-tests/playwright.config.js b/packages/playwright-tests/playwright.config.js index c3151e01d..63aedee0a 100644 --- a/packages/playwright-tests/playwright.config.js +++ b/packages/playwright-tests/playwright.config.js @@ -42,36 +42,6 @@ module.exports = defineConfig({ name: "chromium", use: { ...devices["Desktop Chrome"] }, }, - - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { ...devices['Desktop Edge'], channel: 'msedge' }, - // }, - // { - // name: 'Google Chrome', - // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, - // }, ], /* Run your local dev server before starting the tests */ @@ -87,7 +57,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "web"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 9990', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 9990', port: 9990, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -96,7 +66,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "web-routing"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 2020', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 2020', port: 2020, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -105,7 +75,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "web-hash-routing"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 2021', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 2021', port: 2021, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -114,7 +84,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "fullstack"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 3333', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 3333', port: 3333, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -123,7 +93,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "fullstack-errors"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 3232', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 3232', port: 3232, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -132,7 +102,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "fullstack-mounted"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 7777', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 7777', port: 7777, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -141,7 +111,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "fullstack-routing"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 8888', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 8888', port: 8888, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -150,7 +120,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "suspense-carousel"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 4040', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 4040', port: 4040, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -159,7 +129,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "nested-suspense"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --web --addr "127.0.0.1" --port 5050', + 'cargo run --package dioxus-cli --release -- run --force-sequential --web --addr "127.0.0.1" --port 5050', port: 5050, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -168,7 +138,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "nested-suspense"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --bin nested-suspense-ssg --force-sequential --web --ssg --addr "127.0.0.1" --port 6060', + 'cargo run --package dioxus-cli --release -- run --bin nested-suspense-ssg --force-sequential --web --ssg --addr "127.0.0.1" --port 6060', port: 6060, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -178,7 +148,7 @@ module.exports = defineConfig({ cwd: path.join(process.cwd(), "cli-optimization"), // Remove the cache folder for the cli-optimization build to force a full cache reset command: - 'cargo run --package dioxus-cli --release -- run --verbose --addr "127.0.0.1" --port 8989', + 'cargo run --package dioxus-cli --release -- run --addr "127.0.0.1" --port 8989', port: 8989, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -187,7 +157,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "wasm-split-harness"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --bin wasm-split-harness --web --addr "127.0.0.1" --port 8001 --wasm-split --profile wasm-split-release', + 'cargo run --package dioxus-cli --release -- run --bin wasm-split-harness --web --addr "127.0.0.1" --port 8001 --wasm-split --profile wasm-split-release', port: 8001, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -196,7 +166,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "default-features-disabled"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --addr "127.0.0.1" --port 8002', + 'cargo run --package dioxus-cli --release -- run --force-sequential --addr "127.0.0.1" --port 8002', port: 8002, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, @@ -205,7 +175,7 @@ module.exports = defineConfig({ { cwd: path.join(process.cwd(), "barebones-template"), command: - 'cargo run --package dioxus-cli --release -- run --verbose --force-sequential --addr "127.0.0.1" --port 8123', + 'cargo run --package dioxus-cli --release -- run --force-sequential --addr "127.0.0.1" --port 8123', port: 8123, timeout: 50 * 60 * 1000, reuseExistingServer: !process.env.CI, diff --git a/packages/playwright-tests/suspense-carousel/src/main.rs b/packages/playwright-tests/suspense-carousel/src/main.rs index 17965b9d2..4fe52be79 100644 --- a/packages/playwright-tests/suspense-carousel/src/main.rs +++ b/packages/playwright-tests/suspense-carousel/src/main.rs @@ -111,7 +111,7 @@ fn NestedSuspendedComponent(id: i32) -> Element { fn main() { LaunchBuilder::new() .with_cfg(server_only! { - dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming() + dioxus::server::ServeConfig::builder().enable_out_of_order_streaming() }) .launch(app); } diff --git a/packages/router-macro/src/route_tree.rs b/packages/router-macro/src/route_tree.rs index 166ce5b99..b1c89ae9c 100644 --- a/packages/router-macro/src/route_tree.rs +++ b/packages/router-macro/src/route_tree.rs @@ -172,7 +172,7 @@ impl<'a> ParseRouteTree<'a> { // If there is a dynamic segment, stop adding static segments RouteSegment::Dynamic(..) => break, RouteSegment::CatchAll(..) => { - todo!("Catch all segments are not allowed in nests") + unimplemented!("Catch all segments are not allowed in nests") } } } diff --git a/packages/router/Cargo.toml b/packages/router/Cargo.toml index 4a1ad42ba..cdc4f1c5c 100644 --- a/packages/router/Cargo.toml +++ b/packages/router/Cargo.toml @@ -17,7 +17,7 @@ dioxus-hooks = { workspace = true } dioxus-html = { workspace = true, optional = true } dioxus-history = { workspace = true } dioxus-router-macro = { workspace = true } -dioxus-fullstack-hooks = { workspace = true, optional = true } +dioxus-fullstack-core = { workspace = true, optional = true } tracing = { workspace = true } percent-encoding = { workspace = true } url = { workspace = true } @@ -26,7 +26,7 @@ rustversion = { workspace = true } [features] default = ["html"] -streaming = ["dep:dioxus-fullstack-hooks"] +streaming = ["dep:dioxus-fullstack-core"] wasm-split = [] html = ["dep:dioxus-html"] @@ -47,15 +47,15 @@ cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] # Most of the examples live in the workspace. We include some here so that docs.rs can scrape our examples for better inline docs [[example]] name = "hash_fragment_state" -path = "../../examples/hash_fragment_state.rs" +path = "../../examples/06-routing/hash_fragment_state.rs" doc-scrape-examples = true [[example]] name = "query_segment_search" -path = "../../examples/query_segment_search.rs" +path = "../../examples/06-routing/query_segment_search.rs" doc-scrape-examples = true [[example]] name = "simple_router" -path = "../../examples/simple_router.rs" +path = "../../examples/06-routing/simple_router.rs" doc-scrape-examples = true diff --git a/packages/router/src/components/default_errors.rs b/packages/router/src/components/default_errors.rs index 077c94465..f7c80a482 100644 --- a/packages/router/src/components/default_errors.rs +++ b/packages/router/src/components/default_errors.rs @@ -1,7 +1,6 @@ use dioxus_core::Element; use dioxus_core_macro::rsx; use dioxus_html as dioxus_elements; -use dioxus_signals::{GlobalSignal, ReadableExt}; #[allow(deprecated)] use crate::hooks::use_router; diff --git a/packages/router/src/components/history_buttons.rs b/packages/router/src/components/history_buttons.rs index cc607f032..a7bb2e3cd 100644 --- a/packages/router/src/components/history_buttons.rs +++ b/packages/router/src/components/history_buttons.rs @@ -1,7 +1,6 @@ use dioxus_core::{Element, VNode}; use dioxus_core_macro::{rsx, Props}; use dioxus_html as dioxus_elements; -use dioxus_signals::{GlobalSignal, ReadableExt}; use tracing::error; diff --git a/packages/router/src/components/history_provider.rs b/packages/router/src/components/history_provider.rs index c6e4a4bb5..ccd0764dd 100644 --- a/packages/router/src/components/history_provider.rs +++ b/packages/router/src/components/history_provider.rs @@ -1,7 +1,6 @@ use dioxus_core::{use_hook, Callback, Element}; use dioxus_core_macro::{component, Props}; use dioxus_history::{provide_history_context, History}; -use dioxus_signals::Owner; use std::rc::Rc; diff --git a/packages/router/src/components/link.rs b/packages/router/src/components/link.rs index 80a897219..26a4c6051 100644 --- a/packages/router/src/components/link.rs +++ b/packages/router/src/components/link.rs @@ -7,7 +7,6 @@ use dioxus_core_macro::{rsx, Props}; use dioxus_html::{ self as dioxus_elements, ModifiersInteraction, MountedEvent, MouseEvent, PointerInteraction, }; -use dioxus_signals::{GlobalSignal, Owner, ReadableExt}; use tracing::error; diff --git a/packages/router/src/components/router.rs b/packages/router/src/components/router.rs index 00583372b..18ccbd3d4 100644 --- a/packages/router/src/components/router.rs +++ b/packages/router/src/components/router.rs @@ -1,7 +1,6 @@ use crate::{provide_router_context, routable::Routable, router_cfg::RouterConfig, Outlet}; use dioxus_core::{provide_context, use_hook, Callback, Element}; use dioxus_core_macro::{rsx, Props}; -use dioxus_signals::{GlobalSignal, Owner, ReadableExt}; /// The props for [`Router`]. #[derive(Props)] @@ -43,7 +42,7 @@ pub fn Router(props: RouterProps) -> Element { #[cfg(feature = "streaming")] dioxus_hooks::use_after_suspense_resolved(|| { - dioxus_fullstack_hooks::commit_initial_chunk(); + dioxus_fullstack_core::commit_initial_chunk(); }); use_hook(|| { diff --git a/packages/router/src/routable.rs b/packages/router/src/routable.rs index 1abf7d463..a27797519 100644 --- a/packages/router/src/routable.rs +++ b/packages/router/src/routable.rs @@ -953,3 +953,13 @@ impl Display for SegmentType { } } } + +// /// Routable is implemented for String to allow stringly-typed apps. +// impl Routable for String { +// const SITE_MAP: &'static [SiteMapSegment] = &[]; + +// #[doc = " Render the route at the given level"] +// fn render(&self, _level: usize) -> Element { +// unimplemented!("String routes cannot be rendered as components") +// } +// } diff --git a/packages/rsx/src/expr_node.rs b/packages/rsx/src/expr_node.rs index 8c08df146..71b261198 100644 --- a/packages/rsx/src/expr_node.rs +++ b/packages/rsx/src/expr_node.rs @@ -16,6 +16,23 @@ impl ExprNode { impl Parse for ExprNode { fn parse(input: syn::parse::ParseStream) -> syn::Result { + // // If it's a single-line expression, we want to parse it without braces, fixing some issues with rust 2024 lifetimes + // use syn::braced; + // let forked = input.fork(); + // if forked.peek(syn::token::Brace) { + // let content; + // let _brace = braced!(content in forked); + // let as_expr: Result = content.parse(); + // if as_expr.is_ok() && content.is_empty() { + // let content; + // let _brace = braced!(content in input); + // return Ok(Self { + // expr: content.parse()?, + // dyn_idx: DynIdx::default(), + // }); + // } + // } + Ok(Self { expr: input.parse()?, dyn_idx: DynIdx::default(), diff --git a/packages/rsx/src/template_body.rs b/packages/rsx/src/template_body.rs index 64504c899..645cd2e57 100644 --- a/packages/rsx/src/template_body.rs +++ b/packages/rsx/src/template_body.rs @@ -149,6 +149,8 @@ impl ToTokens for TemplateBody { } #[cfg(debug_assertions)] let __template_read = { + use dioxus_signals::ReadableExt; + static __NORMALIZED_FILE: &'static str = { const PATH: &str = dioxus_core::const_format::str_replace!(file!(), "\\\\", "/"); dioxus_core::const_format::str_replace!(PATH, '\\', "/") @@ -156,7 +158,7 @@ impl ToTokens for TemplateBody { // The key is important here - we're creating a new GlobalSignal each call to this // But the key is what's keeping it stable - static __TEMPLATE: GlobalSignal> = GlobalSignal::with_location( + static __TEMPLATE: dioxus_signals::GlobalSignal> = dioxus_signals::GlobalSignal::with_location( || None::, __NORMALIZED_FILE, line!(), diff --git a/packages/server-macro/src/lib.rs b/packages/server-macro/src/lib.rs deleted file mode 100644 index 8b910405c..000000000 --- a/packages/server-macro/src/lib.rs +++ /dev/null @@ -1,236 +0,0 @@ -// TODO: Create README, uncomment this: #![doc = include_str!("../README.md")] -#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/79236386")] -#![doc(html_favicon_url = "https://avatars.githubusercontent.com/u/79236386")] - -//! This crate contains the dioxus implementation of the #[macro@crate::server] macro without additional context from the server. -//! See the [server_fn_macro] crate for more information. - -use proc_macro::TokenStream; -use quote::ToTokens; -use server_fn_macro::ServerFnCall; -use syn::parse_quote; - -/// Declares that a function is a [server function](https://docs.rs/server_fn/). -/// This means that its body will only run on the server, i.e., when the `ssr` -/// feature is enabled on this crate. -/// -/// ## Usage -/// ```rust,ignore -/// # use dioxus::prelude::*; -/// # #[derive(serde::Deserialize, serde::Serialize)] -/// # pub struct BlogPost; -/// # async fn load_posts(category: &str) -> ServerFnResult> { unimplemented!() } -/// #[server] -/// pub async fn blog_posts( -/// category: String, -/// ) -> ServerFnResult> { -/// let posts = load_posts(&category).await?; -/// // maybe do some other work -/// Ok(posts) -/// } -/// ``` -/// -/// ## Named Arguments -/// -/// You can use any combination of the following named arguments: -/// - `name`: sets the identifier for the server function’s type, which is a struct created -/// to hold the arguments (defaults to the function identifier in PascalCase). -/// Example: `name = MyServerFunction`. -/// - `prefix`: a prefix at which the server function handler will be mounted (defaults to `/api`). -/// Example: `prefix = "/my_api"`. -/// - `endpoint`: specifies the exact path at which the server function handler will be mounted, -/// relative to the prefix (defaults to the function name followed by unique hash). -/// Example: `endpoint = "my_fn"`. -/// - `input`: the encoding for the arguments (defaults to `PostUrl`). -/// - The `input` argument specifies how the function arguments are encoded for transmission. -/// - Acceptable values include: -/// - `PostUrl`: A `POST` request with URL-encoded arguments, suitable for form-like submissions. -/// - `Json`: A `POST` request where the arguments are encoded as JSON. This is a common choice for modern APIs. -/// - `Cbor`: A `POST` request with CBOR-encoded arguments, useful for binary data transmission with compact encoding. -/// - `GetUrl`: A `GET` request with URL-encoded arguments, suitable for simple queries or when data fits in the URL. -/// - `GetCbor`: A `GET` request with CBOR-encoded arguments, useful for query-style APIs when the payload is binary. -/// - `output`: the encoding for the response (defaults to `Json`). -/// - The `output` argument specifies how the server should encode the response data. -/// - Acceptable values include: -/// - `Json`: A response encoded as JSON (default). This is ideal for most web applications. -/// - `Cbor`: A response encoded in the CBOR format for efficient, binary-encoded data. -/// - `client`: a custom `Client` implementation that will be used for this server function. This allows -/// customization of the client-side behavior if needed. -/// - `encoding`: (legacy, may be deprecated in future) specifies the encoding, which may be one -/// of the following (not case sensitive): -/// - `"Url"`: `POST` request with URL-encoded arguments and JSON response -/// - `"GetUrl"`: `GET` request with URL-encoded arguments and JSON response -/// - `"Cbor"`: `POST` request with CBOR-encoded arguments and response -/// - `"GetCbor"`: `GET` request with URL-encoded arguments and CBOR response -/// - `req` and `res`: specify the HTTP request and response types to be used on the server. These -/// are typically necessary if you are integrating with a custom server framework (other than Actix/Axum). -/// Example: `req = SomeRequestType`, `res = SomeResponseType`. -/// -/// ## Advanced Usage of `input` and `output` Fields -/// -/// The `input` and `output` fields allow you to customize how arguments and responses are encoded and decoded. -/// These fields impose specific trait bounds on the types you use. Here are detailed examples for different scenarios: -/// -/// ### `output = StreamingJson` -/// -/// Setting the `output` type to `StreamingJson` requires the return type to implement `From>`, -/// where `T` implements `serde::Serialize` and `serde::de::DeserializeOwned`. -/// -/// ```rust,ignore -/// #[server(output = StreamingJson)] -/// pub async fn json_stream_fn() -> Result, ServerFnError> { -/// todo!() -/// } -/// ``` -/// -/// ### `output = StreamingText` -/// -/// Setting the `output` type to `StreamingText` requires the return type to implement `From`. -/// -/// ```rust,ignore -/// #[server(output = StreamingText)] -/// pub async fn text_stream_fn() -> Result { -/// todo!() -/// } -/// ``` -/// -/// ### `output = PostUrl` -/// -/// Setting the `output` type to `PostUrl` requires the return type to implement `Serialize` and `Deserialize`. -/// Note that this uses `serde_qs`, which imposes the following constraints: -/// - The structure must be less than 5 levels deep. -/// - The structure must not contain any `serde(flatten)` attributes. -/// -/// ```rust,ignore -/// #[server(output = PostUrl)] -/// pub async fn form_fn() -> Result { -/// todo!() -/// } -/// ``` -/// -/// These examples illustrate how the `output` type impacts the bounds and expectations for your server function. Ensure your return types comply with these requirements. -/// -/// -/// ```rust,ignore -/// #[server( -/// name = SomeStructName, -/// prefix = "/my_api", -/// endpoint = "my_fn", -/// input = Cbor, -/// output = Json -/// )] -/// pub async fn my_wacky_server_fn(input: Vec) -> ServerFnResult { -/// unimplemented!() -/// } -/// -/// // expands to -/// #[derive(Deserialize, Serialize)] -/// struct SomeStructName { -/// input: Vec -/// } -/// -/// impl ServerFn for SomeStructName { -/// const PATH: &'static str = "/my_api/my_fn"; -/// -/// // etc. -/// } -/// ``` -/// -/// ## Adding layers to server functions -/// -/// Layers allow you to transform the request and response of a server function. You can use layers -/// to add authentication, logging, or other functionality to your server functions. Server functions integrate -/// with the tower ecosystem, so you can use any layer that is compatible with tower. -/// -/// Common layers include: -/// - [`tower_http::trace::TraceLayer`](https://docs.rs/tower-http/latest/tower_http/trace/struct.TraceLayer.html) for tracing requests and responses -/// - [`tower_http::compression::CompressionLayer`](https://docs.rs/tower-http/latest/tower_http/compression/struct.CompressionLayer.html) for compressing large responses -/// - [`tower_http::cors::CorsLayer`](https://docs.rs/tower-http/latest/tower_http/cors/struct.CorsLayer.html) for adding CORS headers to responses -/// - [`tower_http::timeout::TimeoutLayer`](https://docs.rs/tower-http/latest/tower_http/timeout/struct.TimeoutLayer.html) for adding timeouts to requests -/// - [`tower_sessions::service::SessionManagerLayer`](https://docs.rs/tower-sessions/0.13.0/tower_sessions/service/struct.SessionManagerLayer.html) for adding session management to requests -/// -/// You can add a tower [`Layer`](https://docs.rs/tower/latest/tower/trait.Layer.html) to your server function with the middleware attribute: -/// -/// ```rust,ignore -/// # use dioxus::prelude::*; -/// #[server] -/// // The TraceLayer will log all requests to the console -/// #[middleware(tower_http::timeout::TimeoutLayer::new(std::time::Duration::from_secs(5)))] -/// pub async fn my_wacky_server_fn(input: Vec) -> ServerFnResult { -/// unimplemented!() -/// } -/// ``` -/// -/// ## Extracting additional data from requests -/// -/// Server functions automatically handle serialization and deserialization of arguments and responses. -/// However, you may want to extract additional data from the request, such as the user's session or -/// authentication information. You can do this with the `extract` function. This function returns any -/// type that implements the [`FromRequestParts`](https://docs.rs/axum/latest/axum/extract/trait.FromRequestParts.html) -/// trait: -/// -/// ```rust,ignore -/// # use dioxus::prelude::*; -/// #[server] -/// pub async fn my_wacky_server_fn(input: Vec) -> ServerFnResult { -/// let headers: axum::http::header::HeaderMap = extract().await?; -/// Ok(format!("The server got a request with headers: {:?}", headers)) -/// } -/// ``` -/// -/// ## Sharing data with server functions -/// -/// You may need to share context with your server functions like a database pool. Server -/// functions can access any context provided through the launch builder. You can access -/// this context with the `FromContext` extractor: -/// -/// ```rust,ignore -/// # use dioxus::prelude::*; -/// # fn app() -> Element { unimplemented!() } -/// #[derive(Clone, Copy, Debug)] -/// struct DatabasePool; -/// -/// fn main() { -/// LaunchBuilder::new() -/// .with_context(server_only! { -/// DatabasePool -/// }) -/// .launch(app); -/// } -/// -/// #[server] -/// pub async fn my_wacky_server_fn(input: Vec) -> ServerFnResult { -/// let FromContext(pool): FromContext = extract().await?; -/// Ok(format!("The server read {:?} from the shared context", pool)) -/// } -/// ``` -#[proc_macro_attribute] -pub fn server(args: proc_macro::TokenStream, body: TokenStream) -> TokenStream { - // If there is no input codec, use json as the default - #[allow(unused_mut)] - let mut parsed = match ServerFnCall::parse("/api", args.into(), body.into()) { - Ok(parsed) => parsed, - Err(e) => return e.to_compile_error().into(), - }; - - #[cfg(not(any(feature = "browser", feature = "reqwest")))] - { - let client = &mut parsed.get_args_mut().client; - // If no client is enabled, use the mock client - if client.is_none() { - *client = Some(parse_quote!( - dioxus::fullstack::mock_client::MockServerFnClient - )); - } - } - - parsed - .default_protocol(Some( - parse_quote!(server_fn::Http), - )) - .default_input_encoding(Some(parse_quote!(server_fn::codec::Json))) - .default_output_encoding(Some(parse_quote!(server_fn::codec::Json))) - .default_server_fn_path(Some(parse_quote!(server_fn))) - .to_token_stream() - .into() -} diff --git a/packages/server/Cargo.toml b/packages/server/Cargo.toml deleted file mode 100644 index f158f254e..000000000 --- a/packages/server/Cargo.toml +++ /dev/null @@ -1,114 +0,0 @@ -[package] -name = "dioxus-server" -authors = ["Jonathan Kelley", "Evan Almloff"] -version = { workspace = true } -edition = "2021" -description = "Fullstack utilities for Dioxus: Build fullstack web, desktop, and mobile apps with a single codebase." -license = "MIT OR Apache-2.0" -repository = "https://github.com/DioxusLabs/dioxus/" -homepage = "https://dioxuslabs.com" -keywords = ["web", "desktop", "mobile", "gui", "server"] -resolver = "2" - -[dependencies] -# dioxus -dioxus-core = { workspace = true } -dioxus-core-macro = { workspace = true } -dioxus-signals = { workspace = true } -dioxus-document = { workspace = true } -dioxus-html = { workspace = true } -generational-box = { workspace = true } - -# server functions -server_fn = { workspace = true, default-features = false } - -# axum + native deps -axum = { workspace = true, default-features = false } -tower-http = { workspace = true, features = ["fs"], optional = true } -tokio-util = { workspace = true, features = ["rt"], optional = true } -tokio-stream = { workspace = true, features = ["sync"], optional = true } -tower = { workspace = true, features = ["util"], optional = true} -tower-layer = { version = "0.3.3", optional = true} -hyper-util = { workspace = true, features = ["full"], optional = true } -hyper = { workspace = true, optional = true } -http = { workspace = true } - -# Dioxus + SSR -dioxus-ssr = { workspace = true } -dioxus-isrg = { workspace = true } -dioxus-router = { workspace = true, features = ["streaming"] } -dioxus-fullstack-hooks = { workspace = true } -dioxus-fullstack-protocol = { workspace = true } -dioxus-interpreter-js = { workspace = true, optional = true } - -tracing = { workspace = true } -tracing-futures = { workspace = true } -async-trait = { workspace = true } -serde = { workspace = true } -enumset = "1.1.6" - -futures-util = { workspace = true } -futures-channel = { workspace = true } -ciborium = { workspace = true } -base64 = { workspace = true } -rustls = { workspace = true, optional = true } -hyper-rustls = { workspace = true, optional = true } - -pin-project = { version = "1.1.10" } -thiserror = { workspace = true } -bytes = "1.10.1" -parking_lot = { workspace = true, features = ["send_guard"] } -web-sys = { version = "0.3.77", features = [ - "Window", - "Document", - "Element", - "HtmlDocument", - "Storage", - "console", -] } - -dioxus-cli-config = { workspace = true } -dioxus-devtools = { workspace = true, optional = true } -aws-lc-rs = { version = "1.13.1", optional = true } -dioxus-history = { workspace = true } -subsecond.workspace = true -inventory = { workspace = true } -dashmap = "6.1.0" - -[target.'cfg(target_arch = "wasm32")'.dependencies] -tokio = { workspace = true, features = ["rt", "sync", "macros"] } - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -tokio = { workspace = true, features = ["rt", "sync", "rt-multi-thread", "macros", "net"] } - -[dev-dependencies] -dioxus = { workspace = true, features = ["fullstack"] } - -[features] -default = ["devtools", "full"] -full = [ - "core", - "server_fn/ssr", - "dep:tower-http", - "default-tls", - "dep:tower", - "dep:hyper", - "dep:tower-layer", - "dep:tokio-util", - "dep:hyper-util", - "axum/default", -] -core = [ - "server_fn/axum-no-default", - "server_fn/ssr", - "document", -] -devtools = ["dep:dioxus-devtools"] -document = ["dep:dioxus-interpreter-js"] -default-tls = ["server_fn/default-tls"] -rustls = ["server_fn/rustls", "dep:rustls", "dep:hyper-rustls"] -aws-lc-rs = ["dep:aws-lc-rs"] - -[package.metadata.docs.rs] -cargo-args = ["-Zunstable-options", "-Zrustdoc-scrape-examples"] -features = ["axum", "web", "aws-lc-rs"] diff --git a/packages/server/docs/request_origin.md b/packages/server/docs/request_origin.md deleted file mode 100644 index 5a28b834d..000000000 --- a/packages/server/docs/request_origin.md +++ /dev/null @@ -1,50 +0,0 @@ -This method interacts with information from the current request. The request may come from: - -1. The initial SSR render if this method called from a [`Component`](dioxus_core_macro::component) or a [`server`](crate) function that is called during the initial render - -```rust -# use dioxus::prelude::*; -use dioxus_server::server_context; -#[component] -fn PrintHtmlRequestInfo() -> Element { - // The server context only exists on the server, so we need to put it behind a server_only! config - server_only! { - // Since we are calling this from a component, the server context that is returned will be from - // the html request for ssr rendering - let context = server_context(); - let request_parts = context.request_parts(); - println!("headers are {:?}", request_parts.headers); - } - rsx! {} -} -``` - -2. A request to a [`server`](crate) function called directly from the client (either on desktop/mobile or on the web frontend after the initial render) - -```rust -# use dioxus::prelude::*; -use dioxus_server::server_context; -#[server] -async fn read_headers() -> ServerFnResult { - // Since we are calling this from a server function, the server context that is may be from the - // initial request or a request from the client - let context = server_context(); - let request_parts = context.request_parts(); - println!("headers are {:?}", request_parts.headers); - Ok(()) -} - -#[component] -fn CallServerFunction() -> Element { - rsx! { - button { - // If you click the button, the server function will be called and the server context will be - // from the client request - onclick: move |_| async { - _ = read_headers().await - }, - "Call server function" - } - } -} -``` diff --git a/packages/server/src/context.rs b/packages/server/src/context.rs deleted file mode 100644 index 89ad93d23..000000000 --- a/packages/server/src/context.rs +++ /dev/null @@ -1,576 +0,0 @@ -use enumset::{EnumSet, EnumSetType}; -use parking_lot::RwLock; -use std::any::Any; -use std::collections::HashMap; -use std::sync::atomic::AtomicU32; -use std::sync::Arc; - -type SendSyncAnyMap = std::collections::HashMap; - -#[derive(EnumSetType)] -enum ResponsePartsModified { - Version, - Headers, - Status, - Extensions, - Body, -} - -struct AtomicResponsePartsModified { - modified: AtomicU32, -} - -impl AtomicResponsePartsModified { - fn new() -> Self { - Self { - modified: AtomicU32::new(EnumSet::::empty().as_u32()), - } - } - - fn set(&self, part: ResponsePartsModified) { - let modified = - EnumSet::from_u32(self.modified.load(std::sync::atomic::Ordering::Relaxed)) | part; - self.modified - .store(modified.as_u32(), std::sync::atomic::Ordering::Relaxed); - } - - fn is_modified(&self, part: ResponsePartsModified) -> bool { - self.modified.load(std::sync::atomic::Ordering::Relaxed) & (1 << part as usize) != 0 - } -} - -/// A shared context for server functions that contains information about the request and middleware state. -/// -/// You should not construct this directly inside components or server functions. Instead use [`server_context()`] to get the server context from the current request. -/// -/// # Example -/// -/// ```rust, no_run -/// # use dioxus::prelude::*; -/// use dioxus_server::server_context; -/// #[server] -/// async fn read_headers() -> ServerFnResult { -/// let server_context = server_context(); -/// let headers: http::HeaderMap = server_context.extract().await?; -/// println!("{:?}", headers); -/// Ok(()) -/// } -/// ``` -#[derive(Clone)] -pub struct DioxusServerContext { - shared_context: Arc>, - response_parts_modified: Arc, - response_parts: Arc>, - pub(crate) parts: Arc>, - response_sent: Arc, -} - -enum ContextType { - Factory(Box Box + Send + Sync>), - Value(Box), -} - -impl ContextType { - fn downcast(&self) -> Option { - match self { - ContextType::Value(value) => value.downcast_ref::().cloned(), - ContextType::Factory(factory) => factory().downcast::().ok().map(|v| *v), - } - } -} - -#[allow(clippy::derivable_impls)] -impl Default for DioxusServerContext { - fn default() -> Self { - Self { - shared_context: Arc::new(RwLock::new(HashMap::new())), - response_parts_modified: Arc::new(AtomicResponsePartsModified::new()), - response_parts: Arc::new(RwLock::new( - http::response::Response::new(()).into_parts().0, - )), - parts: Arc::new(RwLock::new(http::request::Request::new(()).into_parts().0)), - response_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } - } -} - -mod server_fn_impl { - use super::*; - use parking_lot::{MappedRwLockWriteGuard, RwLockReadGuard, RwLockWriteGuard}; - use std::any::{Any, TypeId}; - - impl DioxusServerContext { - /// Create a new server context from a request - pub fn new(parts: http::request::Parts) -> Self { - Self { - parts: Arc::new(RwLock::new(parts)), - shared_context: Arc::new(RwLock::new(SendSyncAnyMap::new())), - response_parts_modified: Arc::new(AtomicResponsePartsModified::new()), - response_parts: std::sync::Arc::new(RwLock::new( - http::response::Response::new(()).into_parts().0, - )), - response_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } - } - - /// Create a server context from a shared parts - #[allow(unused)] - pub(crate) fn from_shared_parts(parts: Arc>) -> Self { - Self { - parts, - shared_context: Arc::new(RwLock::new(SendSyncAnyMap::new())), - response_parts_modified: Arc::new(AtomicResponsePartsModified::new()), - response_parts: std::sync::Arc::new(RwLock::new( - http::response::Response::new(()).into_parts().0, - )), - response_sent: Arc::new(std::sync::atomic::AtomicBool::new(false)), - } - } - - /// Clone a value from the shared server context. If you are using [`DioxusRouterExt`](crate::DioxusRouterExt), any values you insert into - /// the launch context will also be available in the server context. - /// - /// Example: - /// ```rust, no_run - /// use dioxus::prelude::*; - /// - /// LaunchBuilder::new() - /// // You can provide context to your whole app (including server functions) with the `with_context` method on the launch builder - /// .with_context(server_only! { - /// 1234567890u32 - /// }) - /// .launch(app); - /// - /// #[server] - /// async fn read_context() -> ServerFnResult { - /// // You can extract values from the server context with the `extract` function - /// let FromContext(value) = extract().await?; - /// Ok(value) - /// } - /// - /// fn app() -> Element { - /// let future = use_resource(read_context); - /// rsx! { - /// h1 { "{future:?}" } - /// } - /// } - /// ``` - pub fn get(&self) -> Option { - self.shared_context - .read() - .get(&TypeId::of::()) - .map(|v| v.downcast::().unwrap()) - } - - /// Insert a value into the shared server context - pub fn insert(&self, value: T) { - self.insert_any(Box::new(value)); - } - - /// Insert a boxed `Any` value into the shared server context - pub fn insert_any(&self, value: Box) { - self.shared_context - .write() - .insert((*value).type_id(), ContextType::Value(value)); - } - - /// Insert a factory that creates a non-sync value for the shared server context - pub fn insert_factory(&self, value: F) - where - F: Fn() -> T + Send + Sync + 'static, - T: 'static, - { - self.shared_context.write().insert( - TypeId::of::(), - ContextType::Factory(Box::new(move || Box::new(value()))), - ); - } - - /// Insert a boxed factory that creates a non-sync value for the shared server context - pub fn insert_boxed_factory(&self, value: Box Box + Send + Sync>) { - self.shared_context - .write() - .insert((*value()).type_id(), ContextType::Factory(value)); - } - - /// Get the response parts from the server context - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn set_headers() -> ServerFnResult { - /// let server_context = server_context(); - /// let response_parts = server_context.response_parts(); - /// let cookies = response_parts - /// .headers - /// .get("Cookie") - /// .ok_or_else(|| ServerFnError::new("failed to find Cookie header in the response"))?; - /// println!("{:?}", cookies); - /// Ok(()) - /// } - /// ``` - pub fn response_parts(&self) -> RwLockReadGuard<'_, http::response::Parts> { - self.response_parts.read() - } - - /// Get the headers from the server context mutably - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn set_headers() -> ServerFnResult { - /// let server_context = server_context(); - /// server_context.headers_mut() - /// .insert("Cookie", http::HeaderValue::from_static("dioxus=fullstack")); - /// Ok(()) - /// } - /// ``` - pub fn headers_mut(&self) -> MappedRwLockWriteGuard<'_, http::HeaderMap> { - self.response_parts_modified - .set(ResponsePartsModified::Headers); - RwLockWriteGuard::map(self.response_parts_mut(), |parts| &mut parts.headers) - } - - /// Get the status from the server context mutably - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn set_status() -> ServerFnResult { - /// let server_context = server_context(); - /// *server_context.status_mut() = http::StatusCode::INTERNAL_SERVER_ERROR; - /// Ok(()) - /// } - /// ``` - pub fn status_mut(&self) -> MappedRwLockWriteGuard<'_, http::StatusCode> { - self.response_parts_modified - .set(ResponsePartsModified::Status); - RwLockWriteGuard::map(self.response_parts_mut(), |parts| &mut parts.status) - } - - /// Get the version from the server context mutably - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn set_version() -> ServerFnResult { - /// let server_context = server_context(); - /// *server_context.version_mut() = http::Version::HTTP_2; - /// Ok(()) - /// } - /// ``` - pub fn version_mut(&self) -> MappedRwLockWriteGuard<'_, http::Version> { - self.response_parts_modified - .set(ResponsePartsModified::Version); - RwLockWriteGuard::map(self.response_parts_mut(), |parts| &mut parts.version) - } - - /// Get the extensions from the server context mutably - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn set_version() -> ServerFnResult { - /// let server_context = server_context(); - /// *server_context.version_mut() = http::Version::HTTP_2; - /// Ok(()) - /// } - /// ``` - pub fn extensions_mut(&self) -> MappedRwLockWriteGuard<'_, http::Extensions> { - self.response_parts_modified - .set(ResponsePartsModified::Extensions); - RwLockWriteGuard::map(self.response_parts_mut(), |parts| &mut parts.extensions) - } - - /// Get the response parts mutably. This does not track what parts have been written to so it should not be exposed publicly. - fn response_parts_mut(&self) -> RwLockWriteGuard<'_, http::response::Parts> { - if self - .response_sent - .load(std::sync::atomic::Ordering::Relaxed) - { - tracing::error!("Attempted to modify the request after the first frame of the response has already been sent. \ - You can read the response, but modifying the response will not change the response that the client has already received. \ - Try modifying the response before the suspense boundary above the router is resolved."); - } - self.response_parts.write() - } - - /// Get the request parts - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn read_headers() -> ServerFnResult { - /// let server_context = server_context(); - /// let request_parts = server_context.request_parts(); - /// let id: &i32 = request_parts - /// .extensions - /// .get() - /// .ok_or_else(|| ServerFnError::new("failed to find i32 extension in the request"))?; - /// println!("{:?}", id); - /// Ok(()) - /// } - /// ``` - pub fn request_parts(&self) -> parking_lot::RwLockReadGuard<'_, http::request::Parts> { - self.parts.read() - } - - /// Get the request parts mutably - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn read_headers() -> ServerFnResult { - /// let server_context = server_context(); - /// let id: i32 = server_context.request_parts_mut() - /// .extensions - /// .remove() - /// .ok_or_else(|| ServerFnError::new("failed to find i32 extension in the request"))?; - /// println!("{:?}", id); - /// Ok(()) - /// } - /// ``` - pub fn request_parts_mut(&self) -> parking_lot::RwLockWriteGuard<'_, http::request::Parts> { - self.parts.write() - } - - /// Extract part of the request. - /// - #[doc = include_str!("../docs/request_origin.md")] - /// - /// # Example - /// - /// ```rust, no_run - /// # use dioxus::prelude::*; - /// use dioxus_server::server_context; - /// #[server] - /// async fn read_headers() -> ServerFnResult { - /// let server_context = server_context(); - /// let headers: http::HeaderMap = server_context.extract().await?; - /// println!("{:?}", headers); - /// Ok(()) - /// } - /// ``` - pub async fn extract>(&self) -> Result { - T::from_request(self).await - } - - /// Copy the response parts to a response and mark this server context as sent - pub(crate) fn send_response(&self, response: &mut http::response::Response) { - self.response_sent - .store(true, std::sync::atomic::Ordering::Relaxed); - let parts = self.response_parts.read(); - - if self - .response_parts_modified - .is_modified(ResponsePartsModified::Headers) - { - let mut_headers = response.headers_mut(); - for (key, value) in parts.headers.iter() { - mut_headers.insert(key, value.clone()); - } - } - if self - .response_parts_modified - .is_modified(ResponsePartsModified::Status) - { - *response.status_mut() = parts.status; - } - if self - .response_parts_modified - .is_modified(ResponsePartsModified::Version) - { - *response.version_mut() = parts.version; - } - if self - .response_parts_modified - .is_modified(ResponsePartsModified::Extensions) - { - response.extensions_mut().extend(parts.extensions.clone()); - } - } - } -} - -#[test] -fn server_context_as_any_map() { - let parts = http::Request::new(()).into_parts().0; - let server_context = DioxusServerContext::new(parts); - server_context.insert_boxed_factory(Box::new(|| Box::new(1234u32))); - assert_eq!(server_context.get::().unwrap(), 1234u32); -} - -std::thread_local! { - pub(crate) static SERVER_CONTEXT: std::cell::RefCell> = Default::default(); -} - -/// Get information about the current server request. -/// -/// This function will only provide the current server context if it is called from a server function or on the server rendering a request. -pub fn server_context() -> DioxusServerContext { - SERVER_CONTEXT.with(|ctx| *ctx.borrow().clone()) -} - -/// Extract some part from the current server request. -/// -/// This function will only provide the current server context if it is called from a server function or on the server rendering a request. -pub async fn extract, I>() -> Result { - E::from_request(&server_context()).await -} - -/// Run a function inside of the server context. -pub fn with_server_context(context: DioxusServerContext, f: impl FnOnce() -> O) -> O { - // before polling the future, we need to set the context - let prev_context = SERVER_CONTEXT.with(|ctx| ctx.replace(Box::new(context))); - // poll the future, which may call server_context() - let result = f(); - // after polling the future, we need to restore the context - SERVER_CONTEXT.with(|ctx| ctx.replace(prev_context)); - result -} - -/// A future that provides the server context to the inner future -#[pin_project::pin_project] -pub struct ProvideServerContext { - context: DioxusServerContext, - #[pin] - f: F, -} - -impl ProvideServerContext { - /// Create a new future that provides the server context to the inner future - pub fn new(f: F, context: DioxusServerContext) -> Self { - Self { f, context } - } -} - -impl std::future::Future for ProvideServerContext { - type Output = F::Output; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - let this = self.project(); - let context = this.context.clone(); - with_server_context(context, || this.f.poll(cx)) - } -} - -/// A trait for extracting types from the server context -#[async_trait::async_trait] -pub trait FromServerContext: Sized { - /// The error type returned when extraction fails. This type must implement `std::error::Error`. - type Rejection; - - /// Extract this type from the server context. - async fn from_request(req: &DioxusServerContext) -> Result; -} - -/// A type was not found in the server context -pub struct NotFoundInServerContext(std::marker::PhantomData); - -impl std::fmt::Debug for NotFoundInServerContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let type_name = std::any::type_name::(); - write!(f, "`{type_name}` not found in server context") - } -} - -impl std::fmt::Display for NotFoundInServerContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let type_name = std::any::type_name::(); - write!(f, "`{type_name}` not found in server context") - } -} - -impl std::error::Error for NotFoundInServerContext {} - -/// Extract a value from the server context provided through the launch builder context or [`DioxusServerContext::insert`] -/// -/// Example: -/// ```rust, no_run -/// use dioxus::prelude::*; -/// -/// dioxus::LaunchBuilder::new() -/// // You can provide context to your whole app (including server functions) with the `with_context` method on the launch builder -/// .with_context(server_only! { -/// 1234567890u32 -/// }) -/// .launch(app); -/// -/// #[server] -/// async fn read_context() -> ServerFnResult { -/// // You can extract values from the server context with the `extract` function -/// let FromContext(value) = extract().await?; -/// Ok(value) -/// } -/// -/// fn app() -> Element { -/// let future = use_resource(read_context); -/// rsx! { -/// h1 { "{future:?}" } -/// } -/// } -/// ``` -pub struct FromContext(pub T); - -#[async_trait::async_trait] -impl FromServerContext for FromContext { - type Rejection = NotFoundInServerContext; - - async fn from_request(req: &DioxusServerContext) -> Result { - Ok(Self(req.get::().ok_or({ - NotFoundInServerContext::(std::marker::PhantomData::) - })?)) - } -} - -/// An adapter for axum extractors for the server context -pub struct Axum; - -#[async_trait::async_trait] -impl> FromServerContext for I { - type Rejection = I::Rejection; - - #[allow(clippy::all)] - async fn from_request(req: &DioxusServerContext) -> Result { - let mut lock = req.request_parts_mut(); - I::from_request_parts(&mut lock, &()).await - } -} diff --git a/packages/server/src/lib.rs b/packages/server/src/lib.rs deleted file mode 100644 index d7e6df3f4..000000000 --- a/packages/server/src/lib.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Dioxus utilities for the [Axum](https://docs.rs/axum/latest/axum/index.html) server framework. -//! -//! # Example -//! ```rust, no_run -//! #![allow(non_snake_case)] -//! use dioxus::prelude::*; -//! -//! fn main() { -//! #[cfg(feature = "web")] -//! // Hydrate the application on the client -//! dioxus::launch(app); -//! #[cfg(feature = "server")] -//! { -//! tokio::runtime::Runtime::new() -//! .unwrap() -//! .block_on(async move { -//! // Get the address the server should run on. If the CLI is running, the CLI proxies fullstack into the main address -//! // and we use the generated address the CLI gives us -//! let address = dioxus::cli_config::fullstack_address_or_localhost(); -//! let listener = tokio::net::TcpListener::bind(address) -//! .await -//! .unwrap(); -//! axum::serve( -//! listener, -//! axum::Router::new() -//! // Server side render the application, serve static assets, and register server functions -//! .serve_dioxus_application(ServeConfigBuilder::default(), app) -//! .into_make_service(), -//! ) -//! .await -//! .unwrap(); -//! }); -//! } -//! } -//! -//! fn app() -> Element { -//! let mut text = use_signal(|| "Click the button to run a server function".to_string()); -//! -//! rsx! { -//! button { -//! onclick: move |_| async move { -//! if let Ok(data) = get_server_data().await { -//! text.set(data); -//! } -//! }, -//! "Run a server function" -//! } -//! "Server said: {text}" -//! } -//! } -//! -//! #[server(GetServerData)] -//! async fn get_server_data() -> ServerFnResult { -//! Ok("Hello from the server!".to_string()) -//! } -//! ``` - -pub mod config; -pub mod context; - -mod document; -mod render; -mod server; -mod streaming; - -pub(crate) use config::*; - -pub use crate::config::{ServeConfig, ServeConfigBuilder}; -pub use crate::context::Axum; -pub use crate::render::{FullstackHTMLTemplate, SSRState}; -pub use crate::server::*; -pub use config::*; -pub use context::{ - extract, server_context, with_server_context, DioxusServerContext, FromContext, - FromServerContext, ProvideServerContext, -}; -pub use dioxus_isrg::{IncrementalRenderer, IncrementalRendererConfig}; -pub use document::ServerDocument; - -#[cfg(not(target_arch = "wasm32"))] -mod launch; - -#[cfg(not(target_arch = "wasm32"))] -pub use launch::{launch, launch_cfg}; diff --git a/packages/wasm-split/wasm-split-cli/src/lib.rs b/packages/wasm-split/wasm-split-cli/src/lib.rs index e8338ed66..b9b982915 100644 --- a/packages/wasm-split/wasm-split-cli/src/lib.rs +++ b/packages/wasm-split/wasm-split-cli/src/lib.rs @@ -1324,12 +1324,24 @@ impl<'a> ModuleWithRelocations<'a> { fn get_symbol_dep_node(&self, index: usize) -> Result> { let res = match self.symbols[index] { SymbolInfo::Data { .. } => Some(Node::DataSymbol(index)), - SymbolInfo::Func { name, .. } => Some(Node::Function( - *self - .names_to_funcs - .get(name.expect("local func symbol without name?")) - .unwrap(), - )), + SymbolInfo::Func { name, .. } => Some(Node::Function({ + let name = name.context( + "Function symbol has no name - did you forget to enable debug symbols", + )?; + + let func_id = self.names_to_funcs.get(name); + + // wbindgen will synthesize some functions that don't exist in the original module (eg describe functions) + // Previously this was a hard error, but now we just ignore it. It used to mean that the user + let Some(res) = func_id else { + if !name.contains("__wbindgen_") { + tracing::error!("Could not find function symbol {name:?} in module - was this built with LTO, --emit-relocs, and debug symbols? Ignoring."); + } + return Ok(None); + }; + + *res + })), _ => None, }; diff --git a/packages/web/Cargo.toml b/packages/web/Cargo.toml index 8f694471c..55e9d5d5a 100644 --- a/packages/web/Cargo.toml +++ b/packages/web/Cargo.toml @@ -22,8 +22,7 @@ dioxus-interpreter-js = { workspace = true, features = [ "minimal_bindings", "webonly", ] } -dioxus-fullstack-protocol = { workspace = true, features = ["web"], optional = true } -dioxus-fullstack-hooks = { workspace = true, features = ["web"], optional = true } +dioxus-fullstack-core = { workspace = true, features = ["web"], optional = true } generational-box = { workspace = true } js-sys = { workspace = true } @@ -38,8 +37,9 @@ serde = { version = "1.0", optional = true } serde-wasm-bindgen = { version = "0.6.5", optional = true } ciborium = { workspace = true, optional = true } -async-trait = { workspace = true, optional = true } gloo-timers = { workspace = true, optional = true, features = ["futures"] } +send_wrapper = { workspace = true, features = ["futures"] } +wasm-streams = "0.4.2" [dependencies.web-sys] version = "0.3.77" @@ -52,6 +52,8 @@ features = [ "console", "CustomEvent", "DataTransfer", + "DataTransferItemList", + "DataTransferItem", "Document", "DomRectReadOnly", "DragEvent", @@ -80,17 +82,20 @@ features = [ "TransitionEvent", "WheelEvent", "Window", + "File", + "FileList", + "FileReader", + "ReadableStream" ] [build-dependencies] lazy-js-bundle = { workspace = true } [features] -default = ["mounted", "file_engine", "devtools", "document"] -hydrate = ["web-sys/Comment", "dep:serde", "dep:dioxus-fullstack-protocol", "dep:dioxus-fullstack-hooks"] +default = ["mounted", "devtools", "document"] +hydrate = ["web-sys/Comment", "dep:serde", "dep:dioxus-fullstack-core"] mounted = [ "web-sys/Element", - "dioxus-html/mounted", "web-sys/Element", "web-sys/DomRect", "web-sys/ScrollIntoViewOptions", @@ -98,13 +103,6 @@ mounted = [ "web-sys/ScrollBehavior", "web-sys/HtmlElement", ] -file_engine = [ - "dioxus-html/file_engine", - "dep:async-trait", - "web-sys/File", - "web-sys/FileList", - "web-sys/FileReader" -] devtools = [ "web-sys/MessageEvent", "web-sys/WebSocket", diff --git a/packages/web/src/cfg.rs b/packages/web/src/cfg.rs index 305e32177..baebc320d 100644 --- a/packages/web/src/cfg.rs +++ b/packages/web/src/cfg.rs @@ -14,6 +14,7 @@ use wasm_bindgen::JsCast as _; /// ``` pub struct Config { pub(crate) hydrate: bool, + #[allow(dead_code)] pub(crate) panic_hook: bool, pub(crate) root: ConfigRoot, #[cfg(feature = "document")] diff --git a/packages/web/src/document.rs b/packages/web/src/document.rs index 4de16e274..b46a62093 100644 --- a/packages/web/src/document.rs +++ b/packages/web/src/document.rs @@ -100,7 +100,7 @@ pub fn init_document() { #[cfg(feature = "hydrate")] pub fn init_fullstack_document() { - use dioxus_fullstack_hooks::{ + use dioxus_fullstack_core::{ document::FullstackWebDocument, history::provide_fullstack_history_context, }; @@ -131,14 +131,14 @@ impl Document for WebDocument { /// Create a new meta tag in the head fn create_meta(&self, props: MetaProps) { queue_effect(move || { - append_element_to_head("meta", &props.attributes(), None); + _ = append_element_to_head("meta", &props.attributes(), None); }); } /// Create a new script tag in the head fn create_script(&self, props: ScriptProps) { queue_effect(move || { - append_element_to_head( + _ = append_element_to_head( "script", &props.attributes(), props.script_contents().ok().as_deref(), @@ -149,7 +149,7 @@ impl Document for WebDocument { /// Create a new style tag in the head fn create_style(&self, props: StyleProps) { queue_effect(move || { - append_element_to_head( + _ = append_element_to_head( "style", &props.attributes(), props.style_contents().ok().as_deref(), @@ -160,7 +160,7 @@ impl Document for WebDocument { /// Create a new link tag in the head fn create_link(&self, props: LinkProps) { queue_effect(move || { - append_element_to_head("link", &props.attributes(), None); + _ = append_element_to_head("link", &props.attributes(), None); }); } } @@ -169,19 +169,21 @@ fn append_element_to_head( local_name: &str, attributes: &Vec<(&'static str, String)>, text_content: Option<&str>, -) { +) -> Result<(), JsValue> { let window = web_sys::window().expect("no global `window` exists"); let document = window.document().expect("should have a document on window"); let head = document.head().expect("document should have a head"); - let element = document.create_element(local_name).unwrap(); + let element = document.create_element(local_name)?; for (name, value) in attributes { - element.set_attribute(name, value).unwrap(); + element.set_attribute(name, value)?; } if text_content.is_some() { element.set_text_content(text_content); } - head.append_child(&element).unwrap(); + head.append_child(&element)?; + + Ok(()) } /// Required to avoid blocking the Rust WASM thread. diff --git a/packages/web/src/events/drag.rs b/packages/web/src/events/drag.rs index 090f4a91e..ef842cada 100644 --- a/packages/web/src/events/drag.rs +++ b/packages/web/src/events/drag.rs @@ -1,11 +1,13 @@ +use crate::{WebFileData, WebFileEngine}; + use super::{Synthetic, WebEventExt}; use dioxus_html::{ geometry::{ClientPoint, ElementPoint, PagePoint, ScreenPoint}, input_data::{decode_mouse_button_set, MouseButton}, - HasDragData, HasFileData, HasMouseData, InteractionElementOffset, InteractionLocation, - Modifiers, ModifiersInteraction, PointerInteraction, + FileData, HasDragData, HasFileData, HasMouseData, InteractionElementOffset, + InteractionLocation, Modifiers, ModifiersInteraction, PointerInteraction, }; -use web_sys::DragEvent; +use web_sys::{DragEvent, FileReader}; impl InteractionLocation for Synthetic { fn client_coordinates(&self) -> ClientPoint { @@ -71,31 +73,35 @@ impl HasDragData for Synthetic { } impl HasFileData for Synthetic { - fn files(&self) -> Option> { - #[cfg(feature = "file_engine")] - { - use wasm_bindgen::JsCast; - let files = self - .event - .dyn_ref::() - .and_then(|drag_event| { - drag_event.data_transfer().and_then(|dt| { - dt.files().and_then(|files| { - #[allow(clippy::arc_with_non_send_sync)] - crate::file_engine::WebFileEngine::new(files).map(|f| { - std::sync::Arc::new(f) - as std::sync::Arc - }) - }) - }) - }); + fn files(&self) -> Vec { + use wasm_bindgen::JsCast; - files - } - #[cfg(not(feature = "file_engine"))] - { - None + if let Some(target) = self.event.dyn_ref::() { + if let Some(data_transfer) = target.data_transfer() { + if let Some(file_list) = data_transfer.files() { + return WebFileEngine::new(file_list).to_files(); + } else { + let items = data_transfer.items(); + let mut files = vec![]; + for i in 0..items.length() { + if let Some(item) = items.get(i) { + if item.kind() == "file" { + if let Ok(Some(file)) = item.get_as_file() { + let web_data = + WebFileData::new(file, FileReader::new().unwrap()); + files.push(FileData::new(web_data)); + } + } + } + } + return files; + } + } + } else { + tracing::warn!("DragEvent target was not a DragEvent"); } + + vec![] } } diff --git a/packages/web/src/events/file.rs b/packages/web/src/events/file.rs index 5968d31b0..f44edd747 100644 --- a/packages/web/src/events/file.rs +++ b/packages/web/src/events/file.rs @@ -1,30 +1,47 @@ -use dioxus_html::HasFileData; +use dioxus_html::{FileData, HasFileData}; +use web_sys::FileReader; + +use crate::{WebFileData, WebFileEngine}; use super::Synthetic; impl HasFileData for Synthetic { - fn files(&self) -> Option> { - #[cfg(feature = "file_engine")] - { - use wasm_bindgen::JsCast; + fn files(&self) -> Vec { + use wasm_bindgen::JsCast; + let target = self.event.target(); - let files = self - .event - .dyn_ref() - .and_then(|input: &web_sys::HtmlInputElement| { - input.files().and_then(|files| { - #[allow(clippy::arc_with_non_send_sync)] - crate::file_engine::WebFileEngine::new(files).map(|f| { - std::sync::Arc::new(f) as std::sync::Arc - }) - }) - }); - - files - } - #[cfg(not(feature = "file_engine"))] + if let Some(target) = target + .clone() + .and_then(|t| t.dyn_into::().ok()) { - None + if let Some(file_list) = target.files() { + return WebFileEngine::new(file_list).to_files(); + } } + + if let Some(target) = target.and_then(|t| t.dyn_into::().ok()) { + if let Some(data_transfer) = target.data_transfer() { + if let Some(file_list) = data_transfer.files() { + return WebFileEngine::new(file_list).to_files(); + } else { + let items = data_transfer.items(); + let mut files = vec![]; + for i in 0..items.length() { + if let Some(item) = items.get(i) { + if item.kind() == "file" { + if let Ok(Some(file)) = item.get_as_file() { + let web_data = + WebFileData::new(file, FileReader::new().unwrap()); + files.push(FileData::new(web_data)); + } + } + } + } + return files; + } + } + } + + vec![] } } diff --git a/packages/web/src/events/form.rs b/packages/web/src/events/form.rs index 80113effa..4dd34c301 100644 --- a/packages/web/src/events/form.rs +++ b/packages/web/src/events/form.rs @@ -1,6 +1,6 @@ use std::{any::Any, collections::HashMap}; -use dioxus_html::{FormValue, HasFileData, HasFormData}; +use dioxus_html::{FileData, FormValue, HasFileData, HasFormData}; use js_sys::Array; use wasm_bindgen::JsValue; use wasm_bindgen::{prelude::wasm_bindgen, JsCast}; @@ -10,7 +10,7 @@ use super::WebEventExt; pub(crate) struct WebFormData { element: Element, - raw: Event, + event: Event, } impl WebEventExt for dioxus_html::FormData { @@ -23,8 +23,8 @@ impl WebEventExt for dioxus_html::FormData { } impl WebFormData { - pub fn new(element: Element, raw: Event) -> Self { - Self { element, raw } + pub fn new(element: Element, event: Event) -> Self { + Self { element, event } } } @@ -32,39 +32,39 @@ impl HasFormData for WebFormData { fn value(&self) -> String { let target = &self.element; target - .dyn_ref() - .map(|input: &web_sys::HtmlInputElement| { - // todo: special case more input types - match input.type_().as_str() { - "checkbox" => { - match input.checked() { - true => "true".to_string(), - false => "false".to_string(), + .dyn_ref() + .map(|input: &web_sys::HtmlInputElement| { + // todo: special case more input types + match input.type_().as_str() { + "checkbox" => { + match input.checked() { + true => "true".to_string(), + false => "false".to_string(), + } + }, + _ => { + input.value() } - }, - _ => { - input.value() } - } - }) - .or_else(|| { - target - .dyn_ref() - .map(|input: &web_sys::HtmlTextAreaElement| input.value()) - }) - // select elements are NOT input events - because - why woudn't they be?? - .or_else(|| { - target - .dyn_ref() - .map(|input: &web_sys::HtmlSelectElement| input.value()) - }) - .or_else(|| { - target - .dyn_ref::() - .unwrap() - .text_content() - }) - .expect("only an InputElement or TextAreaElement or an element with contenteditable=true can have an oninput event listener") + }) + .or_else(|| { + target + .dyn_ref() + .map(|input: &web_sys::HtmlTextAreaElement| input.value()) + }) + // select elements are NOT input events - because - why woudn't they be?? + .or_else(|| { + target + .dyn_ref() + .map(|input: &web_sys::HtmlSelectElement| input.value()) + }) + .or_else(|| { + target + .dyn_ref::() + .unwrap() + .text_content() + }) + .expect("only an InputElement or TextAreaElement or an element with contenteditable=true can have an oninput event listener") } fn values(&self) -> HashMap { @@ -101,32 +101,20 @@ impl HasFormData for WebFormData { } fn as_any(&self) -> &dyn Any { - &self.raw as &dyn Any + &self.event as &dyn Any } } impl HasFileData for WebFormData { - fn files(&self) -> Option> { - #[cfg(feature = "file_engine")] - { - let files = self - .element - .dyn_ref() - .and_then(|input: &web_sys::HtmlInputElement| { - input.files().and_then(|files| { - #[allow(clippy::arc_with_non_send_sync)] - crate::file_engine::WebFileEngine::new(files).map(|f| { - std::sync::Arc::new(f) as std::sync::Arc - }) - }) - }); - - files - } - #[cfg(not(feature = "file_engine"))] - { - None - } + fn files(&self) -> Vec { + use wasm_bindgen::JsCast; + self.event + .target() + .and_then(|t| t.dyn_into::().ok()) + .and_then(|input| input.files()) + .map(crate::files::WebFileEngine::new) + .map(|engine| engine.to_files()) + .unwrap_or_default() } } diff --git a/packages/web/src/file_engine.rs b/packages/web/src/file_engine.rs deleted file mode 100644 index f0a0ff698..000000000 --- a/packages/web/src/file_engine.rs +++ /dev/null @@ -1,133 +0,0 @@ -use std::any::Any; - -use dioxus_html::FileEngine; -use futures_channel::oneshot; -use js_sys::Uint8Array; -use wasm_bindgen::{prelude::Closure, JsCast}; -use web_sys::{File, FileList, FileReader}; - -/// A file engine for the web platform -pub struct WebFileEngine { - file_reader: FileReader, - file_list: FileList, -} - -impl WebFileEngine { - /// Create a new file engine from a file list - pub fn new(file_list: FileList) -> Option { - Some(Self { - file_list, - file_reader: FileReader::new().ok()?, - }) - } - - fn len(&self) -> usize { - self.file_list.length() as usize - } - - fn get(&self, index: usize) -> Option { - self.file_list.item(index as u32) - } - - fn find(&self, name: &str) -> Option { - (0..self.len()) - .filter_map(|i| self.get(i)) - .find(|f| f.name() == name) - } -} - -#[async_trait::async_trait(?Send)] -impl FileEngine for WebFileEngine { - fn files(&self) -> Vec { - (0..self.len()) - .filter_map(|i| self.get(i).map(|f| f.name())) - .collect() - } - - async fn file_size(&self, file: &str) -> Option { - let file = self.find(file)?; - Some(file.size() as u64) - } - - // read a file to bytes - async fn read_file(&self, file: &str) -> Option> { - let file = self.find(file)?; - - let file_reader = self.file_reader.clone(); - let (rx, tx) = oneshot::channel(); - let on_load: Closure = Closure::new({ - let mut rx = Some(rx); - move || { - let result = file_reader.result(); - let _ = rx - .take() - .expect("multiple files read without refreshing the channel") - .send(result); - } - }); - - self.file_reader - .set_onload(Some(on_load.as_ref().unchecked_ref())); - on_load.forget(); - self.file_reader.read_as_array_buffer(&file).ok()?; - - if let Ok(Ok(js_val)) = tx.await { - let as_u8_arr = Uint8Array::new(&js_val); - let as_u8_vec = as_u8_arr.to_vec(); - - Some(as_u8_vec) - } else { - None - } - } - - // read a file to string - async fn read_file_to_string(&self, file: &str) -> Option { - let file = self.find(file)?; - - let file_reader = self.file_reader.clone(); - let (rx, tx) = oneshot::channel(); - let on_load: Closure = Closure::new({ - let mut rx = Some(rx); - move || { - let result = file_reader.result(); - let _ = rx - .take() - .expect("multiple files read without refreshing the channel") - .send(result); - } - }); - - self.file_reader - .set_onload(Some(on_load.as_ref().unchecked_ref())); - on_load.forget(); - self.file_reader.read_as_text(&file).ok()?; - - if let Ok(Ok(js_val)) = tx.await { - js_val.as_string() - } else { - None - } - } - - async fn get_native_file(&self, file: &str) -> Option> { - let file = self.find(file)?; - Some(Box::new(file)) - } -} - -/// Helper trait for WebFileEngine -#[async_trait::async_trait(?Send)] -pub trait WebFileEngineExt { - /// returns web_sys::File - async fn get_web_file(&self, file: &str) -> Option; -} - -#[async_trait::async_trait(?Send)] -impl WebFileEngineExt for std::sync::Arc { - async fn get_web_file(&self, file: &str) -> Option { - let native_file = self.get_native_file(file).await?; - let ret = native_file.downcast::().ok()?; - Some(*ret) - } -} diff --git a/packages/web/src/files.rs b/packages/web/src/files.rs new file mode 100644 index 000000000..adb4bb418 --- /dev/null +++ b/packages/web/src/files.rs @@ -0,0 +1,189 @@ +use dioxus_core::AnyhowContext; +use dioxus_html::{bytes::Bytes, FileData, NativeFileData}; +use futures_channel::oneshot; +use js_sys::Uint8Array; +use send_wrapper::SendWrapper; +use std::{pin::Pin, prelude::rust_2024::Future}; +use wasm_bindgen::{prelude::Closure, JsCast}; +use web_sys::{File, FileList, FileReader}; + +/// A file representation for the web platform +#[derive(Clone)] +pub struct WebFileData { + file: File, + reader: FileReader, +} + +unsafe impl Send for WebFileData {} +unsafe impl Sync for WebFileData {} + +impl WebFileData { + /// Create a new WebFileData from a web_sys::File + pub fn new(file: File, reader: FileReader) -> Self { + Self { file, reader } + } +} + +impl NativeFileData for WebFileData { + fn name(&self) -> String { + self.file.name() + } + + fn size(&self) -> u64 { + self.file.size() as u64 + } + + fn last_modified(&self) -> u64 { + self.file.last_modified() as u64 + } + + fn read_bytes( + &self, + ) -> Pin> + 'static>> { + let file_reader = self.reader.clone(); + let file_reader_ = self.reader.clone(); + let file = self.file.clone(); + Box::pin(async move { + let (rx, tx) = oneshot::channel(); + let on_load: Closure = Closure::new({ + let mut rx = Some(rx); + move || { + let result = file_reader.result(); + let _ = rx + .take() + .expect("multiple files read without refreshing the channel") + .send(result); + } + }); + + file_reader_.set_onload(Some(on_load.as_ref().unchecked_ref())); + on_load.forget(); + file_reader_ + .read_as_array_buffer(&file) + .ok() + .context("Failed to read file")?; + + let js_val = tx.await?.ok().context("Failed to read file")?; + let as_u8_arr = Uint8Array::new(&js_val); + let as_u8_vec = as_u8_arr.to_vec().into(); + Ok(as_u8_vec) + }) + } + + fn read_string( + &self, + ) -> Pin> + 'static>> { + let file_reader = self.reader.clone(); + let file_reader_ = self.reader.clone(); + let file = self.file.clone(); + Box::pin(async move { + let (rx, tx) = oneshot::channel(); + let on_load: Closure = Closure::new({ + let mut rx = Some(rx); + move || { + let result = file_reader.result(); + let _ = rx + .take() + .expect("multiple files read without refreshing the channel") + .send(result); + } + }); + + file_reader_.set_onload(Some(on_load.as_ref().unchecked_ref())); + on_load.forget(); + file_reader_ + .read_as_text(&file) + .ok() + .context("Failed to read file")?; + + let js_val = tx.await?.ok().context("Failed to read file")?; + let as_string = js_val.as_string().context("Failed to read file")?; + Ok(as_string) + }) + } + + /// we'd like to use `blob` to readable stream here, but we cannot. + /// + /// We just read the entire file into memory and return it as a single chunk. + /// This is not super great, especially given the wasm <-> js boundary duplication cost. + /// + /// For more efficient streaming of byte data, consider using the dedicated FileStream type which + /// goes directly from `File` to fetch request body without going through Rust. + /// + /// We should maybe update these APIs to use our own custom `ByteBuffer` type to avoid going through `Vec`? + fn byte_stream( + &self, + ) -> Pin> + 'static + Send>> + { + let file = self.file.dyn_ref::().unwrap().clone(); + Box::pin(SendWrapper::new(futures_util::stream::once(async move { + let array_buff = wasm_bindgen_futures::JsFuture::from(file.array_buffer()) + .await + .unwrap(); + let as_uint_array = array_buff.dyn_into::().unwrap(); + Ok(as_uint_array.to_vec().into()) + }))) + } + + fn inner(&self) -> &dyn std::any::Any { + &self.file + } + + fn path(&self) -> std::path::PathBuf { + std::path::PathBuf::from(self.file.name()) + } + + fn content_type(&self) -> Option { + let type_ = self.file.type_(); + if type_.is_empty() { + None + } else { + Some(type_) + } + } +} + +/// A file engine for the web platform +#[derive(Clone)] +pub(crate) struct WebFileEngine { + file_list: FileList, +} + +impl WebFileEngine { + /// Create a new file engine from a file list + pub fn new(file_list: FileList) -> Self { + Self { file_list } + } + + fn len(&self) -> usize { + self.file_list.length() as usize + } + + fn get(&self, index: usize) -> Option { + self.file_list.item(index as u32) + } + + pub fn to_files(&self) -> Vec { + (0..self.len()) + .filter_map(|i| self.get(i)) + .map(|file| { + FileData::new(WebFileData { + file, + reader: FileReader::new().unwrap(), + }) + }) + .collect() + } +} + +/// Helper trait for extracting the underlying `web_sys::File` from a `FileData` +pub trait WebFileExt { + /// returns web_sys::File + fn get_web_file(&self) -> Option; +} + +impl WebFileExt for FileData { + fn get_web_file(&self) -> Option { + self.inner().downcast_ref::().cloned() + } +} diff --git a/packages/web/src/hydration/hydrate.rs b/packages/web/src/hydration/hydrate.rs index 5116df111..dcc72b9b1 100644 --- a/packages/web/src/hydration/hydrate.rs +++ b/packages/web/src/hydration/hydrate.rs @@ -8,7 +8,7 @@ use dioxus_core::{ AttributeValue, DynamicNode, ElementId, ScopeId, ScopeState, SuspenseBoundaryProps, SuspenseContext, TemplateNode, VNode, VirtualDom, }; -use dioxus_fullstack_protocol::HydrationContext; +use dioxus_fullstack_core::HydrationContext; use futures_channel::mpsc::UnboundedReceiver; use std::fmt::Write; use RehydrationError::*; diff --git a/packages/web/src/lib.rs b/packages/web/src/lib.rs index d16d4be00..79ec91e72 100644 --- a/packages/web/src/lib.rs +++ b/packages/web/src/lib.rs @@ -4,8 +4,6 @@ //! # Dioxus Web -use std::time::Duration; - pub use crate::cfg::Config; use crate::hydration::SuspenseMessage; use dioxus_core::VirtualDom; @@ -22,17 +20,16 @@ pub use events::*; #[cfg(feature = "document")] mod document; -#[cfg(feature = "file_engine")] -mod file_engine; #[cfg(feature = "document")] mod history; #[cfg(feature = "document")] pub use document::WebDocument; -#[cfg(feature = "file_engine")] -pub use file_engine::*; #[cfg(feature = "document")] pub use history::{HashHistory, WebHistory}; +mod files; +pub use files::*; + #[cfg(all(feature = "devtools", debug_assertions))] mod devtools; @@ -95,7 +92,7 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! { #[cfg(feature = "hydrate")] { - use dioxus_fullstack_protocol::HydrationContext; + use dioxus_fullstack_core::HydrationContext; websys_dom.skip_mutations = true; // Get the initial hydration data from the client @@ -139,7 +136,7 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! { // Provide a hydration compatible create error boundary method dioxus_core::ScopeId::ROOT.in_runtime(|| { dioxus_core::provide_create_error_boundary( - dioxus_fullstack_hooks::errors::init_error_boundary, + dioxus_fullstack_core::init_error_boundary, ); }); #[cfg(feature = "document")] @@ -235,7 +232,7 @@ pub async fn run(mut virtual_dom: VirtualDom, web_config: Config) -> ! { "Hot-patch success!", &format!("App successfully patched in {} ms", hr_msg.ms_elapsed), devtools::ToastLevel::Success, - Duration::from_millis(2000), + std::time::Duration::from_millis(2000), false, ); }