Skip to content

Commit 1437a35

Browse files
authored
Hotswap resources (#11)
1. Added hot swapping of images and stylesheets without a hard refresh of the server Non-user facing: 2. Implemented passing messages with payloads to the web client to allow for targeted client updates 3. Debounce hard refreshes and hot swaps 4. Replace the html injection file with a javascript file for future upkeep
1 parent 431cb8c commit 1437a35

File tree

18 files changed

+423
-74
lines changed

18 files changed

+423
-74
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,6 @@ rsconnect/
5151
# debug files
5252
.fuse_hidden*
5353
docs
54+
55+
56+
.DS_STORE

DESCRIPTION

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Package: hotwater
22
Title: Live Reload for Plumber APIs
3-
Version: 0.0.0.9002
3+
Version: 0.0.0.9003
44
Authors@R:
55
person("Elian", "Thiele-Evans", , "elianhte@gmail.com", role = c("aut", "cre"),
66
comment = c(ORCID = "0000-0001-8008-3165"))
@@ -16,7 +16,8 @@ Imports:
1616
mirai,
1717
plumber (>= 0.4.0),
1818
utils,
19-
stats
19+
stats,
20+
jsonlite
2021
Suggests:
2122
box,
2223
docopt,

R/cli.R

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ cli_file_changed <- function(changes) {
1616
cli::cli_alert("{.file {changes}} changed!")
1717
}
1818

19+
cli_hot_swapped <- function(changes) {
20+
n <- length(changes)
21+
cli::cli_inform("Hot swapped assets ({n} file{?s}): {.file {changes}}")
22+
}
23+
1924
cli_server_start_progress <- function(engine) {
2025
cli::cli_progress_step(
2126
msg = "Starting plumber server on {.url {engine$config$host}:{engine$config$port}}",

R/config.R

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,34 @@ new_config <- function(...) {
1313
ignore <- dots$ignore %||%
1414
utils::glob2rx(
1515
paste(
16-
c("*.sqlite", "*.git*"),
16+
c(
17+
# dbs
18+
"*.sqlite",
19+
"*.sqlite3",
20+
"*.db",
21+
"*.db-journal",
22+
"*.db-wal",
23+
"*.db-shm",
24+
25+
#os
26+
".DS_Store",
27+
"Thumbs.db",
28+
29+
# git
30+
"*.git*",
31+
".git/*",
32+
".gitignore",
33+
".gitmodules",
34+
35+
# R
36+
37+
".Rhistory",
38+
".RData",
39+
".Ruserdata",
40+
".Rproj.user/*",
41+
42+
"*/.*"
43+
),
1744
collapse = "|"
1845
)
1946
)

R/engine.R

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,57 @@ new_engine <- function(config) {
3232
eng
3333
}
3434

35+
hot_swappable <- c(
36+
"css",
37+
"png",
38+
"jpg",
39+
"jpeg",
40+
"gif",
41+
"svg",
42+
"webp",
43+
"ico",
44+
"avif"
45+
)
46+
3547
run_engine <- function(engine) {
48+
restart_pending <- FALSE
49+
restart_due_at <- NULL
50+
pending_restart_changes <- character()
51+
restart_ms <- 300L
52+
53+
hotswap_pending <- FALSE
54+
hotswap_due_at <- NULL
55+
pending_hotswap_changes <- character()
56+
hotswap_ms <- 120L
57+
58+
3659
callback <- function(changes) {
37-
cli_file_changed(changes)
38-
teardown_engine(engine)
39-
buildup_engine(engine)
60+
61+
changed_files <- unique(unlist(changes, use.names = FALSE))
62+
63+
exts <- tolower(tools::file_ext(changed_files))
64+
65+
is_hot_swappable <- length(exts) > 0L &&
66+
all(exts %in% hot_swappable)
67+
68+
69+
70+
if (is_hot_swappable) {
71+
hotswap_pending <<- TRUE
72+
pending_hotswap_changes <<- unique(c(
73+
pending_hotswap_changes,
74+
changed_files
75+
))
76+
hotswap_due_at <<- Sys.time() + hotswap_ms / 1000
77+
78+
} else {
79+
restart_pending <<- TRUE
80+
pending_restart_changes <<- unique(c(
81+
pending_restart_changes,
82+
changed_files
83+
))
84+
restart_due_at <<- Sys.time() + restart_ms / 1000
85+
}
4086
}
4187
on.exit({
4288
teardown_engine(engine)
@@ -57,11 +103,52 @@ run_engine <- function(engine) {
57103
repeat {
58104
Sys.sleep(0.05) # todo, allow this to be configured at some point
59105
drain_runner_log(engine)
106+
107+
if (
108+
!isTRUE(restart_pending) &&
109+
isTRUE(hotswap_pending) &&
110+
Sys.time() >= hotswap_due_at
111+
) {
112+
json <- jsonlite::toJSON(
113+
list(
114+
type = "HW::resource",
115+
targets = list(pending_hotswap_changes)
116+
),
117+
auto_unbox = TRUE
118+
)
119+
nanonext::send(
120+
engine$publisher,
121+
json,
122+
mode = "raw"
123+
)
124+
cli_hot_swapped(pending_hotswap_changes)
125+
126+
hotswap_pending <- FALSE
127+
hotswap_due_at <- NULL
128+
pending_hotswap_changes <- character()
129+
}
130+
131+
if (isTRUE(restart_pending) && Sys.time() >= restart_due_at) {
132+
cli_file_changed(pending_restart_changes)
133+
restart_pending <- FALSE
134+
restart_due_at <- NULL
135+
pending_restart_changes <- character()
136+
137+
hotswap_pending <- FALSE
138+
hotswap_due_at <- NULL
139+
pending_hotswap_changes <- character()
140+
141+
142+
teardown_engine(engine)
143+
buildup_engine(engine)
144+
}
145+
60146
current_state <- watch_directory(
61147
engine,
62148
current_state,
63149
callback
64150
)
151+
65152
}
66153
}
67154

@@ -87,13 +174,13 @@ buildup_engine <- function(engine) {
87174

88175
if (!res) {
89176
cli::cli_progress_done(result = "failed")
177+
stop("Failed to start Plumber server.")
90178
} else {
91179
publish_browser_reload(engine)
92180
cli::cli_progress_done()
181+
cli_watching_directory(engine)
182+
drain_runner_log(engine)
93183
}
94-
95-
cli_watching_directory(engine)
96-
drain_runner_log(engine)
97184
}
98185

99186
teardown_engine <- function(engine) {
@@ -144,13 +231,38 @@ drain_runner_log <- function(engine) {
144231
engine$logpos <- size
145232

146233
if (nzchar(data)) {
234+
if (grepl("=== HOTWATER_ERROR_BEGIN ===", data)) {
235+
msg <- sub(
236+
".*=== HOTWATER_ERROR_BEGIN ===\\s*([\\s\\S]*?)\\s*=== HOTWATER_ERROR_END ===.*",
237+
"\\1",
238+
data,
239+
perl = TRUE
240+
)
241+
msg <- trimws(msg)
242+
json <- jsonlite::toJSON(
243+
list(
244+
type = "HW::error",
245+
error = msg
246+
),
247+
auto_unbox = TRUE
248+
)
249+
250+
nanonext::send(
251+
engine$publisher,
252+
json,
253+
mode = "raw"
254+
)
255+
}
256+
147257
data <- gsub(
148258
"=== HOTWATER_ERROR_BEGIN ===\\s*([\\s\\S]*?)\\s*=== HOTWATER_ERROR_END ===",
149259
cli::col_red("\\1"),
150260
data,
151261
perl = TRUE
152262
)
153263

264+
265+
154266
data <- gsub(
155267
"=== HOTWATER_WARNING_BEGIN ===\\s*([\\s\\S]*?)\\s*=== HOTWATER_WARNING_END ===",
156268
cli::col_yellow("\\1"),

R/middleware.R

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,56 @@
33

44
injection <- function(engine) {
55
injection_lines <- readLines(
6-
system.file("middleware", "injection.html", package = "hotwater", mustWork = TRUE)
6+
system.file(
7+
"middleware",
8+
"hotwater-client.js",
9+
package = "hotwater",
10+
mustWork = TRUE
11+
)
712
)
8-
913
sprintf(
1014
paste(injection_lines, collapse = "\n"),
1115
engine$publisher$listener[[1L]]$url
1216
)
1317
}
1418

19+
1520
middleware <- function(engine) {
16-
js <- injection(engine)
21+
js <- '<script src="/__hotwater__/client.js"></script>'
22+
js_path <- injection(engine)
23+
24+
1725
hook <- postserialise_hotwater(js)
26+
pid <- Sys.getpid()
1827
function(pr) {
1928
# remove hotwater from the api spec
2029
plumber::pr_set_api_spec(pr, function(spec) {
2130
spec$paths[["/__hotwater__"]] <- NULL
31+
spec$paths[["/__hotwater__/client.js"]] <- NULL
2232
spec
2333
})
2434
# the dummy path is needed for pinging the server from hotwater
2535
plumber::pr_get(
2636
pr,
2737
"/__hotwater__",
28-
function() "running",
38+
function() pid,
2939
serializer = plumber::serializer_text(),
3040
preempt = "__first__"
3141
)
42+
plumber::pr_get(
43+
pr,
44+
"/__hotwater__/client.js",
45+
function(req, res) {
46+
res$setHeader("Cache-Control", "no-store")
47+
js_path
48+
},
49+
serializer = plumber::serializer_content_type(
50+
"application/javascript",
51+
function(val) {
52+
as.character(val)
53+
}
54+
)
55+
)
3256
plumber::pr_hook(
3357
pr,
3458
"postserialize",
@@ -51,11 +75,11 @@ postserialise_hotwater <- function(js) {
5175
}
5276

5377
publish_browser_reload <- function(engine) {
54-
# at the moment, the message itself is largely meaningless because we're faking the
55-
# protocol on the javascript side of things
56-
# may be worth getting a minimal protocol working down the line on the JS side so we can send
57-
# specific messages to the browser
58-
nanonext::send(engine$publisher, "start")
78+
json <- jsonlite::toJSON(
79+
list(type = "HW::page"),
80+
auto_unbox = TRUE
81+
)
82+
nanonext::send(engine$publisher, json, mode="raw")
5983
}
6084

6185
is_plumber_running <- function(engine) {
@@ -66,10 +90,13 @@ is_plumber_running <- function(engine) {
6690
engine$config$host,
6791
engine$config$port
6892
)
69-
res <- httr2::resp_status(
70-
httr2::req_perform(httr2::request(url))
71-
)
72-
res == 200L
93+
94+
req <- httr2::request(url)
95+
resp <- httr2::req_perform(req)
96+
status <- httr2::resp_status(resp)
97+
content <- httr2::resp_body_string(resp)
98+
99+
status == 200L && as.integer(content) == Sys.getpid()
73100
},
74101
error = function(e) {
75102
FALSE

R/mirai.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ new_runner <- function(engine) {
9696
Sys.sleep(0.1)
9797
}
9898

99-
if (i == timeout && !is_plumber_running(engine)) {
99+
if (!is_runner_alive(engine) || !is_plumber_running(engine)) {
100100
return(FALSE)
101101
}
102102

R/watcher.R

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ watch_directory <- function(engine, current_state, callback) {
55
)
66
next_state <- directory_state(paths, engine$config$ignore)
77
changed_files <- get_changed_files(current_state, next_state)
8-
if (did_files_change(changed_files)) {
8+
9+
10+
if (did_files_change(unique(unlist(changed_files)))) {
911
callback(changed_files)
1012
return(next_state)
1113
}
@@ -16,19 +18,25 @@ get_changed_files <- function(current_state, next_state) {
1618
new <- names(next_state[names(next_state) %nin% names(current_state)])
1719
removed <- names(current_state[names(current_state) %nin% names(next_state)])
1820
modified <- names(next_state[next_state %nin% current_state])
19-
unique(c(new, removed, modified))
21+
list(new = new, removed = removed, modified = modified)
2022
}
2123

22-
did_files_change <- function(changed_files) {
23-
length(changed_files) > 0L
24+
did_files_change <- function(...) {
25+
any(lengths(as.list(...))) > 0L
2426
}
2527

2628
directory_state <- function(paths, ignore_pattern) {
29+
paths <- paths[dir.exists(paths)]
30+
31+
if (length(paths) == 0L) {
32+
return(stats::setNames(numeric(0), character(0)))
33+
}
34+
2735
res <- file.info(
2836
list.files(paths, full.names = TRUE, recursive = TRUE, all.files = TRUE),
2937
extra_cols = FALSE
3038
)
3139
res <- res[grep(pattern = ignore_pattern, x = row.names(res), invert = TRUE), ]
32-
res <- res[res$size > 0L, ]
40+
res <- res[!is.na(res$size), ]
3341
stats::setNames(res$mtime, row.names(res))
3442
}

0 commit comments

Comments
 (0)