Fork me on GitHub
#hyperfiddle
<
2023-09-25
>
mattias07:09:16

Is there a way to use Electric inside chrome extensions (e.g. on the panel or sidebar that execute in chrome devtools). I have a setup that works with shadow-cljs and rum, and I'd like to swap it out with Electric. Electric's shadow-cljs.edn has :target :browser but I also need to use :target :chrome-extension . I have a :browser build for creating the Electric backend (and starting up Chromium via Playwright's Java API which also loads the chrome extension from the shadow build). The :browser build works fine on localhost, and the bare bones :chrome-extension build works as well. What I need now is a way to compile a devtools_sidebar.js file in the :chrome-extension build that would connect to the web/Electric backend. In addition to devtools_sidebar.js knowing how to properly connect to the backend, the cljs-runtime files that are created for :chrome-extension build would need to have the necessary Electric dependencies. I guess you could generalise this question to "how to connect to an external Electric backend from a different cljs build?" Is this doable in principle? If so, could you give me additional pointers where to look next to understand how to make this happen? Or if anyone has time to help out, I could grant access to my existing code example. Any help is much appreciated!

Geoffrey Gaillard07:09:44

> How to connect to an external Electric backend from a different cljs build? > Today this is not possible. It might be possible in the future, but it requires more research. Do you really need to share the server side process between the page and the extension. I assume you actually want to share state? If so, you can have two electric programs, one for the page and one for the extension, so two builds. If both electric programs runs on the same server jvm, then they can easily share state. If they share state, they can react to each other's state changes.

👍 2
mattias07:09:44

Two separate backend processes / shadow builds is fine as well. Is there a way to make a shadow build that has :target :chrome-extension ?

Geoffrey Gaillard08:09:34

We haven’t tried :target :chrome-extension yet. Editing your shadow-cljs.edn and replacing :target :browser by :target :chrome-extension should do the trick. https://github.com/groundedsage/shadow-cljs-chrome-extension-hello-world/tree/main.

grounded_sage09:09:32

I’m also planning to build a chrome extension soon so I’m following this.

grounded_sage09:09:08

Going to be deploying a native app with webview and chrome extension. So might have to do the double server you said @U2DART3HA

mattias13:09:13

With this approach I'm getting Unexpected protocol exception when parsing the URL which refers to the compiled js file which contains the Electric frontend code. Might need to patch the https://github.com/hyperfiddle/electric/blob/0842e64e895b260707a532068dc7f35c147fb65f/src/hyperfiddle/electric_client.cljs#L11 fn temporarily and see if hardcoding it to :port or something like that works? What would be the correct hardcoded string to try if my app is accessible at - or sth else?

Geoffrey Gaillard13:09:58

You are right, server-url hasn’t been designed with chrome extensions in mind. Feel free to patch it (or improve the design) and submit a PR

mattias13:09:49

Hardcoding the response of server-url to "" actually makes it work, which is pretty awesome! But I'm not sure how a good PR for this would look like, because the original URL does not indicate what the host/port/protocol of the backend actually is. Sometimes it would be localhost and http, but it might be some remote url over https as well. Would it make sense to be able to (optionally) pass it as a build argument?

Geoffrey Gaillard14:09:38

Build argument would technically work. Though I’m not sure if it should be hardcoded. I’d assume a production-ready chrome extension to: • fetch initial configuration from an API. • or have the page postMessage the config to the extension • or always connect to . Devs would just redirect the DNS resolve to localhost • etc…

grounded_sage15:09:26

I’ve been outside of extension land for a while. But there is also native messaging for communicating to a local running app.

grounded_sage15:09:45

I’ll be looking into this more when I figure out the desktop setup I am working on.

mattias05:09:21

For me, being able to set the URL at compile time would be enough. But the ideal long-term solution would be: 1. Define a list of backend URLs at compile time (e.g. localhost, dev server, prod server). I guess you could replace this part with "fetch initial configuration from an API". 2. If more than one URL was given, provide an https://developer.chrome.com/docs/extensions/mv2/options/ that enables users to choose the backend. The backend choice is persisted with chrome.storage.sync.set and updated to different Electric frontends (devtools sidebar and panel, content script) with chrome.runtime.sendMessage . 3. Might need to have some initializer (background page?) set the default backend in chrome.storage.sync and emit the initial backend URL with chrome.runtime.sendMessage . 4. Inside Electric frontends, have chrome.runtime.onMessage listeners for changes in the backend URL. If it is detected, it reconnects - is that possible? Before it receives a message, it would not connect anywhere. Not sure if it's feasible with the current architecture though.

Geoffrey Gaillard09:09:30

> If it is detected, it reconnects - is that possible? Yes it is possible. On first message, onMessage would call e/boot to start the electric program On second message, onMessage would stop the running electric program and restart it by calling e/boot again with the new domain

mattias07:09:41

I actually got the following setup working, which is the initial thing I asked for: 1. There is a clj backend process (namespace proj.backend ) that starts an Electric server and has two shadow-cljs watches (one for the :web build frontend and one for :ext build js). In addition to starting Electric and shadow, it starts a nrepl server, where I connect with Calva. The process can start a Chromium browser with the chrome extension from the :ext build. 2. The :web build has :modules {:main {:entries [proj.web.frontend] :init-fn proj.web.frontend/start!}} , so I can access the web frontend in a browser. 3. The chrome extension manifest.edn defines outputs like :devtools-panel {:init-fn proj.ext.devtools-panel/start! for the DevTools panel and sidebar (and could probably work for content script as well). 4. Because I patched server-url to always returns ws:0.0.0.0:8888 which is the URL that proj.backend binds to. So now the web frontend, devtools sidebar and devtools panel javascripts connect to the same backend. Which for me makes sense, because they work as a unified whole (you would often use the extension to interact with the web frontend, so syncing state makes sense). proj.backend has an in-memory XTDB database, and changes from this get propagated to all the different frontends. Not sure if it would behave the same with regular atoms in the backend, or it would create separate state per ws connection (so one for sidebar, one for panel, and one for the web frontend). I guess the reason the single backend approach here works is because I've hardcoded the server-url response? I also tried an approach where I have separate backend processes for the :web and :ext builds with their respective shadow-cljs watches, but (1) shadow-cljs did not allow 2 simultaneously running shadow processes, and (2) I would need to use an XTDB that is not in-memory (is in a separate process and I'm listening on the transaction log in each (backend ?) process). I would need to do (2) soon anyway when scaling things out, though. Will keep exploring this to see if I reach some rough edges, but so far it's pretty cool to get auto-reload and auto-saync between my web app and chrome extension.

nivekuil08:09:36

I pass around a e/fn , and when I call new on it crashes the reactor

2023-09-25T08:43:06.039Z machina ERROR [hyperfiddle.electric-httpkit-adapter:326] - Websocket handler failure. nil
                                               java.lang.Thread.run                   Thread.java: 1623
                 java.util.concurrent.ThreadPoolExecutor$Worker.run       ThreadPoolExecutor.java:  642
                  java.util.concurrent.ThreadPoolExecutor.runWorker       ThreadPoolExecutor.java: 1144
                                java.util.concurrent.FutureTask.run               FutureTask.java:  317
                java.util.concurrent.Executors$RunnableAdapter.call                Executors.java:  577
                             org.httpkit.server.LinkingRunnable.run              RingHandler.java:  156
                                   org.httpkit.server.WSHandler.run              RingHandler.java:  189
                    org.httpkit.server.AsyncChannel.messageReceived             AsyncChannel.java:  153
                                            clojure.core/partial/fn                      core.clj: 2641
 hyperfiddle.electric-httpkit-adapter/handle-electric-ws/on-receive  electric_httpkit_adapter.clj:   75
                                               clojure.core/comp/fn                      core.clj: 2586
                                   missionary.impl.Observe$1.invoke         

                                    missionary.impl.Sample$3.invoke                   Sample.java:  146
                                   missionary.impl.Relieve$1.invoke                  Relieve.java:   86
                                   missionary.impl.Reactor$1.invoke                  Reactor.java:  480
                                      missionary.impl.Reactor.event                  Reactor.java:  398
                                  missionary.impl.Reactor.propagate                  Reactor.java:  242
                                      missionary.impl.Reactor.touch                  Reactor.java:  222
                                       missionary.impl.Reactor.pull                  Reactor.java:  183
                               missionary.impl.Sample$Process.deref                   Sample.java:   34
                                    missionary.impl.Sample.transfer                   Sample.java:   84
                                         missionary.impl.Util.apply                     Util.java:   29
                                            clojure.core/partial/fn                      core.clj: 2648
                                                clojure.core/reduce                      core.clj: 6899
                                                                ...                                    
                      hyperfiddle.electric.impl.runtime/parse-event                  runtime.cljc:  771
                                                clojure.core/reduce                      core.clj: 6899
                                                                ...                                    
                   hyperfiddle.electric.impl.runtime/eval-tree-inst                  runtime.cljc:  736
                                                                ...                                    
 java.lang.NullPointerException: Cannot load from object array because "xs" is null

nivekuil08:09:10

this simple example does NOT repro but it's the general idea, will keep digging

(let [!x (atom nil)
          x (e/watch !x)]
      (when x (new x))
      (eui/button (e/fn [] (reset! !x (e/fn [] (ed/text "i die"))))
                  (ed/text "kill me")))

nivekuil08:09:26

note that the thunked effect (rendering the text) does go off before the reactor crashes

Dustin Getz10:09:34

is a lambda nil? do you have unserializable reference transfer warning? lambdas do not transfer today which is quite easy to accidentally cause using current semantics, you need to be aware of it

Dustin Getz10:09:40

once you confirm e/server is not in play,

Dustin Getz10:09:52

we have a nasty deep bug called “when true” bug which can cause the body of a when to see an impossible value, it is fixed in next electric but not in master, i’ll get more details for you later today from our issue tracker

Dustin Getz10:09:19

there are workarounds

nivekuil11:09:04

no e/server or serialiization errors. it's not a priority for me, actually thinking about a workaround led to a better data model: dynamic state systems don't need to handle singletons, since those are always known up front and can just be def 'd

Dustin Getz11:09:09

i checked our tracker, the "when true" bug is an interaction with Pending, so if you have no e/server then I don't see how this is connected. electric-ui4/button perhaps could emit a pending (i just checked and didn't see one but the infrastructure ui4 uses is really complicated). We can rule it out by writing a barebones dom button with only electric-dom2

Dustin Getz11:09:21

I don't understand your comment about singletones

nivekuil11:09:47

electric has reactive static state. For dynamic state you use something like datascript or an atom holding a collection, but those aren't reactive. I've been struggling on figuring out a good api for my reactive dynamic state system to deal with cardinality and it turns out I can just leave all the cardinality-1 stuff to electric

nivekuil11:09:08

anyway, my planned workaround was to put the e/fn in an atom like my simple example but it turns out that still crashes it.. will have to dig further tomorrow

Dustin Getz11:09:22

we are disaligned on terminology

nivekuil11:09:50

you can (def todos-count (atom nil)) and set that at runtime and that's as granular as you'll ever want. you can't (def all-todos) and have granular reactivity per todo

Dustin Getz11:09:44

differential electric maybe can do that? If you are ingesting batch data then someone has to diff, in Electric 2 (today) e/for does the diffing in Electric 3 diffing is built into e/watch (details still firming up) and then diffs flow through the DAG not collections

nivekuil12:09:30

i am too tired to think right now, but can you efficiently say "do this when :todo/id 1's :todo/title changes"? I was thinking differential electric is like being able to serialize flows

Dustin Getz19:09:56

i think i need a clearer example but likely yes, differential electric is insane (good insane)

Dustin Getz19:09:40

fwiw, putting e/fn in an atom is valid and reasonable and can be idiomatic, but it's also twisted enough that I am unsurprised if you find Electric evaluation bugs here

Dustin Getz19:09:19

i'm not sure if we have any actual instances of that in any of our repos currently, maybe a couple unit tests

nivekuil19:09:08

here's my extremely concise DSL

nivekuil19:09:52

this does two pathom queries on mount and reactively subscribes to a datalog store. first [::global ::session/current-user session/current-user]

nivekuil19:09:40

where session/current-user is {:user/id 1} , then the second one does [[_ :user/id 1] [_ :user/name user/name] ...

nivekuil19:09:32

right now my reactive datalog comes from odoyle, a runtime reactive network. you're telling me that electric will be able to do this at compile time?

nivekuil19:09:46

it was super cool when I realized that you don't need EQL or datalog joins at all, you can just do them in electric and you get fine grained reactive joins for free

nivekuil19:09:19

now you're telling me that https://github.com/leonoel/missionary/commit/187ff9eb630b4281d4b1eb1d33f165a0aa5535cf implements differential dataflow in <200loc? what

Dustin Getz19:09:50

good luck

👀 1
Dustin Getz19:09:29

it's a missionary plugin, it doesn't depend on missionary because missionary flows are "dependency free" you can construct missionary compatible flows out of just lambda

nivekuil19:09:39

nested incremental sequences?

nivekuil20:09:08

well if you can insert triples into a big array and efficiently get back the diffs, I guess you just need an {a {e #{flow}} index and you can transfer those diffs to the UI efficiently

nivekuil20:09:28

in fact if you have that index you can just hydrate from it too.. fuck

nivekuil11:09:46

looking at adding transit types, https://github.com/hyperfiddle/electric-examples-app/blob/main/src/wip/demo_custom_types.cljc says the client only needs read handlers?

Geoffrey Gaillard12:09:47

No, sorry for the confusion. Both client and server can implement both read and write handlers. If your use case requires the client to send custom types to the server, then you’ll need to implement client write and server read.

nivekuil12:09:09

thanks, for a moment I thought electric was really magical

🙂 1
Jordan Calderwood20:09:34

Is it possible to set css vars from electric dom?

(dom/div
  (dom/props
    {:class "slider-container"
     :style {"--num-sizes" num-sizes}}) <----- this bit here
     ...

Geoffrey Gaillard20:09:10

dom/props doesn’t support setting CSS vars yet. In the meantime, this will work:

(dom/div
  (dom/props {:class "slider-container"})
  (.setProperty (.-style dom/node) "--num-sizes" num-sizes)
  (e/on-unmount #(.removeProperty (.-style dom/node) "--num-sizes")))

Jordan Calderwood20:09:33

amazing. Thanks for the workaround!!