diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ac8f7cf..5cbfac3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,33 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Web UI: ES modules and one form model (stage 1) + +- The web UI gains a native ES-module layer, loaded with + `', page) + assert tag, "base.html must load js/core/boot.js as a versioned module script" + # After every classic script, so nothing classic can depend on it at load. + assert page.index(tag.group(0)) > page.index("plugins_manager.js") + + +@pytest.mark.parametrize("module", MODULES, ids=[m.name for m in MODULES]) +def test_every_import_resolves_inside_the_module_tree(module): + source = module.read_text(encoding="utf-8") + for spec in _IMPORT.findall(source): + assert spec.startswith("./") or spec.startswith("../"), ( + f"{module.name}: {spec!r} -- no bundler, so only relative imports work") + target = (module.parent / spec).resolve() + assert target.is_file(), f"{module.name}: {spec!r} does not exist" + assert any(target.parent == d.resolve() for d in MODULE_DIRS), ( + f"{module.name}: {spec!r} leaves js/core and js/pages") + + +def _registered_pages(): + boot = (JS / "core" / "boot.js").read_text(encoding="utf-8") + return re.findall(r"registry\.register\('([\w-]+)',\s*function\(\)\s*\{\s*return import\('\.\./pages/([\w-]+)\.js'\)", + boot) + + +def test_every_registered_page_has_its_module_and_partial(): + pages = _registered_pages() + assert ("cache", "cache") in pages + for name, module in pages: + assert name == module, "a page is named after its module" + assert (JS / "pages" / f"{module}.js").is_file() + partials = [p for p in PARTIALS.glob("*.html") + if f'data-page="{name}"' in p.read_text(encoding="utf-8")] + assert len(partials) == 1, f"one partial roots page {name!r}: {partials}" + + +def test_converted_partials_carry_no_inline_script(): + for partial in PARTIALS.glob("*.html"): + text = partial.read_text(encoding="utf-8") + if "data-page=" in text: + assert " served as anything but JavaScript (the +# static/v3/js/core and js/pages modules), and X-Content-Type-Options: nosniff +# below makes them strict about classic scripts too. Pin it rather than trust +# whatever the host says. +mimetypes.add_type('text/javascript', '.js') +mimetypes.add_type('text/javascript', '.mjs') + # Create Flask app app = Flask(__name__) app.secret_key = os.urandom(24) @@ -618,6 +628,13 @@ def _apply_gzip(response, compressed): return response +def _is_unversioned_static_script(): + """A /static/ .js or .mjs request with no ``v`` (content version) parameter.""" + return (request.path.startswith('/static/') + and request.path.endswith(('.js', '.mjs')) + and 'v' not in request.args) + + # Add security headers and caching to all responses @app.after_request def add_security_headers(response): @@ -629,7 +646,14 @@ def add_security_headers(response): response.headers['X-XSS-Protection'] = '1; mode=block' # Add caching headers for static assets - if request.path.startswith(_VERSIONED_ASSET_PREFIXES): + if _is_unversioned_static_script(): + # A script requested without the ?v= content version. ES modules + # (static/v3/js/core, js/pages) import each other by plain relative + # URL, which url_for never sees, so a year-long immutable copy would + # keep running the old module after an update. Let the browser keep + # it but revalidate (a 304 when unchanged). + response.headers['Cache-Control'] = 'no-cache' + elif request.path.startswith(_VERSIONED_ASSET_PREFIXES): # Cache static assets for 1 year (with versioning via query params) response.headers['Cache-Control'] = 'public, max-age=31536000, immutable' response.headers['Expires'] = (datetime.now() + timedelta(days=365)).strftime('%a, %d %b %Y %H:%M:%S GMT') diff --git a/web_interface/static/v3/js/app-early.js b/web_interface/static/v3/js/app-early.js index afac862d..d41abf4e 100644 --- a/web_interface/static/v3/js/app-early.js +++ b/web_interface/static/v3/js/app-early.js @@ -19,8 +19,12 @@ * state_manager.js, install_manager.js, list_filter.js, * the widget bundle (web_interface/widget_bundle.py), * plugins_manager.js + * end of , type=module (deferred, runs last): js/core/boot.js -- + * window.LEDMatrix and the page registry * Tab partials arrive later through htmx; their inline scripts run on - * htmx:afterSwap (js/htmx-config.js). + * htmx:afterSwap (js/htmx-config.js). A partial converted to a page module + * (data-page root, js/pages/.js) has no inline script; the registry + * starts it (js/core/registry.js, docs/WEB_FRONTEND_ARCHITECTURE.md). * * Globals: * window.LEDEscape html / attr / jsStringAttr, the only HTML escaper diff --git a/web_interface/static/v3/js/app-shell.js b/web_interface/static/v3/js/app-shell.js index 2ab63bea..43e25ebf 100644 --- a/web_interface/static/v3/js/app-shell.js +++ b/web_interface/static/v3/js/app-shell.js @@ -21,8 +21,12 @@ * state_manager.js, install_manager.js, list_filter.js, * the widget bundle (web_interface/widget_bundle.py), * plugins_manager.js + * end of , type=module (deferred, runs last): js/core/boot.js -- + * window.LEDMatrix and the page registry * Tab partials arrive later through htmx; their inline scripts run on - * htmx:afterSwap (js/htmx-config.js). + * htmx:afterSwap (js/htmx-config.js). A partial converted to a page module + * (data-page root, js/pages/.js) has no inline script; the registry + * starts it (js/core/registry.js, docs/WEB_FRONTEND_ARCHITECTURE.md). * * Globals: * window.app the root component: activeTab, plugin tab diff --git a/web_interface/static/v3/js/core/api.js b/web_interface/static/v3/js/core/api.js new file mode 100644 index 00000000..1ea86df8 --- /dev/null +++ b/web_interface/static/v3/js/core/api.js @@ -0,0 +1,128 @@ +/* + * core/api.js -- one fetch wrapper for the interface's own JSON API. + * + * const api = createApi(); + * const body = await api.get('/api/v3/cache/list', { signal }); + * await api.post('/api/v3/cache/delete', { key }, { signal }); + * + * Every call resolves to the parsed JSON body, or rejects with an ApiError: + * error.status the HTTP status (0 when no HTTP answer arrived) + * error.body the parsed JSON body, when there was one + * error.network true when fetch() itself failed (service restarting) + * error.loginRequired true when the optional web login (#683) wants the + * user to sign in again; the page is already navigating + * to the login form, so callers should show nothing + * A body of {"status": "error"} is an error even with HTTP 200: several + * endpoints still answer that way. + * + * Login redirect. base.html wraps window.fetch before any other script runs: + * a 401 carrying X-LEDMatrix-Login sends the browser to that login page. This + * module calls window.fetch at call time (never a copy taken at import), so + * every request made here goes through that same wrapper and gets the same + * redirect. isLoginRedirect() is the wrapper's test, used here only to turn + * that answer into a quiet `loginRequired` error instead of an error message + * that would flash up while the page navigates away. + * + * An aborted request (ctx.signal from the page registry) rejects with the + * DOMException named AbortError, untouched, so callers can ignore it. + */ + +export class ApiError extends Error { + constructor(message, details = {}) { + super(message); + this.name = 'ApiError'; + this.status = details.status || 0; + this.body = details.body === undefined ? null : details.body; + this.network = !!details.network; + this.loginRequired = !!details.loginRequired; + if (details.cause !== undefined) this.cause = details.cause; + } +} + +/** True for the optional web login's "sign in again" answer (see base.html). */ +export function isLoginRedirect(response) { + if (!response || response.status !== 401 || !response.headers) return false; + const login = response.headers.get('X-LEDMatrix-Login'); + return !!login && login.charAt(0) === '/' && login.charAt(1) !== '/'; +} + +/** True for an AbortError from a cancelled request. */ +export function isAbort(error) { + return !!error && error.name === 'AbortError'; +} + +// Only this interface's own paths: "/api/...", never "//host" or a full URL. +function checkPath(url) { + if (typeof url !== 'string' || url.charAt(0) !== '/' || url.charAt(1) === '/' || + /[\\\s]/.test(url)) { + throw new TypeError('LEDMatrix.api: not a path on this server: ' + String(url)); + } + return url; +} + +/** + * @param {object} [options] + * @param {Function} [options.fetch] fetch implementation (tests); default window.fetch at call time + */ +export function createApi(options = {}) { + const doFetch = options.fetch || function(url, init) { return globalThis.fetch(url, init); }; + + async function request(method, url, opts = {}) { + checkPath(url); // a bug in the caller, not a network failure + const init = { + method: method, + headers: Object.assign({ 'Accept': 'application/json' }, opts.headers || {}), + signal: opts.signal, + }; + if (opts.json !== undefined) { + init.headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(opts.json); + } + + let response; + try { + response = await doFetch(url, init); + } catch (error) { + if (isAbort(error)) throw error; + throw new ApiError((error && error.message) || 'Network error', + { network: true, cause: error }); + } + + if (isLoginRedirect(response)) { + throw new ApiError('Signing in again', { status: 401, loginRequired: true }); + } + + let body = null; + let parseError = null; + try { + const text = await response.text(); + body = text ? JSON.parse(text) : null; + } catch (error) { + if (isAbort(error)) throw error; + parseError = error; + } + + const message = body && typeof body.message === 'string' && body.message + ? body.message : null; + if (!response.ok) { + throw new ApiError(message || ('HTTP ' + response.status), + { status: response.status, body: body, cause: parseError || undefined }); + } + if (parseError || body === null || typeof body !== 'object') { + throw new ApiError('Unreadable response from the server (HTTP ' + response.status + ')', + { status: response.status, cause: parseError || undefined }); + } + if (body.status === 'error') { + throw new ApiError(message || 'The request failed', { status: response.status, body: body }); + } + return body; + } + + return { + request: request, + get: function(url, opts) { return request('GET', url, opts); }, + post: function(url, json, opts) { return request('POST', url, Object.assign({}, opts, { json: json })); }, + put: function(url, json, opts) { return request('PUT', url, Object.assign({}, opts, { json: json })); }, + del: function(url, opts) { return request('DELETE', url, opts); }, + }; +} diff --git a/web_interface/static/v3/js/core/boot.js b/web_interface/static/v3/js/core/boot.js new file mode 100644 index 00000000..c7b97e4b --- /dev/null +++ b/web_interface/static/v3/js/core/boot.js @@ -0,0 +1,33 @@ +/* + * core/boot.js -- the entry module. base.html loads it with + * - + + + + diff --git a/web_interface/templates/v3/partials/cache.html b/web_interface/templates/v3/partials/cache.html index 08d30b04..d1debe63 100644 --- a/web_interface/templates/v3/partials/cache.html +++ b/web_interface/templates/v3/partials/cache.html @@ -1,4 +1,7 @@ -
+{# No inline script: static/v3/js/pages/cache.js runs this page. The page + registry (static/v3/js/core/registry.js) starts it when this root appears + and stops it when the partial is swapped away. #} +

Cache Management

View and manage cached API responses. Cache files help reduce API calls and improve performance.

@@ -11,7 +14,7 @@

Cache Directory

Loading...

-
@@ -54,167 +57,3 @@
-