Version the pack's script imports so an update never meets old browser copies
ComfyUI sends no cache header for .mjs, and the 2.5.1 no-cache header cannot reach a copy the browser already stored. Firefox then kept running files from before the update, even through Ctrl+Shift+R, and the preview bar, cards and editors failed to load. The server now adds ?v=<tag> to the relative .mjs imports of the pack's scripts as it serves them. The tag changes whenever any script changes, so after an update the browser asks for addresses it has never stored. Files on disk are unchanged. A test fails if a script names a .mjs in a way the tag would miss.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<tag>). 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=<token> 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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user