Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add "stream_from" functionality #91

Closed
wants to merge 13 commits into from

Conversation

joshleblanc
Copy link
Contributor

@joshleblanc joshleblanc commented Dec 30, 2020

First off, this is just a proposal. I really liked the way hotwire handled isolated subscriptions to specific pages. I found it really helped developer confidence when using cable ready inside jobs.

This is a smaller PR than it looks. It just adds 2 features

  1. Create a subscription from a given model right in the view
  2. Broadcast to that subscription

The following is a light example of how you'd go about doing this:

// index.js
import { registerController } from 'cable_ready';
import { consumer } from './consumer';

/** 
 * This function will register the stimulus controller that powers the cable subscription if and only if stimulus is installed
 * in the parent application. 
 * If stimulus is missing, it will display a message in the console, and not load the controller.
**/
registerController(consumer);

// snip
<%# _show.html.erb %>

<%# This outputs the neccessary markup for the `cable_ready_stream_from_controller` registered above.  %>
<%# It generates a stream key that's used by the controller to subscribe to the cable connection %>
<%= cable_ready_stream_from my_model %>

<div id="my-model-#{my_model.id}">
  <%= my_model.name %>
</div>
# some_job.rb
include CableReady::Broadcaster

def perform(my_model)
  ##
  # stream_to converts the identifier to the signed stream key that's used on the frontend to connect
  # to the cable. The end result is that anything broadcast with `stream_to` will only be received when a corresponding
  # `cable_ready_stream_from` exists in the frontend 
  my_model.update(name: "blah")
  cable_ready[my_model].outer_html(
    selector: "my-model-#{my-model.id}", 
    html: ApplicationController.render(partial: "my_models/show", locals: { my_model: my_model }
  )
end

Notes

stimulus dependency

I want to explicitly note that stimulus is not added as a dependency in this PR. It's added as a peer dependency, which means it's not included in the package itself.

If a user tries to register the stimulus controller without having stimulus installed, cable_ready will display a message and go on its merry way.

broadcast_to

This PR is almost a 1 to 1 copy of hotwire's stream_from implementation. They don't use broadcast_to, so I followed suit. I'm not sure how much this implementation conflicts with the existing broadcast_to methods in cable_ready.

registerController

My first run at this, I just implicitly loaded the controller on an isolated consumer. However, the previously connected cable connection seemed to disconnect. I don't know enough about actioncable to say whether that's just intended behavior, of if there's a properly way to create an isolated connection.

I'd want it to be as hands-off as possible, so if it's possible to create the subscription without passing the consumer, I'd opt for that. If anyone knows how to do it.

@julianrubisch
Copy link
Contributor

I love this! I've got numerous cases where I'd use that, stat.

Just think we should find ways not to duplicate the existing broadcast_to functionality, i.e. wrap around it more cohesively

@leastbad
Copy link
Contributor

This is cool stuff!

It's significantly simpler than what I thought you were describing originally. I like that it takes advantage of Stimulus if it's present. I think that they'd have to call registerController after they'd defined their Stimulus application, which is just a documentation concern.

To that end, I would like to bring you around to something that I will be pushing for when SR 4 comes along as well: I am strongly in favor of trying to get the Stimulus community to embrace setting

application.consumer = consumer

in their index.js so that Stimulus controllers distributed as packages can easily access the memoized ActionCable consumer without having to guess where it lives in the file system.

In the case of this PR, it would drastically simplify (and remove ambiguity from) the controller registration process because you could just tell people to register your controller in the same way that they likely register a few others, and the consumer just gets set once. This would mean we could also remove a substantial amount of complexity from SR for the same reason - there's no need to identify whether there's a consumer or not, you just access this.application.consumer and raise an error if it's not present.

In terms of whether this "belongs" in CR proper or a separate library, I could make good arguments for both and I'm curious what @hopsoft will think. I have to admit that I personally like having full control of the process described in https://cableready.stimulusreflex.com/broadcasting-to-resources#we-dont-need-react-to-do-this-anymore since very few "simple scenarios" stay truly simple for very long. I feel genuinely weird that people are so excited to use view templates to set up ActionCable channels, because these are often the same folks who complain loudly and pedantically to "keep your business logic and presentation concerns separate" to the degree that they fuss about whether it's okay for a Job to know the DOM id of a component... but suddenly view helpers launching jobs is A-OK. 😀

Regardless of what gets decided, I think that this is super cool and I'll help document it as best I can. There's a small part of me that worries that implementing features from Turbo might broadcast that we're playing catch-up, but I will work hard to make sure we're always demonstrating that our tooling was flexible enough to accomodate this all along.

@joshleblanc
Copy link
Contributor Author

joshleblanc commented Dec 31, 2020

Just think we should find ways not to duplicate the existing broadcast_to functionality, i.e. wrap around it more cohesively

Not sure we can wrap it more cohesively. broadcast_to requires setting up a channel that stream_for's a specific model or collection of models doesn't it?

The current channel just uses stream_from, and gets all of its information from the signed key. On top of that, this is also perfectly valid - no models at all:

<%= cable_ready_stream_from "the battle of troy" %>
cable_ready.stream_to("the battle of troy").console_log(message: "Hello!").broadcast

To that end, I would like to bring you around to something that I will be pushing for when SR 4 comes along as well: I am strongly in favor of trying to get the Stimulus community to embrace setting

application.consumer = consumer

I'm actually kind of surprised action cable doesn't just expose a singleton. You would think that since you want to re-use the same consumer everywhere, that would be built in.

suddenly view helpers launching jobs is A-OK.

The hotwire view helper does the same thing this PR does, except in a custom element rather than a stimulus controller. No launching of jobs involved. Or was this a hyperbole? 😄

@leastbad
Copy link
Contributor

I could have sworn that the Turbo Streams broadcasting stuff was wrapped in jobs, but that could well have been a fever dream.

I realize now that I assumed you were using stream_for but yes, stream_from makes good sense.

At the end of the day, all stream identifiers are just strings. The stream_for/broadcast_to is just sugar.

I agree that the consumer.js thing has been poorly handled. At any rate, attaching the consumer to the Stimulus application has been all green lights in the two years I've been doing it. I just need the rest of the world to follow suit so it snowballs. There's no reason not to do it even though I don't work for Basecamp. It's just a good idea even though I thought of it.

@joshleblanc
Copy link
Contributor Author

joshleblanc commented Dec 31, 2020

At the end of the day, all stream identifiers are just strings. The stream_for/broadcast_to is just sugar.

Does this mean this change is safe?

def [](identifier)
  stream_name = stream_name_from(identifier)
  @channels[stream_name] ||= CableReady::Channel.new(stream_name, operations)
end

This would do away with the stream_to method that I added out of fear I'd break something by doing that ^

@leastbad
Copy link
Contributor

I don't fully understand what I'm looking at, TBH. I just meant that under the covers, ActionCable converts a stream_for resource to a string.

I will attempt to look more carefully at the PR and figure out what you're really asking me.

@hopsoft
Copy link
Contributor

hopsoft commented Jan 2, 2021

This is great. I wonder if we should go ahead and recommend this so we can simplify things a little.

application.consumer = consumer

@joshleblanc I think you'd be safe to make that last change and remove the stream_to method given that to_param on a string simply returns the string.

@joshleblanc
Copy link
Contributor Author

I made those changes.

I also moved CableReadyChannel to CableReady::CableReadyChannel because I think it's reasonable to assume someone might already have a CableReadyChannel

@leastbad
Copy link
Contributor

leastbad commented Jan 26, 2021

Hey Josh, I finally had a chance to dig into this properly.

Could we adapt your helper to make it shorter (is stream_from off the table?) and can we do a block syntax? I'm picturing something like:

<%= stream_from my_model do |model| %>
  <%= model.name %>
<% end %>

You could always set up the method like stream_from(resource, **options) if they need to pass in extra tag attributes. I just really can't stand the notion of having empty divs floating around with Stimulus controllers on them. It seems really brittle and easy for them to get orphaned if the content element is removed from the page. The block syntax makes it work like a content area.

Next up, I suspect you're over-thinking the MessageVerifier stuff. If you look at https://cableready.stimulusreflex.com/cableready-everywhere#activerecord you'll see how I recommend folks add an sgid method to their models. I think you could just sidestep all of the messing around with identifiers by just passing an sgid to the server, which you can create without messing with signed_stream_name etc.

I saw that your approach had support for named, non-ActiveRecord model resource identifiers. I'm pretty sure that @julianrubisch figured out how to support AR and non-AR Global ID stuff for Futurism.

Following that, the big news is that I'm pretty sure the existing broadcast_to method can already address what you're trying to do, so you wouldn't even need to add the stream_to method:

cable_ready[CableReady::CableReadyChannel].console_log(message: "foo").broadcast_to(my_model)

To me, the main thrust of this PR is that you give the users a helper that creates an element with a Stimulus controller that automatically subscribes to a common CR channel that can be used to stream to arbitrary resource blocks without having to write channel subscriber code on the client or create custom channels on the server. Well, boom - almost all of the magic is on the client. Your idea is intact even if we don't change anything on the server.

Given the above, I would rename CableReady::CableReadyChannel to Stream (a small class naming sin for a sexy API) and you're basically done.

cable_ready[Stream].console_log(message: "foo").broadcast_to(my_model)

^^^ sexy ^^^

@afomera hopefully you like what you see!

@joshleblanc
Copy link
Contributor Author

I believe there's a number of misconceptions here, but I'll need to take some time to really dig into it and confirm.

Just off the top of my head

  1. We can't use a block format because it's not restricted to the block. The controller subscribes to the channel, any morphs sent down the channel will be performed, regardless of whether or not the content is inside the block
  2. All of the message verifier code was lifted from hotwire, verbatim.
  3. This is something I need to look into, but I believe broadcast_to requires a stream_for in the channel, which is intentionally omitted in the CableReadyChannel, in order to handle absolutely anything. Is that incorrect?
  4. The main thrust of the PR is to only subscribe to changes from certain pages. It's an insurance policy that when you're morphing the page, that the page actually wants to be morphed.

Again, need to dig into this more, but I have a strong feeling I'm miscommunicating something

@leastbad
Copy link
Contributor

  1. I think I understand. I guess there's still something kind of smelly to me about using a view helper in this way, but I promise I'm keeping an open mind.
  2. That's cool! I don't know if we need to lift it, is my point. My sgid one-liner does the same thing!
  3. stream_for is just a fancy stream_from that builds a string: posts:#{post.sgid}... maybe our subscribe method needs a switch to handle a few different permutations, but I'm currently "very" confident that this can be really easy.
  4. Sure, that's cool. Is what I said actually wrong, though?

@joshleblanc
Copy link
Contributor Author

Started bringin myself back up to speed.

The OP is out of date. The current equivelant of your last suggestion is actually just cable_ready[my_model].console_log(message: "foo"), with the equivelant cable_ready_stream_for my_model in your view.

I'll update the original post.

I don't really follow what you want me to change exactly. Maybe someone else can chime in, as I'm confused.

@afomera
Copy link

afomera commented Jan 26, 2021

@leastbad Some thoughts;

I personally prefer the implementation as close as Turbo/Hotwire tbh, aka the MessageVerifier stuff as @joshleblanc lifted from the source. It gives the flexibility I'm hoping for to bring to our work app, and it doesn't involve me adding a sgid method to models. Additionally, it can be used without active record as you mentioned

Maybe Futurism has it figured out to support it like you're saying, but this addition here in CR feels natural for anyone who's using Hotwire already.

It also makes it easier for me to start with Turbo, and go, oh yeah this isn't great I want the clarity that CR's methods for operations provides, bam update the stream tag (or add the new one) an then start using CR in the places I'm broadcasting from.

I actually agree re: changing the channel name the JS subscribes to... Perhaps CableReady::StreamsChannel? or CableReady::OperationsChannel?

The other thing I'll point and it's probably a minor thing, but I like the ability to use a different signed id for resources for different uses, so subscribing to updates, but if we take that signed ID you couldn't say look it up on an endpoint that may look up via signed id for another purpose.


A HUGE motivation to me for this PR is the ability to drop in the helper tag, have it subscribe for me, and then to basically do virtually the same call I'm used to cable_ready[identifier].whatnot.broadcast for things that aren't backed by a model. Being able to do it for the stuff that is backed by a model is great too, so I'd love to be able to keep it consistent.

anyway, I think I'm starting to ramble so I'll cut it off here 😅

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants