@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 +3 -1
- package/pyproject.toml +1 -1
- package/qui/__init__.py +6 -2
- package/qui/constants.py +8 -0
- package/qui/templates/manifest.json +12 -12
- package/qui/templates/qui.html +2 -2
- package/qui/templates/service-worker.js +20 -9
- package/qui/web/tornado.py +22 -1
- package/webpack/webpack-common.js +18 -2
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.
|
|
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
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"
|
package/qui/templates/qui.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
142
|
-
if (
|
|
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
|
|
package/qui/web/tornado.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
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
|
]
|