hotwire-spark 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +49 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/javascripts/hotwire_spark.js +3705 -0
  6. data/app/assets/javascripts/hotwire_spark.js.map +1 -0
  7. data/app/assets/javascripts/hotwire_spark.min.js +2 -0
  8. data/app/assets/javascripts/hotwire_spark.min.js.map +1 -0
  9. data/app/assets/stylesheets/hotwire_spark/application.css +15 -0
  10. data/app/channels/hotwire/spark/channel.rb +5 -0
  11. data/app/javascript/hotwire/spark/channels/consumer.js +3 -0
  12. data/app/javascript/hotwire/spark/channels/monitoring_channel.js +47 -0
  13. data/app/javascript/hotwire/spark/helpers.js +37 -0
  14. data/app/javascript/hotwire/spark/index.js +14 -0
  15. data/app/javascript/hotwire/spark/logger.js +8 -0
  16. data/app/javascript/hotwire/spark/reloaders/css_reloader.js +65 -0
  17. data/app/javascript/hotwire/spark/reloaders/html_reloader.js +31 -0
  18. data/app/javascript/hotwire/spark/reloaders/stimulus_reloader.js +76 -0
  19. data/config/routes.rb +2 -0
  20. data/lib/hotwire/spark/action_cable/persistent_cable_middleware.rb +43 -0
  21. data/lib/hotwire/spark/action_cable/persistent_cable_server.rb +25 -0
  22. data/lib/hotwire/spark/action_cable/solid_cable_listener_with_safe_reloads.rb +8 -0
  23. data/lib/hotwire/spark/engine.rb +25 -0
  24. data/lib/hotwire/spark/file_watcher.rb +40 -0
  25. data/lib/hotwire/spark/installer.rb +52 -0
  26. data/lib/hotwire/spark/middleware.rb +55 -0
  27. data/lib/hotwire/spark/version.rb +5 -0
  28. data/lib/hotwire-spark.rb +26 -0
  29. data/lib/tasks/hotwire_spark_tasks.rake +4 -0
  30. metadata +173 -0
@@ -0,0 +1,3 @@
1
+ import { createConsumer } from "@rails/actioncable"
2
+
3
+ export default createConsumer()
@@ -0,0 +1,47 @@
1
+ import consumer from "./consumer"
2
+ import { assetNameFromPath } from "../helpers.js";
3
+ import { HtmlReloader } from "../reloaders/html_reloader.js";
4
+ import { CssReloader } from "../reloaders/css_reloader.js";
5
+ import { StimulusReloader } from "../reloaders/stimulus_reloader.js";
6
+
7
+ consumer.subscriptions.create({ channel: "Hotwire::Spark::Channel" }, {
8
+ connected() {
9
+ document.body.setAttribute("data-hotwire-spark-ready", "")
10
+ },
11
+
12
+ async received(message) {
13
+ try {
14
+ await this.dispatch(message)
15
+ } catch(error) {
16
+ console.log(`Error on ${message.action}`, error)
17
+ }
18
+ },
19
+
20
+ dispatch({ action, path }) {
21
+ const fileName = assetNameFromPath(path)
22
+
23
+ switch(action) {
24
+ case "reload_html":
25
+ return this.reloadHtml()
26
+ case "reload_css":
27
+ return this.reloadCss(fileName)
28
+ case "reload_stimulus":
29
+ return this.reloadStimulus(fileName)
30
+ default:
31
+ throw new Error(`Unknown action: ${action}`)
32
+ }
33
+ },
34
+
35
+ reloadHtml() {
36
+ return HtmlReloader.reload()
37
+ },
38
+
39
+ reloadCss(fileName) {
40
+ return CssReloader.reload(new RegExp(fileName))
41
+ },
42
+
43
+ reloadStimulus(fileName) {
44
+ return StimulusReloader.reload(new RegExp(fileName))
45
+ }
46
+ })
47
+
@@ -0,0 +1,37 @@
1
+ export function assetNameFromPath(path) {
2
+ return path.split("/").pop().split(".")[0]
3
+ }
4
+
5
+ export function pathWithoutAssetDigest(path) {
6
+ return path.replace(/-[a-z0-9]+\.(\w+)(\?.*)?$/, ".$1")
7
+ }
8
+
9
+ export function urlWithParams(urlString, params) {
10
+ const url = new URL(urlString, window.location.origin)
11
+ Object.entries(params).forEach(([ key, value ]) => {
12
+ url.searchParams.set(key, value)
13
+ })
14
+ return url.toString()
15
+ }
16
+
17
+ export function cacheBustedUrl(urlString) {
18
+ return urlWithParams(urlString, { reload: Date.now() })
19
+ }
20
+
21
+ export async function reloadHtmlDocument() {
22
+ let currentUrl = cacheBustedUrl(urlWithParams(window.location.href, { hotwire_spark: "true" }))
23
+ const response = await fetch(currentUrl)
24
+
25
+ if (!response.ok) {
26
+ throw new Error(`${response.status} when fetching ${currentUrl}`)
27
+ }
28
+
29
+ const fetchedHTML = await response.text()
30
+ const parser = new DOMParser()
31
+ return parser.parseFromString(fetchedHTML, "text/html")
32
+ }
33
+
34
+ export function getConfigurationProperty(name) {
35
+ return document.querySelector(`meta[name="hotwire-spark:${name}"]`)?.content
36
+ }
37
+
@@ -0,0 +1,14 @@
1
+ import "./channels/monitoring_channel.js"
2
+ import { getConfigurationProperty } from "./helpers.js";
3
+
4
+ const HotwireSpark = {
5
+ config: {
6
+ loggingEnabled: false
7
+ }
8
+ }
9
+
10
+ document.addEventListener("DOMContentLoaded", function() {
11
+ HotwireSpark.config.loggingEnabled = getConfigurationProperty("logging");
12
+ })
13
+
14
+ export default HotwireSpark
@@ -0,0 +1,8 @@
1
+ import HotwireSpark from "./index.js"
2
+
3
+ export function log(...args) {
4
+ if (HotwireSpark.config.loggingEnabled) {
5
+ console.log(`[hotwire_spark]`, ...args)
6
+ }
7
+ }
8
+
@@ -0,0 +1,65 @@
1
+ import { log } from "../logger.js"
2
+ import { cacheBustedUrl, reloadHtmlDocument, pathWithoutAssetDigest } from "../helpers.js"
3
+
4
+ export class CssReloader {
5
+ static async reload(...params) {
6
+ return new CssReloader(...params).reload()
7
+ }
8
+
9
+ constructor(filePattern = /./) {
10
+ this.filePattern = filePattern
11
+ }
12
+
13
+ async reload() {
14
+ log("Reload css...")
15
+ await Promise.all(await this.#reloadAllLinks())
16
+ }
17
+
18
+ async #reloadAllLinks() {
19
+ const cssLinks = await this.#loadNewCssLinks();
20
+ return cssLinks.map(link => this.#reloadLinkIfNeeded(link))
21
+ }
22
+
23
+ async #loadNewCssLinks() {
24
+ const reloadedDocument = await reloadHtmlDocument()
25
+ return Array.from(reloadedDocument.head.querySelectorAll("link[rel='stylesheet']"))
26
+ }
27
+
28
+ #reloadLinkIfNeeded(link) {
29
+ if (this.#shouldReloadLink(link)) {
30
+ return this.#reloadLink(link)
31
+ } else {
32
+ return Promise.resolve()
33
+ }
34
+ }
35
+
36
+ #shouldReloadLink(link) {
37
+ return this.filePattern.test(link.getAttribute("href"))
38
+ }
39
+
40
+ async #reloadLink(link) {
41
+ return new Promise(resolve => {
42
+ const href = link.getAttribute("href")
43
+ const newLink = this.#findExistingLinkFor(link) || this.#appendNewLink(link)
44
+
45
+ newLink.setAttribute("href", cacheBustedUrl(link.getAttribute("href")))
46
+ newLink.onload = () => {
47
+ log(`\t${href}`)
48
+ resolve()
49
+ }
50
+ })
51
+ }
52
+
53
+ #findExistingLinkFor(link) {
54
+ return this.#cssLinks.find(newLink => pathWithoutAssetDigest(link.href) === pathWithoutAssetDigest(newLink.href))
55
+ }
56
+
57
+ get #cssLinks() {
58
+ return Array.from(document.querySelectorAll("link[rel='stylesheet']"))
59
+ }
60
+
61
+ #appendNewLink(link) {
62
+ document.head.append(link)
63
+ return link
64
+ }
65
+ }
@@ -0,0 +1,31 @@
1
+ import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js"
2
+ import { log } from "../logger.js"
3
+ import { reloadHtmlDocument } from "../helpers.js"
4
+ import { StimulusReloader } from "./stimulus_reloader.js"
5
+
6
+ export class HtmlReloader {
7
+ static async reload() {
8
+ return new HtmlReloader().reload()
9
+ }
10
+
11
+ async reload() {
12
+ const reloadedDocument = await this.#reloadHtml()
13
+ await this.#reloadStimulus(reloadedDocument)
14
+ }
15
+
16
+ async #reloadHtml() {
17
+ log("Reload html...")
18
+
19
+ const reloadedDocument = await reloadHtmlDocument()
20
+ this.#updateBody(reloadedDocument.body)
21
+ return reloadedDocument
22
+ }
23
+
24
+ #updateBody(newBody) {
25
+ Idiomorph.morph(document.body, newBody)
26
+ }
27
+
28
+ async #reloadStimulus(reloadedDocument) {
29
+ return new StimulusReloader(reloadedDocument).reload()
30
+ }
31
+ }
@@ -0,0 +1,76 @@
1
+ import { Application } from "@hotwired/stimulus"
2
+ import { log } from "../logger.js"
3
+ import { cacheBustedUrl, reloadHtmlDocument } from "../helpers.js"
4
+
5
+ export class StimulusReloader {
6
+ static async reload(filePattern) {
7
+ const document = await reloadHtmlDocument()
8
+ return new StimulusReloader(document, filePattern).reload()
9
+ }
10
+
11
+ constructor(document, filePattern = /./) {
12
+ this.document = document
13
+ this.filePattern = filePattern
14
+ this.application = window.Stimulus || Application.start()
15
+ }
16
+
17
+ async reload() {
18
+ log("Reload Stimulus controllers...")
19
+
20
+ this.application.stop()
21
+ await this.#reloadStimulusControllers()
22
+ this.application.start()
23
+ }
24
+
25
+ async #reloadStimulusControllers() {
26
+ await Promise.all(
27
+ this.#stimulusControllerPaths.map(async moduleName => this.#reloadStimulusController(moduleName))
28
+ )
29
+ }
30
+
31
+ get #stimulusControllerPaths() {
32
+ return Object.keys(this.#stimulusPathsByModule).filter(path => path.endsWith("_controller") && this.#shouldReloadController(path))
33
+ }
34
+
35
+ #shouldReloadController(path) {
36
+ return this.filePattern.test(path)
37
+ }
38
+
39
+ get #stimulusPathsByModule() {
40
+ this.pathsByModule = this.pathsByModule || this.#parseImportmapJson()
41
+ return this.pathsByModule
42
+ }
43
+
44
+ #parseImportmapJson() {
45
+ const importmapScript = this.document.querySelector("script[type=importmap]")
46
+ return JSON.parse(importmapScript.text).imports
47
+ }
48
+
49
+ async #reloadStimulusController(moduleName) {
50
+ log(`\t${moduleName}`)
51
+
52
+ const controllerName = this.#extractControllerName(moduleName)
53
+ const path = cacheBustedUrl(this.#pathForModuleName(moduleName))
54
+
55
+ const module = await import(path)
56
+
57
+ this.#registerController(controllerName, module)
58
+ }
59
+
60
+ #pathForModuleName(moduleName) {
61
+ return this.#stimulusPathsByModule[moduleName]
62
+ }
63
+
64
+ #extractControllerName(path) {
65
+ return path
66
+ .replace(/^.*\//, "")
67
+ .replace("_controller", "")
68
+ .replace(/\//g, "--")
69
+ .replace(/_/g, "-")
70
+ }
71
+
72
+ #registerController(name, module) {
73
+ this.application.unload(name)
74
+ this.application.register(name, module.default)
75
+ }
76
+ }
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ Hotwire::Spark::Engine.routes.draw do
2
+ end
@@ -0,0 +1,43 @@
1
+ class Hotwire::Spark::ActionCable::PersistentCableMiddleware
2
+ def initialize(app)
3
+ @app = app
4
+ end
5
+
6
+ def call(env)
7
+ request = Rack::Request.new(env)
8
+
9
+ if supress_action_cable_restarts?(request)
10
+ respond_suppressing_action_cable_restarts(env)
11
+ else
12
+ @app.call(env)
13
+ end
14
+ end
15
+
16
+ private
17
+ COOKIE_NAME = "hotwire_spark_disable_cable_restarts"
18
+ RESTARTS_SUPPRESSED_GRACE_PERIOD = 10.seconds
19
+
20
+ def supress_action_cable_restarts?(request)
21
+ request.params["hotwire_spark"] || request.cookies[COOKIE_NAME]
22
+ end
23
+
24
+ def respond_suppressing_action_cable_restarts(env)
25
+ status, headers, body = suppressing_action_cable_restarts { @app.call(env) }
26
+ headers["Set-Cookie"] = append_cookie_to_disable_cable_restarts(headers["Set-Cookie"])
27
+
28
+ [ status, headers, body ]
29
+ end
30
+
31
+ def suppressing_action_cable_restarts(&block)
32
+ ActionCable.server.without_restarting(&block)
33
+ end
34
+
35
+ def append_cookie_to_disable_cable_restarts(existing_cookies)
36
+ [ existing_cookies, cookie_to_disable_cable_restarts ].compact
37
+ end
38
+
39
+ def cookie_to_disable_cable_restarts
40
+ expiration = RESTARTS_SUPPRESSED_GRACE_PERIOD.from_now.utc
41
+ "#{COOKIE_NAME}=true; Path=/; Expires=#{expiration.httpdate}; HttpOnly"
42
+ end
43
+ end
@@ -0,0 +1,25 @@
1
+ module Hotwire::Spark::ActionCable::PersistentCableServer
2
+ def self.prepended(base)
3
+ base.class_eval do
4
+ thread_mattr_accessor :suppress_restarts
5
+ end
6
+ end
7
+
8
+ def restart
9
+ return if restarts_suppressed?
10
+
11
+ super
12
+ end
13
+
14
+ def without_restarting
15
+ old_suppress_restarts, self.suppress_restarts = self.suppress_restarts, true
16
+ yield
17
+ ensure
18
+ self.suppress_restarts = old_suppress_restarts
19
+ end
20
+
21
+ private
22
+ def restarts_suppressed?
23
+ suppress_restarts
24
+ end
25
+ end
@@ -0,0 +1,8 @@
1
+ module Hotwire::Spark::ActionCable::SolidCableListenerWithSafeReloads
2
+ private
3
+ def broadcast_messages
4
+ Rails.application.executor.wrap do
5
+ super
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,25 @@
1
+ require "action_cable/server/base"
2
+
3
+ module Hotwire::Spark
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Hotwire::Spark
6
+
7
+ config.hotwire = ActiveSupport::OrderedOptions.new unless config.respond_to?(:hotwire)
8
+ config.hotwire.spark = ActiveSupport::OrderedOptions.new
9
+ config.hotwire.spark.merge! \
10
+ enabled: Rails.env.development?,
11
+ css_paths: %w[ app/assets/stylesheets ],
12
+ html_paths: %w[ app/controllers app/helpers app/models app/views ],
13
+ stimulus_paths: %w[ app/javascript/controllers ]
14
+
15
+ initializer "hotwire_spark.config" do |app|
16
+ config.hotwire.spark.each do |key, value|
17
+ Hotwire::Spark.send("#{key}=", value)
18
+ end
19
+ end
20
+
21
+ initializer "hotwire_spark.install" do |application|
22
+ Hotwire::Spark.install_into application if Hotwire::Spark.enabled?
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,40 @@
1
+ require "listen"
2
+
3
+ class Hotwire::Spark::FileWatcher
4
+ def initialize
5
+ @callbacks_by_path = Hash.new { |hash, key| hash[key] = [] }
6
+ end
7
+
8
+ def monitor(paths, &callback)
9
+ Array(paths).each do |path|
10
+ @callbacks_by_path[expand_path(path)] << callback
11
+ end
12
+ end
13
+
14
+ def start
15
+ listener = Listen.to(*paths) do |modified, added, removed|
16
+ process_changed_files modified + added + removed
17
+ end
18
+
19
+ listener.start
20
+ end
21
+
22
+ private
23
+ def expand_path(path)
24
+ Rails.application.root.join(path)
25
+ end
26
+
27
+ def paths
28
+ @callbacks_by_path.keys
29
+ end
30
+
31
+ def process_changed_files(changed_files)
32
+ changed_files.each do |file|
33
+ @callbacks_by_path.each do |path, callbacks|
34
+ if file.to_s.start_with?(path.to_s)
35
+ callbacks.each { |callback| callback.call(file) }
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,52 @@
1
+ class Hotwire::Spark::Installer
2
+ attr_reader :file_watcher
3
+
4
+ def initialize(application)
5
+ @application = application
6
+ end
7
+
8
+ def install
9
+ configure_middleware
10
+ monitor_paths
11
+ end
12
+
13
+ def configure_middleware
14
+ ::ActionCable::Server::Base.prepend(Hotwire::Spark::ActionCable::PersistentCableServer)
15
+
16
+ middleware.insert_before ActionDispatch::Executor, Hotwire::Spark::ActionCable::PersistentCableMiddleware
17
+ middleware.use Hotwire::Spark::Middleware
18
+ end
19
+
20
+ private
21
+ attr_reader :application
22
+ delegate :middleware, to: :application
23
+
24
+ def monitor_paths
25
+ register_monitored_paths
26
+ file_watcher.start
27
+ end
28
+
29
+ def register_monitored_paths
30
+ monitor :css_paths, action: :reload_css
31
+ monitor :html_paths, action: :reload_html
32
+ monitor :stimulus_paths, action: :reload_stimulus
33
+ end
34
+
35
+ def monitor(paths_name, action:)
36
+ file_watcher.monitor Hotwire::Spark.public_send(paths_name) do |file_path|
37
+ broadcast_reload_action(action, file_path)
38
+ end
39
+ end
40
+
41
+ def broadcast_reload_action(action, file_path)
42
+ ActionCable.server.broadcast "hotwire_spark", reload_message_for(action, file_path)
43
+ end
44
+
45
+ def reload_message_for(action, file_path)
46
+ { action: action, path: file_path }
47
+ end
48
+
49
+ def file_watcher
50
+ @file_watches ||= Hotwire::Spark::FileWatcher.new
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ class Hotwire::Spark::Middleware
2
+ def initialize(app)
3
+ @app = app
4
+ end
5
+
6
+ def call(env)
7
+ status, headers, response = @app.call(env)
8
+
9
+ if html_response?(headers)
10
+ html = html_from(response)
11
+ html = inject_javascript(html)
12
+ html = inject_options(html)
13
+ headers["Content-Length"] = html.bytesize.to_s if html
14
+ response = [ html ]
15
+ end
16
+
17
+ [ status, headers, response ]
18
+ end
19
+
20
+ private
21
+ def html_response?(headers)
22
+ headers["Content-Type"]&.include?("text/html")
23
+ end
24
+
25
+ def html_from(response)
26
+ response_body = []
27
+ response.each { |part| response_body << part }
28
+ response_body.join
29
+ end
30
+
31
+ def inject_javascript(html)
32
+ html.sub("</head>", "#{script_tag}</head>")
33
+ end
34
+
35
+ def script_tag
36
+ script_path = view_helpers.asset_path("hotwire_spark.js")
37
+ view_helpers.javascript_include_tag(script_path)
38
+ end
39
+
40
+ def view_helpers
41
+ ActionController::Base.helpers
42
+ end
43
+
44
+ def inject_options(html)
45
+ if Hotwire::Spark.logging
46
+ html.sub("</head>", "#{logging_option}</head>")
47
+ else
48
+ html
49
+ end
50
+ end
51
+
52
+ def logging_option
53
+ view_helpers.tag.meta(name: "hotwire-spark:logging", content: "true")
54
+ end
55
+ end
@@ -0,0 +1,5 @@
1
+ module Hotwire
2
+ module Spark
3
+ VERSION = "0.1.2"
4
+ end
5
+ end
@@ -0,0 +1,26 @@
1
+ require "hotwire/spark/version"
2
+ require "hotwire/spark/engine"
3
+
4
+ require "zeitwerk"
5
+ loader = Zeitwerk::Loader.for_gem(warn_on_extra_files: false)
6
+ loader.ignore("#{__dir__}/hotwire-spark.rb")
7
+ loader.setup
8
+
9
+ module Hotwire::Spark
10
+ mattr_accessor :css_paths, default: []
11
+ mattr_accessor :html_paths, default: []
12
+ mattr_accessor :stimulus_paths, default: []
13
+ mattr_accessor :logging, default: false
14
+
15
+ mattr_accessor :enabled, default: Rails.env.development?
16
+
17
+ class << self
18
+ def install_into(application)
19
+ Installer.new(application).install
20
+ end
21
+
22
+ def enabled?
23
+ enabled
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :hotwire_spark do
3
+ # # Task goes here
4
+ # end