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

Avoid CSP violations when reloading stimulus controllers #51

Merged
merged 1 commit into from
Dec 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 52 additions & 36 deletions app/assets/javascripts/hotwire_spark.js
Original file line number Diff line number Diff line change
Expand Up @@ -1396,8 +1396,9 @@ var HotwireSpark = (function () {
}

class StimulusReloader {
static async reload(path) {
return new StimulusReloader(path).reload();
static async reload(changedFilePath) {
const document = await reloadHtmlDocument();
return new StimulusReloader(document, changedFilePath).reload();
}
static async reloadAll() {
Stimulus.controllers.forEach(controller => {
Expand All @@ -1406,54 +1407,70 @@ var HotwireSpark = (function () {
});
return Promise.resolve();
}
constructor(changedPath) {
this.changedPath = changedPath;
constructor(document, changedFilePath) {
this.document = document;
this.changedFilePath = changedFilePath;
this.application = window.Stimulus;
}
async reload() {
log("Reload Stimulus controllers...");
this.application.stop();
try {
await this.#reloadChangedController();
} catch (error) {
if (error instanceof SourceFileNotFound) {
this.#deregisterChangedController();
} else {
console.error("Error reloading controller", error);
}
}
await this.#reloadChangedStimulusControllers();
this.#unloadDeletedStimulusControllers();
this.application.start();
}
async #reloadChangedController() {
const module = await this.#importControllerFromSource(this.changedPath);
await this.#registerController(this.#changedControllerIdentifier, module);
async #reloadChangedStimulusControllers() {
await Promise.all(this.#stimulusControllerPathsToReload.map(async moduleName => this.#reloadStimulusController(moduleName)));
}
async #importControllerFromSource(path) {
const response = await fetch(`/spark/source_files/?path=${path}`);
if (response.status === 404) {
throw new SourceFileNotFound(`Source file not found: ${path}`);
}
const sourceCode = await response.text();
const blob = new Blob([sourceCode], {
type: "application/javascript"
});
const moduleUrl = URL.createObjectURL(blob);
const module = await import(moduleUrl);
URL.revokeObjectURL(moduleUrl);
return module;
get #stimulusControllerPathsToReload() {
this.controllerPathsToReload = this.controllerPathsToReload || this.#stimulusControllerPaths.filter(path => this.#shouldReloadController(path));
return this.controllerPathsToReload;
}
get #stimulusControllerPaths() {
return Object.keys(this.#stimulusPathsByModule).filter(path => path.endsWith("_controller"));
}
#shouldReloadController(path) {
return this.#extractControllerName(path) === this.#changedControllerIdentifier;
}
get #changedControllerIdentifier() {
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedPath);
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedFilePath);
return this.changedControllerIdentifier;
}
#extractControllerName(path) {
return path.replace(/^.*\//, "").replace("_controller", "").replace(/\//g, "--").replace(/_/g, "-").replace(/\.js$/, "");
get #stimulusPathsByModule() {
this.pathsByModule = this.pathsByModule || this.#parseImportmapJson();
return this.pathsByModule;
}
#deregisterChangedController() {
this.#deregisterController(this.#changedControllerIdentifier);
#parseImportmapJson() {
const importmapScript = this.document.querySelector("script[type=importmap]");
return JSON.parse(importmapScript.text).imports;
}
async #reloadStimulusController(moduleName) {
log(`\t${moduleName}`);
const controllerName = this.#extractControllerName(moduleName);
const path = cacheBustedUrl(this.#pathForModuleName(moduleName));
const module = await import(path);
this.#registerController(controllerName, module);
}
#unloadDeletedStimulusControllers() {
this.#controllersToUnload.forEach(controller => this.#deregisterController(controller.identifier));
}
get #controllersToUnload() {
if (this.#didChangeTriggerAReload) {
return [];
} else {
return this.application.controllers.filter(controller => this.#changedControllerIdentifier === controller.identifier);
}
}
get #didChangeTriggerAReload() {
return this.#stimulusControllerPathsToReload.length > 0;
}
#pathForModuleName(moduleName) {
return this.#stimulusPathsByModule[moduleName];
}
#extractControllerName(path) {
return path.replace(/^\/+/, "").replace(/^controllers\//, "").replace("_controller", "").replace(/\//g, "--").replace(/_/g, "-").replace(/\.js$/, "");
}
#registerController(name, module) {
log("\tReloading controller", name);
this.application.unload(name);
this.application.register(name, module.default);
}
Expand All @@ -1462,7 +1479,6 @@ var HotwireSpark = (function () {
this.application.unload(name);
}
}
class SourceFileNotFound extends Error {}

class MorphHtmlReloader {
static async reload() {
Expand Down
2 changes: 1 addition & 1 deletion app/assets/javascripts/hotwire_spark.min.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion app/assets/javascripts/hotwire_spark.min.js.map

Large diffs are not rendered by default.

14 changes: 0 additions & 14 deletions app/controllers/hotwire/spark/source_files_controller.rb

This file was deleted.

106 changes: 66 additions & 40 deletions app/javascript/hotwire/spark/reloaders/stimulus_reloader.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { log } from "../logger.js"
import { cacheBustedUrl, reloadHtmlDocument } from "../helpers.js"

export class StimulusReloader {
static async reload(path) {
return new StimulusReloader(path).reload()
static async reload(changedFilePath) {
const document = await reloadHtmlDocument()
return new StimulusReloader(document, changedFilePath).reload()
}

static async reloadAll() {
Expand All @@ -14,8 +16,9 @@ export class StimulusReloader {
return Promise.resolve()
}

constructor(changedPath) {
this.changedPath = changedPath
constructor(document, changedFilePath) {
this.document = document
this.changedFilePath = changedFilePath
this.application = window.Stimulus
}

Expand All @@ -24,63 +27,88 @@ export class StimulusReloader {

this.application.stop()

try {
await this.#reloadChangedController()
}
catch(error) {
if (error instanceof SourceFileNotFound) {
this.#deregisterChangedController()
} else {
console.error("Error reloading controller", error)
}
}
await this.#reloadChangedStimulusControllers()
this.#unloadDeletedStimulusControllers()

this.application.start()
}

async #reloadChangedController() {
const module = await this.#importControllerFromSource(this.changedPath)
await this.#registerController(this.#changedControllerIdentifier, module)
async #reloadChangedStimulusControllers() {
await Promise.all(
this.#stimulusControllerPathsToReload.map(async moduleName => this.#reloadStimulusController(moduleName))
)
}

async #importControllerFromSource(path) {
const response = await fetch(`/spark/source_files/?path=${path}`)

if (response.status === 404) {
throw new SourceFileNotFound(`Source file not found: ${path}`)
}

const sourceCode = await response.text()
get #stimulusControllerPathsToReload() {
this.controllerPathsToReload = this.controllerPathsToReload || this.#stimulusControllerPaths.filter(path => this.#shouldReloadController(path))
return this.controllerPathsToReload
}

const blob = new Blob([sourceCode], { type: "application/javascript" })
const moduleUrl = URL.createObjectURL(blob)
const module = await import(moduleUrl)
URL.revokeObjectURL(moduleUrl)
get #stimulusControllerPaths() {
return Object.keys(this.#stimulusPathsByModule).filter(path => path.endsWith("_controller"))
}

return module
#shouldReloadController(path) {
return this.#extractControllerName(path) === this.#changedControllerIdentifier
}

get #changedControllerIdentifier() {
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedPath)
this.changedControllerIdentifier = this.changedControllerIdentifier || this.#extractControllerName(this.changedFilePath)
return this.changedControllerIdentifier
}

get #stimulusPathsByModule() {
this.pathsByModule = this.pathsByModule || this.#parseImportmapJson()
return this.pathsByModule
}

#parseImportmapJson() {
const importmapScript = this.document.querySelector("script[type=importmap]")
return JSON.parse(importmapScript.text).imports
}

async #reloadStimulusController(moduleName) {
log(`\t${moduleName}`)

const controllerName = this.#extractControllerName(moduleName)
const path = cacheBustedUrl(this.#pathForModuleName(moduleName))

const module = await import(path)

this.#registerController(controllerName, module)
}

#unloadDeletedStimulusControllers() {
this.#controllersToUnload.forEach(controller => this.#deregisterController(controller.identifier))
}

get #controllersToUnload() {
if (this.#didChangeTriggerAReload) {
return []
} else {
return this.application.controllers.filter(controller => this.#changedControllerIdentifier === controller.identifier)
}
}

get #didChangeTriggerAReload() {
return this.#stimulusControllerPathsToReload.length > 0
}

#pathForModuleName(moduleName) {
return this.#stimulusPathsByModule[moduleName]
}

#extractControllerName(path) {
return path
.replace(/^.*\//, "")
.replace(/^\/+/, "")
.replace(/^controllers\//, "")
.replace("_controller", "")
.replace(/\//g, "--")
.replace(/_/g, "-")
.replace(/\.js$/, "")
}

#deregisterChangedController() {
this.#deregisterController(this.#changedControllerIdentifier)
}

#registerController(name, module) {
log("\tReloading controller", name)

this.application.unload(name)
this.application.register(name, module.default)
}
Expand All @@ -90,5 +118,3 @@ export class StimulusReloader {
this.application.unload(name)
}
}

class SourceFileNotFound extends Error { }
37 changes: 37 additions & 0 deletions lib/hotwire/spark/change.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
class Hotwire::Spark::Change
attr_reader :paths, :extensions, :changed_path, :action

def initialize(paths, extensions, changed_path, action)
@paths = paths
@extensions = extensions
@changed_path = changed_path
@action = action
end

def broadcast
broadcast_reload_action if should_broadcast?
end

private
def broadcast_reload_action
Hotwire::Spark.cable_server.broadcast "hotwire_spark", reload_message
end

def reload_message
{ action: action, path: canonical_changed_path }
end

def canonical_changed_path
canonical_changed_path = changed_path
paths.each { |path| canonical_changed_path = canonical_changed_path.to_s.gsub(/^#{path}/, "") }
canonical_changed_path
end

def should_broadcast?
changed_path.to_s =~ extension_regexp
end

def extension_regexp
/#{extensions.map { |ext| "\\." + ext }.join("|")}$/
end
end
18 changes: 1 addition & 17 deletions lib/hotwire/spark/installer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ def initialize(application)

def install
configure_cable_server
configure_routes
configure_middleware
monitor_paths
end
Expand All @@ -20,12 +19,6 @@ def configure_cable_server
end
end

def configure_routes
application.routes.prepend do
mount Hotwire::Spark::Engine => "/spark", as: "hotwire_spark"
end
end

def configure_middleware
middleware.use Hotwire::Spark::Middleware
end
Expand All @@ -45,20 +38,11 @@ def monitor(paths_name, action:, extensions:)
paths = Hotwire::Spark.public_send(paths_name)
if paths.present?
file_watcher.monitor paths do |file_path|
pattern = /#{extensions.map { |ext| "\\." + ext }.join("|")}$/
broadcast_reload_action(action, file_path) if file_path.to_s =~ pattern
Hotwire::Spark::Change.new(paths, extensions, file_path, action).broadcast
end
end
end

def broadcast_reload_action(action, file_path)
Hotwire::Spark.cable_server.broadcast "hotwire_spark", reload_message_for(action, file_path)
end

def reload_message_for(action, file_path)
{ action: action, path: file_path }
end

def file_watcher
@file_watches ||= Hotwire::Spark::FileWatcher.new
end
Expand Down
Empty file.
1 change: 1 addition & 0 deletions test/helpers/files_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def edit_file(path, replace:, with:)
File.write(path, updated_content)

reload_rails_reloader
sleep 2 # Broadcasting many jobs in a row sometimes makes the test fail
end

def add_file(path, content)
Expand Down
Loading
Loading