Make PWA updates resilient with progressive feature caching #620
|
|
@ -73,6 +73,8 @@ const SHELL = [
|
||||||
BASE + 'static/push-notifications.js',
|
BASE + 'static/push-notifications.js',
|
||||||
BASE + 'static/background-issue-sync.js',
|
BASE + 'static/background-issue-sync.js',
|
||||||
];
|
];
|
||||||
|
const OPTIONAL_FEATURES = [
|
||||||
|
];
|
||||||
|
|
||||||
async function fetchNavigation(request) {
|
async function fetchNavigation(request) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
@ -262,10 +264,16 @@ self.addEventListener('install', event => {
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', event => {
|
self.addEventListener('activate', event => {
|
||||||
event.waitUntil(caches.keys().then(keys => Promise.all(
|
event.waitUntil((async () => {
|
||||||
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
const keys = await caches.keys();
|
||||||
.map(key => caches.delete(key))
|
await Promise.all(
|
||||||
)).then(() => self.clients.claim()));
|
keys.filter(key => key.startsWith('stackchain-dashboard-') && key !== CACHE)
|
||||||
|
.map(key => caches.delete(key))
|
||||||
|
);
|
||||||
|
const cache = await caches.open(CACHE);
|
||||||
|
await Promise.allSettled(OPTIONAL_FEATURES.map(asset => cache.add(asset)));
|
||||||
|
await self.clients.claim();
|
||||||
|
})());
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('sync', event => {
|
self.addEventListener('sync', event => {
|
||||||
|
|
@ -433,5 +441,9 @@ self.addEventListener('fetch', event => {
|
||||||
}
|
}
|
||||||
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
if (url.origin === self.location.origin && SHELL.includes(url.pathname)) {
|
||||||
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.origin === self.location.origin && OPTIONAL_FEATURES.includes(url.pathname)) {
|
||||||
|
event.respondWith(caches.match(request).then(cached => cached || fetch(request)));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -114,11 +114,20 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild:
|
||||||
for source in sources:
|
for source in sources:
|
||||||
if source != WORKER_RUNTIME_SOURCE:
|
if source != WORKER_RUNTIME_SOURCE:
|
||||||
worker = worker.replace(f" BASE + '{source}',\n", "")
|
worker = worker.replace(f" BASE + '{source}',\n", "")
|
||||||
|
eager_feature = feature_bundles["today-timer"]
|
||||||
|
optional_features = {
|
||||||
|
name: bundle for name, bundle in feature_bundles.items() if name != "today-timer"
|
||||||
|
}
|
||||||
worker = worker.replace(
|
worker = worker.replace(
|
||||||
" BASE + 'static/dashboard.css',\n",
|
" BASE + 'static/dashboard.css',\n",
|
||||||
" BASE + 'static/dashboard.css',\n"
|
" BASE + 'static/dashboard.css',\n"
|
||||||
+ f" BASE + '{core.runtime_name}',\n"
|
+ f" BASE + '{core.runtime_name}',\n"
|
||||||
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in feature_bundles.values()),
|
+ f" BASE + '{eager_feature.runtime_name}',\n",
|
||||||
|
)
|
||||||
|
worker = worker.replace(
|
||||||
|
"const OPTIONAL_FEATURES = [\n",
|
||||||
|
"const OPTIONAL_FEATURES = [\n"
|
||||||
|
+ "".join(f" BASE + '{bundle.runtime_name}',\n" for bundle in optional_features.values()),
|
||||||
)
|
)
|
||||||
worker = CACHE_DECLARATION.sub(
|
worker = CACHE_DECLARATION.sub(
|
||||||
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
"const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,18 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||||
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||||
|
|
||||||
|
shell_block, optional_block = first.service_worker_source.split(
|
||||||
|
"const OPTIONAL_FEATURES = [", 1
|
||||||
|
)
|
||||||
|
optional_block = optional_block.split("];", 1)[0]
|
||||||
|
assert f"BASE + '{first.feature_bundles['today-timer'].runtime_name}'" in shell_block
|
||||||
|
for name, bundle in first.feature_bundles.items():
|
||||||
|
if name == "today-timer":
|
||||||
|
assert f"BASE + '{bundle.runtime_name}'" not in optional_block
|
||||||
|
else:
|
||||||
|
assert f"BASE + '{bundle.runtime_name}'" not in shell_block
|
||||||
|
assert f"BASE + '{bundle.runtime_name}'" in optional_block
|
||||||
|
|
||||||
changed_frontend = tmp_path / "frontend"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
capture_source = changed_frontend / "create-issue-sheet.js"
|
capture_source = changed_frontend / "create-issue-sheet.js"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const vm = require('vm');
|
const vm = require('vm');
|
||||||
const listeners = {{}};
|
const listeners = {{}};
|
||||||
const state = {{ added: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
const state = {{ added: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], focused: [], opened: [], failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||||
const storedResponses = new Map();
|
const storedResponses = new Map();
|
||||||
storedResponses.set(
|
storedResponses.set(
|
||||||
'https://forge.example/dashboard/__offline-session-lease',
|
'https://forge.example/dashboard/__offline-session-lease',
|
||||||
|
|
@ -24,6 +24,10 @@ storedResponses.set(
|
||||||
);
|
);
|
||||||
const cache = {{
|
const cache = {{
|
||||||
addAll: async urls => {{ state.added = urls; }},
|
addAll: async urls => {{ state.added = urls; }},
|
||||||
|
add: async url => {{
|
||||||
|
if (state.failedAdds.includes(url)) throw new Error('optional asset unavailable');
|
||||||
|
state.individuallyAdded.push(url);
|
||||||
|
}},
|
||||||
match: async request => {{
|
match: async request => {{
|
||||||
const key = String(request.url || request);
|
const key = String(request.url || request);
|
||||||
if (storedResponses.has(key)) return storedResponses.get(key).clone();
|
if (storedResponses.has(key)) return storedResponses.get(key).clone();
|
||||||
|
|
@ -89,7 +93,7 @@ const context = {{
|
||||||
}},
|
}},
|
||||||
}};
|
}};
|
||||||
vm.createContext(context);
|
vm.createContext(context);
|
||||||
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf;', context);
|
vm.runInContext(fs.readFileSync({json.dumps(str(WORKER))}, 'utf8') + '\\nself.__testFetchJson = fetchJson; self.__testWithSessionCsrf = withSessionCsrf; self.__testOptionalFeatures = OPTIONAL_FEATURES;', context);
|
||||||
async function dispatch(name, request) {{
|
async function dispatch(name, request) {{
|
||||||
let pending;
|
let pending;
|
||||||
let response;
|
let response;
|
||||||
|
|
@ -746,6 +750,45 @@ def test_activate_deletes_only_stale_stackchain_caches():
|
||||||
assert result["deleted"] == ["stackchain-dashboard-old"]
|
assert result["deleted"] == ["stackchain-dashboard-old"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_activate_warms_optional_features_without_blocking_siblings_or_claim():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
state.failedAdds = ['/dashboard/feature-security-center-test.js'];
|
||||||
|
context.self.__testOptionalFeatures.push(
|
||||||
|
'/dashboard/feature-issue-capture-test.js',
|
||||||
|
'/dashboard/feature-security-center-test.js',
|
||||||
|
'/dashboard/feature-pull-workflow-test.js',
|
||||||
|
);
|
||||||
|
await dispatch('activate');
|
||||||
|
process.stdout.write(JSON.stringify(state));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["claimed"] is True
|
||||||
|
assert result["individuallyAdded"] == [
|
||||||
|
"/dashboard/feature-issue-capture-test.js",
|
||||||
|
"/dashboard/feature-pull-workflow-test.js",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_warmed_optional_feature_is_served_from_cache_while_offline():
|
||||||
|
result = run_worker_scenario(
|
||||||
|
"""
|
||||||
|
context.self.__testOptionalFeatures.push('/dashboard/feature-issue-capture-test.js');
|
||||||
|
state.cachedBody = 'cached feature';
|
||||||
|
state.failFetch = true;
|
||||||
|
const response = await dispatch('fetch', {
|
||||||
|
method: 'GET', mode: 'cors',
|
||||||
|
url: 'https://forge.example/dashboard/feature-issue-capture-test.js',
|
||||||
|
});
|
||||||
|
process.stdout.write(JSON.stringify({ body: await response.text(), state }));
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["body"] == "cached feature"
|
||||||
|
assert result["state"]["fetches"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
def test_offline_navigation_returns_cached_shell_for_share_target_url():
|
||||||
result = run_worker_scenario(
|
result = run_worker_scenario(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user