Files
penpot/frontend/src/app/config.cljs
Andrey Antukh ab4e195cca Add protection for stale cache of js assets loading issues (#8638)
*  Use update-when for update dashboard state

This make updates more consistent and reduces possible eventual
consistency issues in out of order events execution.

* 🐛 Detect stale JS modules at boot and force reload

When the browser serves cached JS files from a previous deployment
alongside a fresh index.html, code-split modules reference keyword
constants that do not exist in the stale shared.js, causing TypeError
crashes.

This adds a compile-time version tag (via goog-define / closure-defines)
that is baked into the JS bundle. At boot, it is compared against the
runtime version tag from index.html (which is always fresh due to
no-cache headers). If they differ, the app forces a hard page reload
before initializing, ensuring all JS modules come from the same build.

* 📎 Ensure consistent version across builds on github e2e test workflow

---------

Signed-off-by: Andrey Antukh <niwi@niwi.nz>
2026-03-17 15:04:06 +01:00

256 lines
8.3 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.config
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.flags :as flags]
[app.common.logging :as log]
[app.common.time :as ct]
[app.common.uri :as u]
[app.common.version :as v]
[app.util.avatars :as avatars]
[app.util.extends]
[app.util.globals :refer [global location]]
[app.util.navigator :as nav]
[app.util.object :as obj]
[app.util.storage :as sto]
[app.util.timers :as ts]
[cuerdas.core :as str]))
(set! *assert* js/goog.DEBUG)
;; --- Auxiliar Functions
(def valid-browsers
#{:chrome :firefox :safari :safari-16 :safari-17 :edge :other})
(def valid-platforms
#{:windows :linux :macos :other})
(defn- parse-browser
[]
(let [user-agent (-> (nav/get-user-agent) str/lower)
check-chrome? (fn [] (str/includes? user-agent "chrom"))
check-firefox? (fn [] (str/includes? user-agent "firefox"))
check-edge? (fn [] (str/includes? user-agent "edg"))
check-safari? (fn [] (str/includes? user-agent "safari"))
check-safari-16? (fn [] (and (check-safari?) (str/includes? user-agent "version/16")))
check-safari-17? (fn [] (and (check-safari?) (str/includes? user-agent "version/17")))]
(cond
^boolean (check-edge?) :edge
^boolean (check-chrome?) :chrome
^boolean (check-firefox?) :firefox
^boolean (check-safari-16?) :safari-16
^boolean (check-safari-17?) :safari-17
^boolean (check-safari?) :safari
:else :unknown)))
(defn- parse-platform
[]
(let [user-agent (str/lower (nav/get-user-agent))
check-windows? (fn [] (str/includes? user-agent "windows"))
check-linux? (fn [] (str/includes? user-agent "linux"))
check-macos? (fn [] (str/includes? user-agent "mac os"))]
(cond
^boolean (check-windows?) :windows
^boolean (check-linux?) :linux
^boolean (check-macos?) :macos
:else :other)))
(defn- parse-target
[global]
(if (some? (obj/get global "document"))
:browser
:webworker))
(defn- parse-flags
[global]
(let [flags (obj/get global "penpotFlags" "")
flags (sequence (map keyword) (str/words flags))]
(flags/parse flags/default flags)))
(defn- parse-version
[global]
(-> (obj/get global "penpotVersion")
(v/parse)))
(defn parse-build-date
[global]
(let [date (obj/get global "penpotBuildDate")]
(if (= date "%buildDate%")
"unknown"
date)))
;; --- Compile-time version tag
;;
;; This value is baked into the compiled JS at build time via closure-defines,
;; so it travels with the JS bundle. In contrast, `version-tag` (below) is read
;; at runtime from globalThis.penpotVersionTag which is set by the always-fresh
;; index.html. Comparing the two lets us detect when the browser has loaded
;; stale cached JS files.
(goog-define compiled-version-tag "develop")
;; --- Global Config Vars
(def default-theme "default")
(def default-language "en")
(def themes (obj/get global "penpotThemes"))
(def build-date (parse-build-date global))
(def flags (parse-flags global))
(def target (parse-target global))
(def browser (parse-browser))
(def platform (parse-platform))
(def version (parse-version global))
(def version-tag (obj/get global "penpotVersionTag"))
(defn stale-build?
"Returns true when the compiled JS was built with a different version
tag than the one present in the current index.html. This indicates
the browser has cached JS from a previous deployment."
^boolean
[]
(not= compiled-version-tag version-tag))
;; --- Throttled reload
;;
;; A generic reload mechanism with loop protection via sessionStorage.
;; Used by both the boot-time stale-build check and the runtime
;; stale-asset error handler.
(def ^:private reload-storage-key "penpot-last-reload-timestamp")
(def ^:private reload-cooldown-ms 30000)
(defn throttled-reload
"Force a hard page reload unless one was already triggered within the
last 30 seconds (tracked in sessionStorage). Returns true when a
reload is initiated, false when suppressed."
[& {:keys [reason]}]
(let [now (ct/now)
prev-ts (-> (sto/get-item sto/session-storage reload-storage-key)
(d/parse-integer))]
(if (and (some? prev-ts)
(< (- now prev-ts) reload-cooldown-ms))
(do
(log/wrn :hint "reload suppressed (cooldown active)"
:reason reason)
false)
(do
(log/wrn :hint "forcing page reload" :reason reason)
(sto/set-item sto/session-storage reload-storage-key (str now))
(ts/asap #(.reload ^js location true))
true))))
(def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI"))
(def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI"))
(def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
(def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/"))
(def plugins-list-uri (obj/get global "penpotPluginsListUri" "https://penpot.app/penpothub/plugins"))
(def plugins-whitelist (into #{} (obj/get global "penpotPluginsWhitelist" [])))
(def templates-uri (obj/get global "penpotTemplatesUri" "https://penpot.github.io/penpot-files/"))
;; We set the current parsed flags under common for make
;; it available for common code without the need to pass
;; the flags all arround on parameters.
(set! app.common.flags/*current* flags)
(defn- normalize-uri
[uri-str]
;; Ensure that the path always ends with "/"; this ensures that
;; all path join operations works as expected.
(u/ensure-path-slash uri-str))
(def public-uri
(normalize-uri (or (obj/get global "penpotPublicURI")
(obj/get location "origin"))))
(def mcp-ws-uri
(or (some-> (obj/get global "penpotMcpServerURI") u/uri)
(u/join public-uri "mcp/ws")))
(def rasterizer-uri
(or (some-> (obj/get global "penpotRasterizerURI") normalize-uri)
public-uri))
(def worker-uri
(obj/get global "penpotWorkerURI" "/js/worker/main.js"))
(defn external-feature-flag
[flag value]
(let [f (obj/get global "externalFeatureFlag")]
(when (fn? f)
(f flag value))))
(defn external-session-id
[]
(let [f (obj/get global "externalSessionId")]
(when (fn? f) (f))))
(defn external-context-info
[]
(let [f (obj/get global "externalContextInfo")]
(when (fn? f) (f))))
(defn initialize-external-context-info
[]
(let [f (obj/get global "initializeExternalConfigInfo")]
(when (fn? f) (f))))
(def mcp-server-url (-> public-uri u/ensure-path-slash (u/join "mcp/stream") str))
(def mcp-help-center-uri "https://help.penpot.app/technical-guide/")
;; --- Helper Functions
(defn ^boolean check-browser? [candidate]
(dm/assert! (contains? valid-browsers candidate))
(if (= candidate :safari)
(contains? #{:safari :safari-16 :safari-17} browser)
(= candidate browser)))
(defn ^boolean check-platform? [candidate]
(dm/assert! (contains? valid-platforms candidate))
(= candidate platform))
(defn resolve-profile-photo-url
[{:keys [photo-id fullname name color] :as profile}]
(if (nil? photo-id)
(avatars/generate {:name (or fullname name) :color color})
(dm/str (u/join public-uri "assets/by-id/" photo-id))))
(defn resolve-team-photo-url
[{:keys [photo-id name] :as team}]
(if (nil? photo-id)
(avatars/generate {:name name})
(dm/str (u/join public-uri "assets/by-id/" photo-id))))
(defn resolve-media
[id]
(dm/str (u/join public-uri "assets/by-id/" (str id))))
(defn resolve-file-media
([media]
(resolve-file-media media false))
([{:keys [id data-uri] :as media} thumbnail?]
(if data-uri
data-uri
(dm/str
(cond-> (u/join public-uri "assets/by-file-media-id/")
(true? thumbnail?) (u/join (dm/str id "/thumbnail"))
(false? thumbnail?) (u/join (dm/str id)))))))
(defn resolve-href
[resource]
(let [href (-> public-uri
(u/ensure-path-slash)
(u/join resource)
(get :path))]
(str href "?version=" version-tag)))