GithubHelp home page GithubHelp logo

ripley's Introduction

ripley

test workflow

Ripley is a fast server-side rendered web UI toolkit with live components.

Create rich webapps without the need for a SPA frontend.

Comparison with SPA

Single Page Appplications are complicated things, ripley is a traditional server side rendered model with websocket enhancement for live parts.

Pros:

  • No need for an API just for the frontend
  • No need for client-side state management
  • No need to wait for large JS download before rendering (or setup complicated SSR for client side apps)
  • Leverages browser's native routing
  • No separate backend and frontend build complexity, just use Clojure
  • Use functions and hiccup to build the UI, like Reagent or other ClojureScript React wrappers

Cons:

  • Client browser needs constant connection to server
  • Interaction latency is limited by network conditions
  • Unsuitable for serverless cloud platforms

Usage

Using ripley is from a regular ring app is easy. You call ripley.html/render-response from a ring handler to create a response that sets up a live context.

The render response takes a root component that will render the page. Any rendered live components are registered with the context and updates are sent to the client if their sources change. Use ripley.html/html macro to output HTML with hiccup style markup.

Live components are rendered with the special :ripley.html/live element. The live component takes a source (which implements ripley.live.protocols/Source) and a component function. The component function is called with the value received from the source.

(def counter (atom 0))

(defn counter-app [counter]
  (h/html
    [:div
      "Counter value: " [::h/live {:source (atom-source counter)
                                   :component #(h/html [:span %])}]
      [:button {:on-click #(swap! counter inc)} "increment"]
      [:button {:on-click #(swap! counter dec)} "decrement"]]))

All event handling attributes (like :on-click or :on-change) are registered as callbacks that are sent via the websocket to the server. See ripley.js namespace for helpers in creating callbacks with more options. You can add debouncing and client side condition and success/failure handlers.

See more details and fully working example in the examples folder.

Sources

The main abstraction for working with live components in ripley is the Source. It provides a way for rendering to get the current value (if available) and allows the live context to listen for changes.

The source value can be an atomic value (like string, number or boolean) or a map or collection. The interpretation of the value of the source if entirely up to the component that is being rendered.

Built-in sources

Ripley provides built-in sources that integrate regular Clojure mechanisms into sources. Built-in sources don't require any external extra dependencies.

You can create sources by calling the specific constructor functions in ripley.live.source namespace or the to-source multimethod.

Type Description
atom Regular Clojure atoms. Listens to changes with add-watch. See: use-state
use-state Convenient light weight source for per render local state
core.async channel A core async channel
future Any future value, if realized by render time, used directly. Otherwise patched in after the result is available.
promise A promise, if delivered by render time, used directly. Otherwise patched in after the promise is delivered.
computed Takes one or more input sources and a function. Listens to input sources and calls function with their values to update. See also c= convenience macros.
split Takes a map valued input source and keysets. Distributes changes to sub sources only when their keysets change.

Integration sources

Ripley also contains integration sources that integrate external state into usable sources. Integration sources may need external dependencies (not provided by ripley) see namespace docstring for an integration source in ripley.integration.<type>.

Type Description
redis Integrate Redis pubsub channels as sources (uses carmine library)
manifold Integrate manifold library deferred and stream as source
xtdb Integrate XTDB query as an automatically updating source

Working with components

Component functions

In Ripley, components are functions that take in parameters and output HTML fragment as a side-effect. They do not return a value. This is different from normal hiccup, where functions would return a hiccup vector describing the HTML.

Ripley uses the ripley.html/html macro to convert a hiccup style body into plain Clojure that writes HTML. The macro also adds Ripley's internal tracking attributes so components can be updated on the fly.

Any Clojure code can be called inside the body, but take note that return values are discarded. This is a common mistake, forgetting to use the HTML macro in a function and returning a vector. The caller will simply discard it and nothing is output.

Child components

Components form a tree so a component can have child components with their own sources. The children are registered under the parent and if the parent fully rerenders, the children are recursively cleaned up. A component does not need to care if it is at the top level or a child of some other component.

The main consideration comes from the sources used. If the parent component creates per render sources for the children, the children will lose the state when the parent is rerendered.

Dynamic scope

Ripley supports capturing dynamic scope that was in place when a component or callback was created. This can be used to avoid passing in every piece of context to all components (like user information or db connection pools). The set of vars to capture must be configured when calling ripley.html/render-response.

Dev mode

In development mode, Ripley can be made to replace any html macro with an error description panel that shows exception information and the body source of the form. This has some performance penalty as all components will first be output into an in-memory StringWriter instead of directly to the response.

Dev mode can be enabled with the system property argument -Dripley.dev-mode=true or by setting the ripley.html/dev-mode? atom to true before any ripley.html/html macroexpansions take place.

Client side state

Ripley supports a custom attribute ::h/after-replace in the component's root element. When the component is replaced when the source value changes, this JS fragment is evaluated after the DOM update. This can be used to reinitialize any client side scripts that are attached to this component. The DOM element that was replaced is bound to this during evaluation.

Changes

2024-04-03

  • Use new Function instead of eval for after replace JS code

2024-04-02

  • Add ::h/after-replace attribute (see Client side state above)

2024-03-18

  • Add inert boolean attribute

2024-03-06

  • Dev mode: replace component with an error display when an exception is thrown

2023-12-27

  • Bugfix: also cleanup source that is only used via other computed sources

2023-12-05

  • Bugfix: handle callback arities correctly when using bindings and no success handler

2023-09-21

  • Add support for undertow server (thanks @zekzekus)

2023-09-20

  • Bugfix: proper live collection cleanup on long-lived source (like atom)

2023-09-19

  • Bugfix: support 0-arity callbacks when wrapping failure/success handlers

2023-09-16

  • Alternate server implementation support (with pedestal+jetty implementation)

2023-09-09

  • Support dynamic binding capture

2023-09-02

  • Support a ::h/live-let directive that is more concise

2023-08-30

  • Log errors in component render and callback processing

2023-08-26

  • Fix bug in live collection cleanup not being called

2023-08-04

  • Fix computed source when calculation fn is not pure (eg. uses current time)

2023-07-01

  • Added ripley.js/export-callbacks to conveniently expose server functions as JS functions
  • Added static utility to use a static value as a source

2023-06-28

  • Source value can be nil now and component is replaced with placeholder

2023-06-10

  • Support client side success and failure callbacks

2023-06-07

  • ripley.html/compile-special is now a multimethod and can be extended

2023-03-18

  • Support specifying :should-update? in ::h/live
  • use-state now returns a third value (update-state! callback)

See commit log for older changes

ripley's People

Contributors

dependabot[bot] avatar tatut avatar zekzekus avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

ripley's Issues

Add `::h/live-let` directive

Currently there is a ::h/live directive that takes a source and a component function.
The component will usually need to destructure the source value it gets as a parameter and switch back to html context with h/html

[::h/live user-source
  (fn [{:keys [id first-name last-name]}]
    (let [some-other (db.user/find-some-auxiliary-data db id)]
      (h/html 
        [:div.user-info ...])))]

A live let where the first binding value would be a source and the body would be already in html context, would cut down unnecessary code even further.

Example using proposed live-let

[::h/live-let [{:keys [id first-name last-name]} user-source
               some-other (db.user/find-some-auxiliary-data db id)]
  [:div.user-info ...]]

Improve error messages

An error like
clojure.lang.ArityException: Wrong number of args (0) passed to: ripley.live.context/bind/fn--22131

is inscrutable when processing a callback... add information about which component the callback belongs to the exception handler around ripley.live.context:298

`::h/for` could support `:index`

If you need an running index number, you usually need to add map-indexed around your data to assoc it.
For could support it automatically by specifying a symbol.

Example of use with this feature:

[:ul 
  [::h/for [{:item/keys [name]} (fetch-my-items db)
            :index i]
    [:li {:class (if (even? i) "eden" "odd)} name]]]

Support for servers other than http-kit?

I am trying to run ripley in a kit-clj project which uses undertow web server through reitit router. I investigated a lot and found out that the implementation of connection handler is coupled with http-kit server and it's websocket support.

Would you confirm that I am on the right path? I feel I need to implement connection handler for other servers. If it makes sense and might get some help, I would like to try a generic connection handler to integrate with various web servers.

Clojars

Would it be possible/advisable to get this library a little easier to use by getting it onto Clojars?

Use sci for interactivity?

IMO the major challenges of client-side event handling in server side apps are:

  1. Deciding the frequency at which to send an event. For example, should "mousemove" be sent every time or throttled? This varies from case to case.
  2. Serializing the event. Which data does the server care about? This varies from case to case.
  3. e.preventDefault / e.stopPropagation โ€“ this needs to happen immediately and client-side since the JS runtime will move into the next frame. Sometimes this requires client-side logic, e.g. did the user click-outside? an element

So what if ripley used sci for event-handling functions? This would allow to make client-side decisions in Clojure. The functions would be wrapped by a macro like ripley.html/html to send additional EDN or additional code to eval server-side.

Inspiration: bb-web which uses sci as its runtime for a CLJS app. demo

Making the counter work

I tried for some time getting the counter to work. I have the following main.clj

(ns infinite-orange.main
  (:require [ripley.html :as h]
            [compojure.core :refer [routes GET]]
            [org.httpkit.server :as server]
            [ripley.live.context :as context]
            [ripley.live.atom :as atom]))

(def counter (atom 0))

(defn counter-app [counter]
  (h/html
   [:div
    "Counter value: " [::h/live {:source (atom/atom-source counter)
                                 :component #(h/out! (str %))}]
    [:button {:on-click #(swap! counter inc)} "increment"]
    [:button {:on-click #(swap! counter dec)} "decrement"]]))

(defn index []
  (h/html
   [:html
    [:head]
    [:body
     (h/live-client-script "/__ripley-live")
     (counter-app counter)]]))

(def app-routes
  (routes
   (GET "/" _req
        (h/render-response #(index)))
   (context/connection-handler "/__ripley-live")))

(defonce server (atom nil))

(defn- restart []
  (swap! server
         (fn [old-server]
           (when old-server (old-server))
           (println "(Re)starting server")
           (server/run-server app-routes {:port 3000}))))

(defn -main [& _args]
  (restart))

But I end up with the following

counter1

Replacing #(h/out! (str %)) with #(prn %) prints the correct count to the terminal however.
Is something missing in my example?

ClassCastException on render

Exception while rendering! #error {
 :cause class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')
 :via
 [{:type java.lang.ClassCastException
   :message class java.lang.String cannot be cast to class clojure.lang.IFn (java.lang.String is in module java.base of loader 'bootstrap'; clojure.lang.IFn is in unnamed module of loader 'app')
   :at [ripley.live.context$render_with_context$fn__13925 invoke context.clj 220]}]
 :trace
 [[ripley.live.context$render_with_context$fn__13925 invoke context.clj 220]
  [ring.util.io$piped_input_stream$fn__205 invoke io.clj 28]
  [clojure.core$binding_conveyor_fn$fn__5823 invoke core.clj 2047]
  [clojure.lang.AFn call AFn.java 18]
  [java.util.concurrent.FutureTask run FutureTask.java 317]
  [java.util.concurrent.ThreadPoolExecutor runWorker ThreadPoolExecutor.java 1144]
  [java.util.concurrent.ThreadPoolExecutor$Worker run ThreadPoolExecutor.java 642]
  [java.lang.Thread run Thread.java 1589]]}

This feels faintly like I haven't sussed out one of the wrapping functions that's needed; I think I have them all, but let's find out. Here's the calling view code:

(defn node [obj]
  (let [[selected-tab select-tab] (source/use-state 0)]
    (hc/html
     [:main#nodeview
      [:header
       [:details {:open true}
        [:summary
         [:nav#breadcrumb]
         [:h1 (:title obj)]]

        [:article (:body obj)]

        [:nav#nodelinks]
        [:aside#incominglinks]]]
      [::h/live {:source selected-tab
                 :component (fn [tab]
                              (h/html [:section#nodetabs.two-up
                                       [:nav [:ul
                                              [:li {:on-click (fn [] (select-tab 0))}
                                               (when (= tab 0) {:set-attributes {:class "open"}}) [:h6 "Subnodes"]]
                                              [:li {:on-click (fn [] (select-tab 1))}
                                               (when (= tab 1) {:set-attributes {:class "open"}}) [:h6 "Properties"]]]]
                                       (when (= tab 0) [:aside#subnodes.active
                                                        (subnodes obj)])
                                       (when (= tab 1) [:aside#properties
                                                        [:div.place (if (:props obj) "props!" "no props")]])]))}]])))

The hc namespace is hiccup, and h is ripley.html. I was getting a different error on top of this one with h/html (at line 3 above, I mean), that using hiccup's at the top of this function got rid of... if that makes sense.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.