hotwire-spark 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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