@qtoggle/qui 1.20.0-alpha.2 → 1.20.0-alpha.4

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@qtoggle/qui",
3
3
  "description": "A JavaScript UI library with batteries included.",
4
- "version": "1.20.0-alpha.2",
4
+ "version": "1.20.0-alpha.4",
5
5
  "author": {
6
6
  "name": "Calin Crisan",
7
7
  "email": "ccrisan@gmail.com"
@@ -12,6 +12,7 @@
12
12
  "keywords": [],
13
13
  "license": "Apache-2.0",
14
14
  "dependencies": {
15
+ "@babel/runtime": "^7.29.7",
15
16
  "jquery": "^3",
16
17
  "jquery-mousewheel": "*",
17
18
  "jquery-ui": "^1",
@@ -22,6 +23,7 @@
22
23
  "@babel/core": "^7.27.0",
23
24
  "@babel/eslint-parser": "^7.27.0",
24
25
  "@babel/plugin-proposal-class-properties": "^7.18.6",
26
+ "@babel/plugin-transform-runtime": "^7.29.7",
25
27
  "@babel/preset-env": "^7.27.0",
26
28
  "babel-loader": "^8",
27
29
  "css-loader": "^3",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qui-server"
3
- version = "1.20.0-alpha.2"
3
+ version = "1.20.0-alpha.4"
4
4
  description = "A fully fledged qToggle implementation written in Python"
5
5
  authors = [
6
6
  {name = "Calin Crisan", email = "ccrisan@gmail.com"},
package/qui/__init__.py CHANGED
@@ -5,7 +5,7 @@ import secrets
5
5
 
6
6
  from typing import Any
7
7
 
8
- from . import settings
8
+ from . import constants, settings
9
9
 
10
10
 
11
11
  logger = logging.getLogger(__name__)
@@ -89,7 +89,11 @@ def configure(
89
89
  if not settings.package_name:
90
90
  settings.package_name = re.sub(r"[^a-zA-Z_0-9]", "", settings.name)
91
91
 
92
- if settings.debug:
92
+ if settings.debug or constants.NON_RELEASE_VERSION_RE.match(version):
93
+ # A long-lived cache on static assets is only safe because the build hash changes whenever the content does.
94
+ # Deriving it from a version the release pipeline never stamped would make it one constant shared by every
95
+ # build from source, freezing whatever a browser happened to fetch first. Fall back to a per-process hash, so
96
+ # that restarting the server is enough to invalidate it.
93
97
  settings.build_hash = secrets.token_hex()[:16]
94
98
 
95
99
  else:
package/qui/constants.py CHANGED
@@ -1 +1,9 @@
1
+ import re
2
+
3
+
1
4
  BASE_PREFIX_HEADER = "X-Forwarded-Path"
5
+
6
+ # Matches a version the release pipeline never stamped: an all-zero version, or the literal placeholder word. Spelled
7
+ # as a pattern rather than as plain strings on purpose -- the release tooling rewrites those exact literals wherever
8
+ # they appear in the source, which would quietly turn this into a test against the real version.
9
+ NON_RELEASE_VERSION_RE = re.compile(r"^(?:0(?:\.0)+|unknown[-_]version)$")
@@ -7,18 +7,18 @@
7
7
  {% if theme_color -%}"theme_color": "{{ theme_color }}",{%- endif %}
8
8
  {% if background_color -%}"background_color": "{{ background_color }}",{%- endif %}
9
9
  "icons": [
10
- {"src": "{{ static_url }}/img/launcher-icon-16.png", "sizes": "16x16", "type": "image/png"},
11
- {"src": "{{ static_url }}/img/launcher-icon-32.png", "sizes": "32x32", "type": "image/png"},
12
- {"src": "{{ static_url }}/img/launcher-icon-36.png", "sizes": "36x36", "type": "image/png"},
13
- {"src": "{{ static_url }}/img/launcher-icon-48.png", "sizes": "48x48", "type": "image/png"},
14
- {"src": "{{ static_url }}/img/launcher-icon-64.png", "sizes": "64x64", "type": "image/png"},
15
- {"src": "{{ static_url }}/img/launcher-icon-72.png", "sizes": "72x72", "type": "image/png"},
16
- {"src": "{{ static_url }}/img/launcher-icon-96.png", "sizes": "96x96", "type": "image/png"},
17
- {"src": "{{ static_url }}/img/launcher-icon-144.png", "sizes": "144x144", "type": "image/png"},
18
- {"src": "{{ static_url }}/img/launcher-icon-192.png", "sizes": "192x192", "type": "image/png"},
19
- {"src": "{{ static_url }}/img/launcher-icon-256.png", "sizes": "256x256", "type": "image/png"},
20
- {"src": "{{ static_url }}/img/launcher-icon-384.png", "sizes": "384x384", "type": "image/png"},
21
- {"src": "{{ static_url }}/img/launcher-icon-512.png", "sizes": "512x512", "type": "image/png"}
10
+ {"src": "{{ static_url }}/img/launcher-icon-16.png?h={{ build_hash }}", "sizes": "16x16", "type": "image/png"},
11
+ {"src": "{{ static_url }}/img/launcher-icon-32.png?h={{ build_hash }}", "sizes": "32x32", "type": "image/png"},
12
+ {"src": "{{ static_url }}/img/launcher-icon-36.png?h={{ build_hash }}", "sizes": "36x36", "type": "image/png"},
13
+ {"src": "{{ static_url }}/img/launcher-icon-48.png?h={{ build_hash }}", "sizes": "48x48", "type": "image/png"},
14
+ {"src": "{{ static_url }}/img/launcher-icon-64.png?h={{ build_hash }}", "sizes": "64x64", "type": "image/png"},
15
+ {"src": "{{ static_url }}/img/launcher-icon-72.png?h={{ build_hash }}", "sizes": "72x72", "type": "image/png"},
16
+ {"src": "{{ static_url }}/img/launcher-icon-96.png?h={{ build_hash }}", "sizes": "96x96", "type": "image/png"},
17
+ {"src": "{{ static_url }}/img/launcher-icon-144.png?h={{ build_hash }}", "sizes": "144x144", "type": "image/png"},
18
+ {"src": "{{ static_url }}/img/launcher-icon-192.png?h={{ build_hash }}", "sizes": "192x192", "type": "image/png"},
19
+ {"src": "{{ static_url }}/img/launcher-icon-256.png?h={{ build_hash }}", "sizes": "256x256", "type": "image/png"},
20
+ {"src": "{{ static_url }}/img/launcher-icon-384.png?h={{ build_hash }}", "sizes": "384x384", "type": "image/png"},
21
+ {"src": "{{ static_url }}/img/launcher-icon-512.png?h={{ build_hash }}", "sizes": "512x512", "type": "image/png"}
22
22
  ],
23
23
  "start_url": ".",
24
24
  "display": "standalone"
@@ -36,8 +36,8 @@
36
36
  background_color|urlquote }}&description={{
37
37
  description|urlquote }}&h={{ build_hash }}">
38
38
  {% endif %}
39
- <link rel="icon" href="{{ static_url }}/img/launcher-icon-32.png">
40
- <link rel="apple-touch-icon" href="{{ static_url }}/img/launcher-icon-32.png">
39
+ <link rel="icon" href="{{ static_url }}/img/launcher-icon-32.png?h={{ build_hash }}">
40
+ <link rel="apple-touch-icon" href="{{ static_url }}/img/launcher-icon-32.png?h={{ build_hash }}">
41
41
 
42
42
  {% block _meta %}
43
43
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
@@ -99,7 +99,9 @@ function sendClientMessage(message, uncontrolled = false) {
99
99
  }
100
100
 
101
101
  function shouldCacheRequest(request) {
102
- return request.url.match(cacheURLRegex) && (request.method === 'GET')
102
+ /* Match on the path alone. Every asset URL carries a ?h=<build hash> query, which an extension pattern anchored
103
+ * at the end of the string would never match. */
104
+ return new URL(request.url).pathname.match(cacheURLRegex) && (request.method === 'GET')
103
105
  }
104
106
 
105
107
  function shouldCacheResponse(response) {
@@ -113,19 +115,21 @@ self.addEventListener('activate', function (event) {
113
115
 
114
116
  function deleteCache(name) {
115
117
  logDebug(`deleting cache ${name}`)
116
- caches.delete(name)
118
+ return caches.delete(name)
117
119
  }
118
120
 
119
121
  event.waitUntil(
120
- /* Become available to all pages */
121
- self.clients.claim().then(function () {
122
- /* Clear all caches starting with our app name */
122
+ Promise.all([
123
+ /* Become available to all pages */
124
+ self.clients.claim(),
125
+ /* Clear the caches left behind by other builds -- but not the one this build is about to fill, whose
126
+ * name also starts with the app name */
123
127
  caches.keys().then(function (cacheNames) {
124
128
  return Promise.all(cacheNames
125
- .filter(name => name.startsWith(`${appName}-`))
129
+ .filter(name => name.startsWith(`${appName}-`) && name !== cacheName)
126
130
  .map(name => deleteCache(name)))
127
131
  })
128
- })
132
+ ])
129
133
  )
130
134
  })
131
135
 
@@ -138,8 +142,9 @@ self.addEventListener('fetch', function (event) {
138
142
  }
139
143
 
140
144
  let url = event.request.url
141
- /* Ensure URL has build hash */
142
- if (!url.includes('?h=') && !url.includes('&h=')) {
145
+ let requestedHash = new URL(url).searchParams.get('h')
146
+ if (requestedHash == null) {
147
+ /* Ensure URL has build hash */
143
148
  if (url.includes('?')) {
144
149
  url += `&h=${buildHash}`
145
150
  }
@@ -147,6 +152,12 @@ self.addEventListener('fetch', function (event) {
147
152
  url += `?h=${buildHash}`
148
153
  }
149
154
  }
155
+ else if (requestedHash !== buildHash) {
156
+ /* A URL left over from another build, most likely requested by a page that was already open when this worker
157
+ * took over. Send it to the network untouched rather than filling this build's cache with a key that nothing
158
+ * is going to ask for again. */
159
+ return
160
+ }
150
161
 
151
162
  let request = new Request(url, event.request)
152
163
 
@@ -103,6 +103,27 @@ class JSModuleMapperStaticFileHandler(StaticFileHandler):
103
103
  return False
104
104
 
105
105
 
106
+ class CachedStaticFileHandler(StaticFileHandler):
107
+ # A year, rather than Tornado's ten. Long enough that nothing revalidates in practice, short enough to bound the
108
+ # damage should a build ever be shipped under a build hash that has already been served.
109
+ CACHE_MAX_AGE = 365 * 24 * 3600
110
+
111
+ def get_cache_time(self, path: str, modified: Any, mime_type: str) -> int:
112
+ # Tornado grants its long max-age only to requests carrying a "v" argument, while QUI stamps every asset URL
113
+ # with "h" instead, so by default nothing here is ever cacheable. Honour "h", but only when it matches the hash
114
+ # this process is currently serving: a URL left over from an earlier build names content we can no longer
115
+ # vouch for, and must keep revalidating.
116
+ if self.get_argument("h", None) == settings.build_hash:
117
+ return self.CACHE_MAX_AGE
118
+
119
+ return super().get_cache_time(path, modified, mime_type)
120
+
121
+ def set_extra_headers(self, path: str) -> None:
122
+ # The URL names an exact build, so there is nothing to revalidate even when the user reloads the page.
123
+ if self.get_argument("h", None) == settings.build_hash:
124
+ self.set_header("Cache-Control", f"max-age={self.CACHE_MAX_AGE}, immutable")
125
+
126
+
106
127
  class RedirectFrontendHandler(RequestHandler):
107
128
  def get(self) -> None:
108
129
  base_prefix = self.request.headers.get(constants.BASE_PREFIX_HEADER, "/")
@@ -192,7 +213,7 @@ def make_routing_table() -> list[URLSpec]:
192
213
  spec_list.append(
193
214
  URLSpec(
194
215
  rf"^{static_url}/(.*)$",
195
- StaticFileHandler,
216
+ CachedStaticFileHandler,
196
217
  {"path": frontend_path},
197
218
  name="static",
198
219
  )
@@ -211,8 +211,24 @@ function makeJSRule({type, appFullPath}) {
211
211
  {
212
212
  loader: 'babel-loader',
213
213
  options: {
214
- presets: ['@babel/preset-env'],
215
- plugins: ['@babel/plugin-proposal-class-properties']
214
+ /* The entry is a <script type="module"> with no nomodule fallback, so a browser that cannot run
215
+ * ES modules never gets this far. Targeting them is the honest floor -- and it is the floor the
216
+ * markup already enforces, so it drops no browser that could previously run the app.
217
+ *
218
+ * Set here rather than in a .browserslistrc because the build compiles QUI's own sources from a
219
+ * sibling checkout outside the app's tree, where browserslist config resolution would depend on
220
+ * the working directory, and because it keeps one source of truth for both repos. */
221
+ presets: [['@babel/preset-env', {targets: {esmodules: true}}]],
222
+ plugins: [
223
+ '@babel/plugin-proposal-class-properties',
224
+ /* Import the remaining helpers from a shared runtime rather than inlining a copy of
225
+ * each into every module that needs one. useESModules defaults to false, which would pull
226
+ * the CommonJS builds of those helpers and cost webpack its tree shaking and module
227
+ * concatenation across them; "auto" takes the answer from the bundler's own declared
228
+ * capability instead of asserting it here. (Babel 8 drops the option and always
229
+ * auto-detects, so it comes out again with that upgrade.) */
230
+ ['@babel/plugin-transform-runtime', {useESModules: 'auto'}]
231
+ ]
216
232
  }
217
233
  }
218
234
  ]