diff --git a/AGENTS.md b/AGENTS.md index dca72ff..f331473 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,6 +126,12 @@ example_workflows/ # example workflows (regular workflow JSON, not API JSON) SECURITY.md states the model for users. - No new pip dependencies without an explicit decision; if truly optional, use `[project.optional-dependencies]` and fail soft at runtime. +- Frontend files import each other only with plain string literals + (`from "../shared/x.mjs"`, `import("../shared/x.mjs")`). The server adds a + version tag to those paths (`nodes/_frontend_cache.py`) so an update never + meets old copies from the browser's cache. A path built in code + (`new URL(...)`, `"../shared/" + name`) escapes the tag and loads a second, + stale copy; `tests/test_frontend_cache.py` fails on one. - Frontend JS never assigns prototype callbacks directly — use `chainCallback` from `js/shared/index.mjs`, or `chainHandler` where the return value matters (a truthy `onMouseDown` result is what stops a node diff --git a/CHANGELOG.md b/CHANGELOG.md index 5de0f9f..ebb7c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to ComfyUI-AusBoss are documented here. +## Unreleased + +- **After an update, old files in your browser can no longer break AusBoss.** + In Firefox, a page could keep running files from before an update even + after Ctrl+Shift+R, so nodes lost their preview bar, card or editor until + the browser's cache was cleared. Every update now gives the pack's files + new addresses, so the browser always fetches the new ones. Restart ComfyUI + and reload the page once; there is nothing to clear. (The server adds a + version tag to the pack's own script imports as it serves them; the files + on disk are unchanged.) + ## 2.5.1 - 2026-10-01 - **Manager search finds AusBoss by its repo name.** Searching for diff --git a/nodes/_frontend_cache.py b/nodes/_frontend_cache.py index fa8a0a8..8361e6f 100644 --- a/nodes/_frontend_cache.py +++ b/nodes/_frontend_cache.py @@ -1,10 +1,16 @@ """Keep the browser from running old AusBoss JavaScript after an update. ComfyUI tells browsers never to store .js files, but sends no cache header -for .mjs, so a browser may reuse an old copy for hours. After an update the +for .mjs, so a browser may reuse an old copy for days. After an update the new entry scripts then import functions the old copies do not have, and part -of the pack stops loading. Two small pieces fix that: +of the pack stops loading. Firefox can keep such a copy even through +Ctrl+Shift+R. Three small pieces fix that: +- every relative .mjs import in the pack's scripts is served with a version + tag (shared/index.mjs becomes shared/index.mjs?v=). The tag changes + whenever any script changes, so after an update the browser asks for + addresses it has never stored, whatever it kept from before. The files on + disk are not touched; - every .mjs of this pack is served with Cache-Control: no-cache, so the browser asks the server before reusing it (a quick "not modified" when nothing changed); @@ -12,11 +18,18 @@ of the pack stops loading. Two small pieces fix that: old copies (js/cache_guard/index.js) can refresh them in one go. """ +import hashlib +import re from pathlib import Path PACK_DIR = Path(__file__).parent.parent _registered = False +# `from "./x.mjs"`, `import "./x.mjs"` and `import("./x.mjs")`; the pack's own +# files only (a relative path), and only plain string literals. +# tests/test_frontend_cache.py fails if a script refers to a .mjs another way. +_MODULE_IMPORT = re.compile(r"""(\bfrom\s*|\bimport\s*\(?\s*)(["'])(\.{1,2}/[^"'?\s]+\.mjs)\2""") + def list_pack_modules(pack_dir=PACK_DIR): """Paths of the pack's .mjs files, relative to its js folder, with '/'.""" @@ -31,12 +44,79 @@ def is_pack_module_path(request_path, pack_name): return request_path.startswith(f"/extensions/{pack_name}/") and request_path.endswith(".mjs") -def make_revalidate_middleware(pack_name): - """aiohttp middleware: no-cache on this pack's .mjs, nothing else touched.""" +def compute_module_token(js_root): + """Short tag that changes whenever any script in the js folder does.""" + root = Path(js_root) + digest = hashlib.sha1() + for path in sorted(root.rglob("*")): + if path.suffix in (".js", ".mjs") and path.is_file(): + digest.update(path.relative_to(root).as_posix().encode("utf-8") + b"\0") + digest.update(path.read_bytes() + b"\0") + return digest.hexdigest()[:10] + + +def add_module_version(source, token): + """The script with ?v= on every relative .mjs import in it.""" + return _MODULE_IMPORT.sub(lambda m: f"{m.group(1)}{m.group(2)}{m.group(3)}?v={token}{m.group(2)}", source) + + +def make_revalidate_middleware(pack_name, js_root=None, token=None): + """aiohttp middleware for this pack's scripts, nothing else touched. + + Always: no-cache on the .mjs. With `js_root` and `token`: scripts that + import another .mjs are served with the version tag on those imports. + """ from aiohttp import web + marker = f"/extensions/{pack_name}/" + root = Path(js_root).resolve() if js_root and token else None + served = {} + + def versioned(path): + """(body, etag) for a script with imports to tag, else None.""" + stat = path.stat() + stamp = (stat.st_mtime_ns, stat.st_size) + hit = served.get(path) + if hit and hit[0] == stamp: + return hit[1] + source = path.read_text(encoding="utf-8") + tagged = add_module_version(source, token) + result = None + if tagged != source: + body = tagged.encode("utf-8") + result = (body, '"' + hashlib.sha1(body).hexdigest()[:20] + '"') + served[path] = (stamp, result) + return result + + def tagged_response(request): + """The tagged script for this request, or None to let ComfyUI serve it.""" + if root is None or request.method not in ("GET", "HEAD"): + return None + start = request.path.find(marker) + if start < 0 or not request.path.endswith((".js", ".mjs")): + return None + try: + target = (root / request.path[start + len(marker):]).resolve() + if not target.is_relative_to(root) or not target.is_file(): + return None + found = versioned(target) + except (OSError, ValueError, UnicodeDecodeError): + return None + if found is None: + return None + body, etag = found + if target.suffix == ".js": + return web.Response(body=body, content_type="text/javascript", headers={"Cache-Control": "no-store"}) + headers = {"Cache-Control": "no-cache", "ETag": etag} + if request.headers.get("If-None-Match") == etag: + return web.Response(status=304, headers=headers) + return web.Response(body=body, content_type="text/javascript", headers=headers) + @web.middleware async def revalidate_pack_modules(request, handler): + response = tagged_response(request) + if response is not None: + return response response = await handler(request) if is_pack_module_path(request.path, pack_name): response.headers["Cache-Control"] = "no-cache" @@ -46,7 +126,7 @@ def make_revalidate_middleware(pack_name): def register_frontend_cache_guard(): - """Add the no-cache header and the module list. Fail-soft, run once.""" + """Add the version tags, the no-cache header and the module list. Fail-soft, run once.""" global _registered if _registered: return @@ -60,7 +140,12 @@ def register_frontend_cache_guard(): return _registered = True try: - prompt_server.app.middlewares.append(make_revalidate_middleware(PACK_DIR.name)) + js_root = PACK_DIR / "js" + try: + token = compute_module_token(js_root) + except OSError: + token = None + prompt_server.app.middlewares.append(make_revalidate_middleware(PACK_DIR.name, js_root, token)) except Exception: pass diff --git a/tests/test_frontend_cache.py b/tests/test_frontend_cache.py index 0e6f32d..a5727eb 100644 --- a/tests/test_frontend_cache.py +++ b/tests/test_frontend_cache.py @@ -1,7 +1,9 @@ from __future__ import annotations import asyncio +import os from pathlib import Path +import re import sys import tempfile import unittest @@ -11,7 +13,14 @@ sys.path.insert(0, str(ROOT)) if "nodes" in sys.modules and not hasattr(sys.modules["nodes"], "__path__"): del sys.modules["nodes"] -from nodes._frontend_cache import is_pack_module_path, list_pack_modules, make_revalidate_middleware +from nodes._frontend_cache import ( + _MODULE_IMPORT, + add_module_version, + compute_module_token, + is_pack_module_path, + list_pack_modules, + make_revalidate_middleware, +) class PackModuleListTests(unittest.TestCase): @@ -72,5 +81,159 @@ class MiddlewareTests(unittest.TestCase): self.assertIsNone(seen["/extensions/other-pack/shared/index.mjs"]) +class ModuleVersionTests(unittest.TestCase): + def test_every_kind_of_relative_import_gets_the_tag(self): + source = ( + 'import { a } from "../shared/index.mjs";\n' + "import {b} from './b.mjs';\n" + 'export * from "./c.mjs";\n' + 'export { d } from "../shared/d.mjs";\n' + 'import "./side.mjs";\n' + 'const e = await import("../shared/e.mjs");\n' + ) + tagged = add_module_version(source, "t0") + self.assertEqual( + tagged, + 'import { a } from "../shared/index.mjs?v=t0";\n' + "import {b} from './b.mjs?v=t0';\n" + 'export * from "./c.mjs?v=t0";\n' + 'export { d } from "../shared/d.mjs?v=t0";\n' + 'import "./side.mjs?v=t0";\n' + 'const e = await import("../shared/e.mjs?v=t0");\n', + ) + + def test_everything_else_is_left_alone(self): + source = ( + 'import { app } from "/scripts/app.js";\n' + 'import { x } from "./plain.js";\n' + 'import { y } from "./old.mjs?v=1";\n' + 'import { z } from "https://example.com/z.mjs";\n' + 'const url = base + "../shared/index.mjs";\n' + ) + self.assertEqual(add_module_version(source, "t0"), source) + + def test_tagging_twice_changes_nothing_more(self): + once = add_module_version('import { a } from "./a.mjs";', "t0") + self.assertEqual(add_module_version(once, "t0"), once) + + def test_the_tag_follows_the_scripts(self): + with tempfile.TemporaryDirectory() as folder: + js = Path(folder) + (js / "a.mjs").write_text("export const a = 1;") + (js / "readme.md").write_text("one") + first = compute_module_token(js) + self.assertEqual(compute_module_token(js), first) + (js / "readme.md").write_text("two") + self.assertEqual(compute_module_token(js), first) + (js / "a.mjs").write_text("export const a = 2;") + self.assertNotEqual(compute_module_token(js), first) + + def test_real_pack_scripts_refer_to_modules_only_in_ways_the_server_tags(self): + # A script that names a .mjs some other way (built from pieces, + # new URL(...)) would load a second, untagged copy of it. + quoted = re.compile(r"""["'][^"'\n]*\.mjs["']""") + loose = [] + for path in sorted((ROOT / "js").rglob("*")): + if path.suffix not in (".js", ".mjs") or not path.is_file(): + continue + source = path.read_text(encoding="utf-8") + if len(quoted.findall(source)) != len(_MODULE_IMPORT.findall(source)): + loose.append(path.relative_to(ROOT).as_posix()) + tagged = add_module_version(source, "t0") + if re.search(r"""\bfrom\s*["']\.{1,2}/[^"']+\.mjs["']""", tagged): + loose.append(path.relative_to(ROOT).as_posix()) + self.assertEqual(loose, []) + + +class VersionedServingTests(unittest.TestCase): + def setUp(self): + try: + from aiohttp import web + from aiohttp.test_utils import TestClient, TestServer + except ImportError: + self.skipTest("aiohttp is not installed") + self.web, self.TestClient, self.TestServer = web, TestClient, TestServer + self.folder = tempfile.TemporaryDirectory() + self.addCleanup(self.folder.cleanup) + base = Path(self.folder.name) + self.js = base / "js" + (self.js / "shared").mkdir(parents=True) + (self.js / "seed").mkdir() + (self.js / "shared" / "a.mjs").write_text("export const a = 1;") + (self.js / "shared" / "b.mjs").write_text('import { a } from "./a.mjs"; export const b = a;') + (self.js / "seed" / "index.js").write_text('import { b } from "../shared/b.mjs";') + (self.js / "seed" / "plain.js").write_text("console.log(1);") + (base / "secret.mjs").write_text('import "./a.mjs";') + + def fetch(self, paths, token="tok", headers=None, js_root="default", setup=None): + web = self.web + + async def handled(request): + return web.Response(text="from the handler", content_type="text/javascript") + + async def run(): + root = self.js if js_root == "default" else js_root + app = web.Application(middlewares=[make_revalidate_middleware("ausboss-nodes", root, token)]) + app.router.add_get("/extensions/{tail:.*}", handled) + async with self.TestClient(self.TestServer(app)) as client: + out = {} + for path in paths: + response = await client.get(path, headers=headers) + out[path] = (response.status, await response.text(), response.headers) + return out + + return asyncio.run(run()) + + def test_module_with_imports_is_served_tagged_and_revalidated(self): + path = "/extensions/ausboss-nodes/shared/b.mjs" + status, body, headers = self.fetch([path])[path] + self.assertEqual(status, 200) + self.assertIn('"./a.mjs?v=tok"', body) + self.assertEqual(headers["Cache-Control"], "no-cache") + self.assertIn("javascript", headers["Content-Type"]) + etag = headers["ETag"] + again = self.fetch([path], headers={"If-None-Match": etag})[path] + self.assertEqual(again[0], 304) + self.assertEqual(again[2]["Cache-Control"], "no-cache") + + def test_entry_script_is_served_tagged_and_never_stored(self): + path = "/extensions/ausboss-nodes/seed/index.js" + status, body, headers = self.fetch([path])[path] + self.assertEqual(status, 200) + self.assertIn('"../shared/b.mjs?v=tok"', body) + self.assertEqual(headers["Cache-Control"], "no-store") + + def test_files_with_nothing_to_tag_go_to_the_normal_server(self): + seen = self.fetch(["/extensions/ausboss-nodes/shared/a.mjs", "/extensions/ausboss-nodes/seed/plain.js"]) + self.assertEqual(seen["/extensions/ausboss-nodes/shared/a.mjs"][1], "from the handler") + self.assertEqual(seen["/extensions/ausboss-nodes/shared/a.mjs"][2]["Cache-Control"], "no-cache") + self.assertEqual(seen["/extensions/ausboss-nodes/seed/plain.js"][1], "from the handler") + + def test_other_packs_and_missing_files_are_not_touched(self): + seen = self.fetch( + ["/extensions/other-pack/shared/b.mjs", "/extensions/ausboss-nodes/shared/missing.mjs"] + ) + self.assertEqual(seen["/extensions/other-pack/shared/b.mjs"][1], "from the handler") + self.assertNotIn("Cache-Control", seen["/extensions/other-pack/shared/b.mjs"][2]) + self.assertEqual(seen["/extensions/ausboss-nodes/shared/missing.mjs"][1], "from the handler") + + def test_nothing_outside_the_js_folder_is_read(self): + outside = "/extensions/ausboss-nodes/%2e%2e/secret.mjs" + link = self.js / "shared" / "link.mjs" + try: + os.symlink(Path(self.folder.name) / "secret.mjs", link) + except (OSError, NotImplementedError): + link = None + paths = [outside] + (["/extensions/ausboss-nodes/shared/link.mjs"] if link else []) + for path, (_status, body, _headers) in self.fetch(paths).items(): + self.assertNotIn("?v=tok", body, path) + + def test_without_a_tag_the_files_are_served_as_before(self): + path = "/extensions/ausboss-nodes/shared/b.mjs" + status, body, headers = self.fetch([path], token=None)[path] + self.assertEqual(body, "from the handler") + self.assertEqual(headers["Cache-Control"], "no-cache") + + if __name__ == "__main__": unittest.main()