From eee651fcb637def50a87510bb3d550324b5ab869 Mon Sep 17 00:00:00 2001 From: Rylee Corradini Date: Mon, 13 Jul 2026 12:47:40 -0700 Subject: [PATCH] adds a "what`s new" aspect to the upgrade modal --- about.ts | 44 +- components/UpdateNotification.ts | 630 ++++++++++++++------------- scripts/copy-assets.mjs | 1 + src/ServiceWorkerManager.ts | 195 +++++---- src/shell/domTypes.ts | 11 +- src/shell/shellRuntime.ts | 70 ++- src/updateRelease.ts | 66 +++ sw-core.js | 40 +- sw-dev.js | 32 +- sw-release.js | 12 + sw.js | 32 +- tests/unit/serviceWorkerCore.test.ts | 86 ++++ tests/unit/updateRelease.test.ts | 59 +++ 13 files changed, 848 insertions(+), 430 deletions(-) create mode 100644 src/updateRelease.ts create mode 100644 sw-release.js create mode 100644 tests/unit/serviceWorkerCore.test.ts create mode 100644 tests/unit/updateRelease.test.ts diff --git a/about.ts b/about.ts index 41cd945..7482470 100644 --- a/about.ts +++ b/about.ts @@ -1,4 +1,8 @@ import { serviceWorkerManager } from './src/ServiceWorkerManager.js'; +import type { + UpdateAvailableDetail, + UpdateRestartRequiredDetail, +} from './src/ServiceWorkerManager.js'; import '/components/UpdateNotification.js'; import '/components/ConfirmationModal.js'; @@ -10,8 +14,11 @@ interface ConfirmationModal extends HTMLElement { } interface UpdateNotification extends HTMLElement { - show(pendingWorker: ServiceWorker | null): void; - pendingWorkerInstance: ServiceWorker | null; + show(update: UpdateAvailableDetail): void; + showRestartRequired(release: UpdateRestartRequiredDetail['release']): void; + showUnavailable(message: string): void; + showUpdating(status?: string): void; + showFailure(message: string): void; } const whenLoaded = Promise.all([ @@ -25,7 +32,30 @@ whenLoaded.then(async () => { window.addEventListener('sw-update-available', event => { console.log('Service worker update available, showing notification'); - updateNotification?.show((event as CustomEvent).detail.pendingWorker); + updateNotification?.show((event as CustomEvent).detail); + }); + window.addEventListener('sw-update-restart-required', event => { + const detail = (event as CustomEvent).detail; + updateNotification?.showRestartRequired(detail.release); + }); + window.addEventListener('sw-update-error', event => { + const detail = (event as CustomEvent<{ message: string }>).detail; + updateNotification?.showUnavailable(detail.message); + }); + window.addEventListener('sw-update-progress', event => { + const detail = (event as CustomEvent<{ status: string }>).detail; + updateNotification?.showUpdating(detail.status); + }); + updateNotification?.addEventListener('update-accepted', event => { + const pendingWorker = (event as CustomEvent<{ pendingWorker: ServiceWorker }>).detail + .pendingWorker; + serviceWorkerManager.handleUpdateNow(pendingWorker).catch(error => { + console.error('[about] Update failed:', error); + updateNotification.showFailure('Update failed. The current version remains active.'); + }); + }); + updateNotification?.addEventListener('update-restart-requested', () => { + window.location.reload(); }); await serviceWorkerManager.register(); @@ -60,13 +90,7 @@ whenLoaded.then(async () => { } btnUpdate?.addEventListener('click', () => { - const pendingWorker = - updateNotification?.pendingWorkerInstance || serviceWorkerManager.getRegistration()?.waiting; - if (pendingWorker) { - serviceWorkerManager.handleUpdateNow(pendingWorker); - } else { - serviceWorkerManager.checkForUpdates(); - } + serviceWorkerManager.checkForUpdates(); }); const btnClearCache = document.getElementById('btnClearCache') as HTMLButtonElement; diff --git a/components/UpdateNotification.ts b/components/UpdateNotification.ts index 12a4a24..5ad3062 100644 --- a/components/UpdateNotification.ts +++ b/components/UpdateNotification.ts @@ -1,342 +1,374 @@ /** - * UpdateNotification Web Component - * Displays a notification when a service worker update is available - * Uses Shadow DOM with encapsulated styles + * — blocking, release-aware service-worker update dialog. + * The shell owns persistence and activation; this component owns presentation + * and emits acknowledgement events. */ -class UpdateNotification extends HTMLElement { - pendingWorker: ServiceWorker | null = null; - isVisible: boolean = false; - isUpdating: boolean = false; - boundHandleUpdateNow: EventListener; - boundHandleUpdateLater: EventListener; - _updateProgressHandler: EventListener | null = null; - - constructor() { - super(); - this.pendingWorker = null; - this.isVisible = false; - this.isUpdating = false; - this.boundHandleUpdateNow = this.handleUpdateNow.bind(this); - this.boundHandleUpdateLater = this.handleUpdateLater.bind(this); - - this.attachShadow({ mode: 'open' }); - } +import { h } from '/src/domUtils.js'; +import type { UpdateAvailableDetail } from '/src/ServiceWorkerManager.js'; +import type { UpdateRelease } from '/src/updateRelease.js'; + +const CSS = ` +:host { + display: none; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --update-accent: var(--accent-color, #00d9a5); + --update-bg: #071210; + --update-border: #2a4a42; + --update-text: #c5efdf; + --update-dim: #6ae8c8; + --update-danger: #ff8a9a; +} - connectedCallback() { - this.render(); - this.setupEventListeners(); - } +dialog[open] { + display: flex; + flex-direction: column; + width: min(560px, calc(100vw - 24px)); + max-height: min(680px, calc(100vh - 24px)); + box-sizing: border-box; + margin: auto; + padding: 0; + overflow: hidden; + color: var(--update-text); + background: linear-gradient(155deg, #0a100e 0%, var(--update-bg) 55%, #0d1815 100%); + border: 1px solid var(--update-border); + border-radius: 8px; + box-shadow: 0 0 28px rgba(0, 217, 165, 0.18), 0 12px 36px rgba(0, 0, 0, 0.55); +} - disconnectedCallback() { - this.cleanupEventListeners(); - if (this._updateProgressHandler) { - window.removeEventListener('sw-update-progress', this._updateProgressHandler); - this._updateProgressHandler = null; - } - } +dialog.required { + border-color: var(--update-accent); +} - render() { - const root = this.shadowRoot; - if (!root) return; - root.innerHTML = ` - -
- Update Available! -

A new version is ready.

-
- - -
-
-
-

Please wait while we install the update.

-
-
- `; - } +dialog::backdrop { + background: rgba(2, 4, 3, 0.78); + backdrop-filter: blur(2px); +} - setupEventListeners() { - const updateNowBtn = this.shadowRoot?.querySelector('.update-now') as HTMLButtonElement; - const updateLaterBtn = this.shadowRoot?.querySelector('.update-later') as HTMLButtonElement; +header { + padding: 1rem 1.25rem; + border-bottom: 1px dashed var(--update-border); +} - if (updateNowBtn) { - updateNowBtn.addEventListener('click', this.boundHandleUpdateNow); - } +h2 { + margin: 0; + color: var(--update-accent); + font-family: var(--font-heading, Silkscreen, ui-monospace, monospace); + font-size: 1rem; + font-weight: 400; + letter-spacing: 0.08em; +} - if (updateLaterBtn) { - updateLaterBtn.addEventListener('click', this.boundHandleUpdateLater); - } +.version { + display: block; + margin-top: 0.45rem; + color: var(--update-dim); + font-size: 0.78rem; + letter-spacing: 0.12em; +} + +.body { + overflow-y: auto; + padding: 1.15rem 1.25rem 0.5rem; +} + +.message { + margin: 0 0 1rem; + line-height: 1.5; +} + +ul { + margin: 0; + padding-left: 1.3rem; +} + +li { + margin: 0.55rem 0; + line-height: 1.45; +} + +.status { + display: none; + margin: 1rem 0 0; + color: var(--update-dim); + line-height: 1.45; +} + +.status.visible { + display: block; +} + +.status.error { + color: var(--update-danger); +} + +.spinner { + display: inline-block; + width: 14px; + height: 14px; + margin-right: 0.55rem; + vertical-align: -2px; + border: 2px solid color-mix(in srgb, var(--update-accent) 35%, transparent); + border-top-color: var(--update-accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.25rem 1.25rem; +} + +button { + min-height: 44px; + padding: 0.6rem 1rem; + border-radius: 5px; + cursor: pointer; + font-family: var(--font-heading, Silkscreen, ui-monospace, monospace); + font-size: 0.82rem; + font-weight: 700; +} + +button.primary { + color: #020403; + background: var(--update-accent); + border: 1px solid var(--update-accent); +} + +button.secondary { + color: var(--update-text); + background: transparent; + border: 1px solid var(--update-border); +} + +button:hover:not(:disabled), +button:focus-visible:not(:disabled) { + outline: 2px solid var(--update-accent); + outline-offset: 2px; +} + +button:disabled { + cursor: wait; + opacity: 0.55; +} + +[hidden] { + display: none !important; +} +`; + +class UpdateNotification extends HTMLElement { + #dialog: HTMLDialogElement | null = null; + #title: HTMLElement | null = null; + #version: HTMLElement | null = null; + #message: HTMLElement | null = null; + #notes: HTMLUListElement | null = null; + #status: HTMLElement | null = null; + #primary: HTMLButtonElement | null = null; + #later: HTMLButtonElement | null = null; + #update: UpdateAvailableDetail | null = null; + #required = false; + #restartOnly = false; + #ready = false; + + connectedCallback(): void { + if (this.#ready) return; + const shadow = this.attachShadow({ mode: 'open' }); + const style = h('style'); + style.textContent = CSS; + + this.#title = h('h2'); + this.#version = h('span', { className: 'version' }); + this.#message = h('p', { className: 'message' }); + this.#notes = h('ul') as HTMLUListElement; + this.#status = h('p', { className: 'status', ariaLive: 'polite' }); + this.#primary = h('button', { + className: 'primary', + type: 'button', + textContent: 'Update & Restart', + }) as HTMLButtonElement; + this.#later = h('button', { + className: 'secondary', + type: 'button', + textContent: 'Later', + }) as HTMLButtonElement; + + this.#dialog = h('dialog', {}, [ + h('header', {}, [this.#title, this.#version]), + h('div', { className: 'body' }, [this.#message, this.#notes, this.#status]), + h('div', { className: 'actions' }, [this.#later, this.#primary]), + ]) as HTMLDialogElement; + + this.#primary.addEventListener('click', () => this.#accept()); + this.#later.addEventListener('click', () => this.#dismiss()); + this.#dialog.addEventListener('cancel', event => { + if (this.#required) { + event.preventDefault(); + return; + } + event.preventDefault(); + this.#dismiss(); + }); + this.#dialog.addEventListener('click', event => { + if (event.target === this.#dialog && !this.#required) this.#dismiss(); + }); + + shadow.append(style, this.#dialog); + this.#ready = true; } - cleanupEventListeners() { - const updateNowBtn = this.shadowRoot?.querySelector('.update-now') as HTMLButtonElement; - const updateLaterBtn = this.shadowRoot?.querySelector('.update-later') as HTMLButtonElement; + show(update: UpdateAvailableDetail): void { + this.#ensureReady(); + this.#update = update; + this.#required = update.required; + this.#restartOnly = false; + this.#renderRelease(update.pending); + if (this.#message) { + this.#message.textContent = update.required + ? 'This app-shell update is required to continue safely. Your campaign will be saved before restart.' + : 'A new version is installed and ready to use.'; + } + if (this.#later) this.#later.hidden = update.required; + this.#resetActions('Update & Restart'); + this.#open(); + } - if (updateNowBtn) { - updateNowBtn.removeEventListener('click', this.boundHandleUpdateNow); + showRestartRequired(release: UpdateRelease | null): void { + this.#ensureReady(); + this.#update = null; + this.#required = true; + this.#restartOnly = true; + if (this.#title) this.#title.textContent = 'UPDATE INSTALLED'; + if (this.#version) this.#version.textContent = release ? `VERSION ${release.version}` : ''; + if (this.#message) { + this.#message.textContent = + 'Another tab installed an update. Restart this session before continuing.'; } + this.#renderNotes(release?.highlights ?? []); + if (this.#later) this.#later.hidden = true; + this.#resetActions('Restart'); + this.#open(); + } - if (updateLaterBtn) { - updateLaterBtn.removeEventListener('click', this.boundHandleUpdateLater); + showUnavailable(message: string): void { + this.#ensureReady(); + this.#update = null; + this.#required = false; + this.#restartOnly = false; + if (this.#title) this.#title.textContent = 'UPDATE UNAVAILABLE'; + if (this.#version) this.#version.textContent = ''; + if (this.#message) this.#message.textContent = message; + this.#renderNotes([]); + if (this.#primary) this.#primary.hidden = true; + if (this.#later) { + this.#later.hidden = false; + this.#later.textContent = 'Continue Current Version'; } + this.#clearStatus(); + this.#open(); } - show(pendingWorker: ServiceWorker | null) { - this.pendingWorker = pendingWorker; - const notification = this.shadowRoot?.querySelector('.update-notification') as HTMLDivElement; - - if (notification) { - this.style.display = 'block'; - notification.style.display = 'block'; - this.isVisible = true; - - this.dispatchEvent( - new CustomEvent('update-notification-shown', { - detail: { pendingWorker }, - bubbles: true, - composed: true, - }) + showUpdating(status = 'Activating the new version…'): void { + if (this.#primary) this.#primary.disabled = true; + if (this.#later) this.#later.disabled = true; + if (this.#status) { + this.#status.className = 'status visible'; + this.#status.replaceChildren( + h('span', { className: 'spinner', ariaHidden: 'true' }), + document.createTextNode(status) ); - } else { - console.error('[UpdateNotification] Could not find .update-notification element'); } } - hide() { - const notification = this.shadowRoot?.querySelector('.update-notification') as HTMLDivElement; + showFailure(message: string): void { + if (this.#status) { + this.#status.className = 'status visible error'; + this.#status.textContent = message; + } + if (this.#primary) { + this.#primary.disabled = false; + this.#primary.hidden = false; + this.#primary.textContent = this.#restartOnly ? 'Retry Restart' : 'Retry Update'; + this.#primary.focus(); + } + if (this.#later) this.#later.disabled = false; + } - if (notification) { - this.style.display = 'none'; - notification.style.display = 'none'; - this.isVisible = false; + hide(): void { + if (this.#dialog?.open) this.#dialog.close(); + this.style.display = 'none'; + } - this.dispatchEvent( - new CustomEvent('update-notification-hidden', { - bubbles: true, - composed: true, - }) - ); - } + get isOpen(): boolean { + return Boolean(this.#dialog?.open); } - showUpdating(status = 'Please wait while we install the update.') { - this.isUpdating = true; - const notification = this.shadowRoot?.querySelector('.update-notification') as HTMLDivElement; - const actions = this.shadowRoot?.querySelector('.update-actions') as HTMLDivElement; - const updatingState = this.shadowRoot?.querySelector('.updating-state') as HTMLDivElement; - const statusText = this.shadowRoot?.querySelector('.update-status') as HTMLParagraphElement; - const title = this.shadowRoot?.querySelector('.title') as HTMLHeadingElement; - const message = this.shadowRoot?.querySelector('.message') as HTMLParagraphElement; - - if (notification && actions && updatingState) { - actions.classList.add('hidden'); - updatingState.classList.add('active'); - if (statusText) { - statusText.textContent = status; - } - if (title) { - title.textContent = 'Updating...'; - } - if (message) { - message.style.display = 'none'; - } + get pendingWorkerInstance(): ServiceWorker | null { + return this.#update?.pendingWorker ?? null; + } - const buttons = this.shadowRoot?.querySelectorAll('button') as NodeListOf; - buttons.forEach(btn => { - btn.disabled = true; - }); - } + #ensureReady(): void { + if (!this.#ready) this.connectedCallback(); } - handleUpdateNow() { - this.showUpdating('Activating new service worker...'); + #renderRelease(release: UpdateRelease): void { + if (this.#title) this.#title.textContent = `WHAT'S NEW // ${release.title}`; + if (this.#version) this.#version.textContent = `VERSION ${release.version}`; + this.#renderNotes(release.highlights); + } - this.dispatchEvent( - new CustomEvent('update-accepted', { - detail: { pendingWorker: this.pendingWorker }, - bubbles: true, - composed: true, - }) - ); + #renderNotes(highlights: readonly string[]): void { + this.#notes?.replaceChildren(...highlights.map(text => h('li', { textContent: text }))); + if (this.#notes) this.#notes.hidden = highlights.length === 0; + } - if (window.serviceWorkerManager) { - const handleUpdateProgress = (event: CustomEvent<{ status: string }>) => { - if (event.detail && event.detail.status) { - this.showUpdating(event.detail.status); - } - }; - - this._updateProgressHandler = handleUpdateProgress as EventListener; - window.addEventListener('sw-update-progress', this._updateProgressHandler); - - window.serviceWorkerManager.handleUpdateNow(this.pendingWorker).catch(error => { - console.error('[UpdateNotification] Update failed:', error); - this.showUpdating('Update failed. Please try again.'); - this.isUpdating = false; - const buttons = this.shadowRoot?.querySelectorAll( - 'button' - ) as NodeListOf; - buttons.forEach(btn => { - btn.disabled = false; - }); - if (this._updateProgressHandler) { - window.removeEventListener('sw-update-progress', this._updateProgressHandler); - this._updateProgressHandler = null; - } - }); - } else { - console.error('[UpdateNotification] ServiceWorkerManager not available'); - this.hide(); + #resetActions(primaryLabel: string): void { + this.#clearStatus(); + if (this.#primary) { + this.#primary.hidden = false; + this.#primary.disabled = false; + this.#primary.textContent = primaryLabel; } + if (this.#later) { + this.#later.disabled = false; + this.#later.textContent = 'Later'; + } + } + + #clearStatus(): void { + if (!this.#status) return; + this.#status.className = 'status'; + this.#status.replaceChildren(); + } + + #open(): void { + if (!this.#dialog) return; + this.style.display = 'block'; + this.#dialog.classList.toggle('required', this.#required); + if (!this.#dialog.open) this.#dialog.showModal(); + this.#primary?.focus(); } - handleUpdateLater() { + #accept(): void { + this.showUpdating(this.#restartOnly ? 'Restarting with the installed version…' : undefined); this.dispatchEvent( - new CustomEvent('update-dismissed', { + new CustomEvent(this.#restartOnly ? 'update-restart-requested' : 'update-accepted', { + detail: this.#update ? { pendingWorker: this.#update.pendingWorker } : undefined, bubbles: true, composed: true, }) ); - - this.hide(); } - get visible() { - return this.isVisible; - } - - get pendingWorkerInstance() { - return this.pendingWorker; + #dismiss(): void { + this.dispatchEvent(new CustomEvent('update-dismissed', { bubbles: true, composed: true })); + this.hide(); } } diff --git a/scripts/copy-assets.mjs b/scripts/copy-assets.mjs index 1447be1..c9f47aa 100644 --- a/scripts/copy-assets.mjs +++ b/scripts/copy-assets.mjs @@ -29,6 +29,7 @@ const files = [ 'sw.js', 'sw-dev.js', 'sw-core.js', + 'sw-release.js', // Debug harness pages (their scripts are compiled by tsc into dist/debug/). 'debug/index.html', 'debug/map.html', diff --git a/src/ServiceWorkerManager.ts b/src/ServiceWorkerManager.ts index 3d947b4..66bec7e 100644 --- a/src/ServiceWorkerManager.ts +++ b/src/ServiceWorkerManager.ts @@ -1,6 +1,15 @@ // Singleton module for service worker registration // Provides centralized management of service worker lifecycle import { isDevelopmentMode } from './domUtils.js'; +import { + parseUpdateRelease, + requiresUpdateAcknowledgement, + type AvailableUpdate, + type UpdateRelease, +} from './updateRelease.js'; + +export type UpdateAvailableDetail = AvailableUpdate; +export type UpdateRestartRequiredDetail = Readonly<{ release: UpdateRelease | null }>; export class ServiceWorkerManager { #isRegistered = false; @@ -9,6 +18,8 @@ export class ServiceWorkerManager { #developmentMode = isDevelopmentMode(); #swFile = this.#developmentMode ? '/sw-dev.js' : '/sw.js'; #isUpdating = false; + #announcedWorker: ServiceWorker | null = null; + #hadController = false; static instance: ServiceWorkerManager | null = null; constructor() { @@ -30,13 +41,14 @@ export class ServiceWorkerManager { this.#registration = existingRegistration; this.#isRegistered = true; - this.#checkForMultipleWorkers().catch(error => { - console.warn(`[KernelPanic] Error checking for multiple workers:`, error); - }); + this.#checkForMultipleWorkers(); if (!this.#listenersSetup) { this.#setupUpdateListeners(); } + this.#registration.update().catch(error => { + console.error(`[KernelPanic] Startup service-worker update check failed:`, error); + }); return this.#registration; } @@ -52,7 +64,9 @@ export class ServiceWorkerManager { }); } - this.#registration = await navigator.serviceWorker.register(this.#swFile); + this.#registration = await navigator.serviceWorker.register(this.#swFile, { + updateViaCache: 'none', + }); this.#isRegistered = true; console.log( @@ -69,7 +83,7 @@ export class ServiceWorkerManager { } } - async #checkForMultipleWorkers() { + #checkForMultipleWorkers() { if (!this.#registration) return; const active = this.#registration.active; @@ -84,45 +98,25 @@ export class ServiceWorkerManager { installing: installing ? `${installing.state} (script: ${installing.scriptURL})` : 'none', controller: controller ? `${controller.state} (script: ${controller.scriptURL})` : 'none', }); - - if (waiting && controller && waiting !== controller && !this.#isUpdating) { - try { - const activeVersion = await this.getVersion(); - const waitingVersion = await this.getLatestVersion(); - if (activeVersion === waitingVersion) { - console.log( - `[KernelPanic] Waiting worker is same version as active. Auto-activating...` - ); - await this.skipWaiting(waiting); - return; - } - } catch (error) { - console.warn(`[KernelPanic] Could not verify versions, proceeding normally:`, error); - } - } } } #setupUpdateListeners() { if (!this.#registration || this.#listenersSetup) return; - setTimeout(async () => { + this.#hadController = Boolean(navigator.serviceWorker.controller); + navigator.serviceWorker.addEventListener('controllerchange', () => { + const previouslyControlled = this.#hadController; + this.#hadController = Boolean(navigator.serviceWorker.controller); + if (!previouslyControlled || this.#isUpdating) return; + this.#dispatchRestartRequired(); + }); + + setTimeout(() => { if (this.#registration?.waiting && navigator.serviceWorker.controller && !this.#isUpdating) { const waitingWorker = this.#registration.waiting; - - try { - const currentVersion = await this.getVersion(); - const latestVersion = await this.getLatestVersion(); - if (currentVersion === latestVersion) { - console.log(`[KernelPanic] Waiting worker is same version, skipping notification`); - return; - } - } catch (error) { - console.warn(`[KernelPanic] Could not verify versions:`, error); - } - console.log(`[KernelPanic] Found waiting service worker from previous session`); - this.#dispatchUpdateEvent(waitingWorker); + this.#announceUpdate(waitingWorker); } }, 0); @@ -140,33 +134,8 @@ export class ServiceWorkerManager { navigator.serviceWorker.controller && !this.#isUpdating ) { - this.getLatestVersion() - .then(latestVersion => { - return this.getVersion().then(currentVersion => { - if (currentVersion === latestVersion) { - console.log(`[KernelPanic] New worker is same version, skipping notification`); - return false; - } - return true; - }); - }) - .then(shouldShow => { - if (shouldShow) { - console.log( - `[KernelPanic] New service worker available. Consider refreshing the page.` - ); - this.#dispatchUpdateEvent(newWorker); - } - newWorker.removeEventListener('statechange', handleStateChange); - }) - .catch(error => { - console.warn(`[KernelPanic] Could not verify version, showing notification:`, error); - console.log( - `[KernelPanic] New service worker available. Consider refreshing the page.` - ); - this.#dispatchUpdateEvent(newWorker); - newWorker.removeEventListener('statechange', handleStateChange); - }); + this.#announceUpdate(newWorker); + newWorker.removeEventListener('statechange', handleStateChange); } }; @@ -177,19 +146,59 @@ export class ServiceWorkerManager { this.#listenersSetup = true; } - #dispatchUpdateEvent(pendingWorker?: ServiceWorker | null) { - if (this.#isUpdating) { - console.log(`[KernelPanic] Update already in progress, skipping notification`); - return; + async #announceUpdate(pendingWorker: ServiceWorker): Promise { + if (this.#isUpdating || this.#announcedWorker === pendingWorker) return; + const activeWorker = this.#registration?.active; + if (!activeWorker || !navigator.serviceWorker.controller) return; + + try { + const [active, pending] = await Promise.all([ + this.getReleaseInfo(activeWorker), + this.getReleaseInfo(pendingWorker), + ]); + if (active.version === pending.version) { + console.log(`[KernelPanic] Waiting worker is same version, skipping notification`); + return; + } + + const detail: UpdateAvailableDetail = Object.freeze({ + pendingWorker, + active, + pending, + required: requiresUpdateAcknowledgement(active, pending), + }); + this.#announcedWorker = pendingWorker; + window.dispatchEvent( + new CustomEvent('sw-update-available', { detail }) + ); + } catch (error) { + this.#announcedWorker = pendingWorker; + console.error(`[KernelPanic] Refusing update with invalid release metadata:`, error); + window.dispatchEvent( + new CustomEvent('sw-update-error', { + detail: { message: 'Update metadata is invalid. The current version remains active.' }, + }) + ); } + } - const event = new CustomEvent('sw-update-available', { - detail: { - registration: this.#registration, - pendingWorker: pendingWorker || this.#registration?.waiting, - }, - }); - window.dispatchEvent(event); + #dispatchRestartRequired(): void { + const controller = navigator.serviceWorker.controller; + if (!controller) return; + this.getReleaseInfo(controller) + .then(release => { + const detail: UpdateRestartRequiredDetail = Object.freeze({ release }); + window.dispatchEvent( + new CustomEvent('sw-update-restart-required', { detail }) + ); + }) + .catch(error => { + console.error(`[KernelPanic] Activated worker has invalid release metadata:`, error); + const detail: UpdateRestartRequiredDetail = Object.freeze({ release: null }); + window.dispatchEvent( + new CustomEvent('sw-update-restart-required', { detail }) + ); + }); } async checkForUpdates() { @@ -205,7 +214,7 @@ export class ServiceWorkerManager { setTimeout(() => { if (this.#registration?.waiting && navigator.serviceWorker.controller) { console.log(`[KernelPanic] Update check found waiting service worker`); - this.#dispatchUpdateEvent(this.#registration.waiting); + this.#announceUpdate(this.#registration.waiting); } }, 100); } catch (error) { @@ -250,6 +259,28 @@ export class ServiceWorkerManager { } } + async getReleaseInfo(worker: ServiceWorker): Promise { + return new Promise((resolve, reject) => { + const messageChannel = new window.MessageChannel(); + const timeout = window.setTimeout(() => { + messageChannel.port1.close(); + reject(new Error('Timed out waiting for service-worker release metadata')); + }, 1000); + + messageChannel.port1.onmessage = event => { + window.clearTimeout(timeout); + messageChannel.port1.close(); + try { + resolve(parseUpdateRelease(event.data, 'service-worker release metadata')); + } catch (error) { + reject(error); + } + }; + + worker.postMessage({ type: 'GET_RELEASE_INFO' }, [messageChannel.port2]); + }); + } + async getLatestVersion(): Promise { if (!this.#registration) { return null; @@ -261,21 +292,7 @@ export class ServiceWorkerManager { } try { - return new Promise(resolve => { - const messageChannel = new window.MessageChannel(); - const timeout = setTimeout(() => { - messageChannel.port1.close(); - resolve(null); - }, 1000); - - messageChannel.port1.onmessage = event => { - clearTimeout(timeout); - messageChannel.port1.close(); - resolve(event.data?.version || null); - }; - - pendingWorker.postMessage({ type: 'GET_CACHE_INFO' }, [messageChannel.port2]); - }); + return (await this.getReleaseInfo(pendingWorker)).version; } catch (error) { console.error(`[KernelPanic] Failed to get latest service worker version:`, error); return null; diff --git a/src/shell/domTypes.ts b/src/shell/domTypes.ts index 4bdd4cf..affd65d 100644 --- a/src/shell/domTypes.ts +++ b/src/shell/domTypes.ts @@ -7,6 +7,10 @@ import type { CampaignSummary } from '../game/campaignSummary.js'; import type { TypedSalvage } from '../game/salvage.js'; import type { KeyItem, Telemetry } from '../types.js'; import type { AimKind, Mode } from '../input/keymap.js'; +import type { + UpdateAvailableDetail, + UpdateRestartRequiredDetail, +} from '../ServiceWorkerManager.js'; export type HelpScope = 'hub' | 'combat'; @@ -128,7 +132,12 @@ export type ConfirmationModalElement = HTMLElement & { }; export type UpdateNotificationElement = HTMLElement & { - show(pendingWorker: ServiceWorker | null): void; + show(update: UpdateAvailableDetail): void; + showRestartRequired(release: UpdateRestartRequiredDetail['release']): void; + showUnavailable(message: string): void; + showUpdating(status?: string): void; + showFailure(message: string): void; + readonly isOpen: boolean; }; export type InputState = { diff --git a/src/shell/shellRuntime.ts b/src/shell/shellRuntime.ts index 44120ba..483362b 100644 --- a/src/shell/shellRuntime.ts +++ b/src/shell/shellRuntime.ts @@ -3,6 +3,10 @@ */ import dataStore from '/src/DataStore.js'; import { serviceWorkerManager } from '/src/ServiceWorkerManager.js'; +import type { + UpdateAvailableDetail, + UpdateRestartRequiredDetail, +} from '/src/ServiceWorkerManager.js'; import { Campaign, CAMPAIGN_STATE, willEndCampaignAfterResult } from '/src/game/Campaign.js'; import { buildCampaignSummary } from '/src/game/campaignSummary.js'; @@ -204,6 +208,7 @@ let combatInventoryEl: CombatInventoryElement; let crewInventoryEl: CrewInventoryElement; let chronicleArchiveEl: ChronicleArchiveElement; let keyHelpEl: KeyHelpElement; +let updateNotificationEl: UpdateNotificationElement; let logEl: HTMLElement; let logHeaderEl: HTMLElement; let logContentEl: HTMLPreElement; @@ -506,10 +511,30 @@ export async function boot() { }); // Update-notification wiring kept from the original scaffold. - const updateNotification = mustQuery('update-notification'); + updateNotificationEl = mustQuery('update-notification'); window.addEventListener('sw-update-available', event => { - const detail = (event as CustomEvent<{ pendingWorker: ServiceWorker | null }>).detail; - updateNotification.show(detail.pendingWorker); + const detail = (event as CustomEvent).detail; + showUpdateWhenStable(() => updateNotificationEl.show(detail)); + }); + window.addEventListener('sw-update-restart-required', event => { + const detail = (event as CustomEvent).detail; + showUpdateWhenStable(() => updateNotificationEl.showRestartRequired(detail.release)); + }); + window.addEventListener('sw-update-error', event => { + const detail = (event as CustomEvent<{ message: string }>).detail; + showUpdateWhenStable(() => updateNotificationEl.showUnavailable(detail.message)); + }); + window.addEventListener('sw-update-progress', event => { + const detail = (event as CustomEvent<{ status: string }>).detail; + updateNotificationEl.showUpdating(detail.status); + }); + updateNotificationEl.addEventListener('update-accepted', event => { + const pendingWorker = (event as CustomEvent<{ pendingWorker: ServiceWorker }>).detail + .pendingWorker; + activateUpdateSafely(pendingWorker); + }); + updateNotificationEl.addEventListener('update-restart-requested', () => { + restartWithValidatedSave(); }); await dataStore.init(); @@ -1198,6 +1223,44 @@ function handlePersist() { dataStore.setCampaign(snapshotCampaign(campaign)); } +function showUpdateWhenStable(show: () => void): void { + if (animLock.isLocked() || isAnyBlockingModalOpen()) { + window.setTimeout(() => showUpdateWhenStable(show), 100); + return; + } + show(); +} + +function persistValidatedCampaignForRestart(): void { + if (!campaign || degrading) return; + const snapshot = snapshotCampaign(campaign); + // Constructing a second campaign verifies the serialized boundary before + // the live page is allowed to unload. It deliberately has no persistence or + // result callbacks, so validation cannot mutate the active session. + restoreCampaign(structuredClone(snapshot)); + dataStore.setCampaign(snapshot); +} + +async function activateUpdateSafely(pendingWorker: ServiceWorker): Promise { + try { + persistValidatedCampaignForRestart(); + await serviceWorkerManager.handleUpdateNow(pendingWorker); + } catch (error) { + console.error('[shell] Failed to activate service-worker update:', error); + updateNotificationEl.showFailure('Update failed. Your current campaign remains saved.'); + } +} + +function restartWithValidatedSave(): void { + try { + persistValidatedCampaignForRestart(); + window.location.reload(); + } catch (error) { + console.error('[shell] Refusing restart because campaign validation failed:', error); + updateNotificationEl.showFailure('Restart blocked: campaign save validation failed.'); + } +} + function presentEndedCampaignOverlay(c: Campaign): void { // P3.M6.4: commit a stolen blueprint to the cross-campaign meta-store before // archiving the summary. Idempotent (duplicate id → no-op), so it's safe on @@ -2679,6 +2742,7 @@ function isAnyBlockingModalOpen(): boolean { if (chronicleArchiveEl?.isOpen) return true; if (keyHelpEl?.isOpen) return true; if (faultEl?.isOpen) return true; + if (updateNotificationEl?.isOpen) return true; // uses a native internally; treat any open // attribute as "blocking". if (isConfirmationDialogOpen(confirmationModalEl)) return true; diff --git a/src/updateRelease.ts b/src/updateRelease.ts new file mode 100644 index 0000000..ad0e5c7 --- /dev/null +++ b/src/updateRelease.ts @@ -0,0 +1,66 @@ +export type UpdateRelease = Readonly<{ + version: string; + shellEpoch: number; + title: string; + highlights: readonly string[]; +}>; + +export type AvailableUpdate = Readonly<{ + pendingWorker: ServiceWorker; + active: UpdateRelease; + pending: UpdateRelease; + required: boolean; +}>; + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value as Record; +} + +/** Validate release metadata received across the service-worker message boundary. */ +export function parseUpdateRelease(value: unknown, label = 'release metadata'): UpdateRelease { + const candidate = record(value, label); + const { version, shellEpoch, title, highlights } = candidate; + + if (typeof version !== 'string' || version.trim().length === 0) { + throw new TypeError(`${label}.version must be a non-empty string`); + } + if (!Number.isInteger(shellEpoch) || (shellEpoch as number) < 1) { + throw new RangeError(`${label}.shellEpoch must be a positive integer`); + } + if (typeof title !== 'string' || title.trim().length === 0) { + throw new TypeError(`${label}.title must be a non-empty string`); + } + if ( + !Array.isArray(highlights) || + highlights.length === 0 || + highlights.some(item => typeof item !== 'string' || item.trim().length === 0) + ) { + throw new TypeError(`${label}.highlights must be a non-empty array of non-empty strings`); + } + + return Object.freeze({ + version, + shellEpoch: shellEpoch as number, + title, + highlights: Object.freeze([...(highlights as string[])]), + }); +} + +/** + * A higher shell epoch means the pending worker cannot safely serve the + * currently loaded application shell. Downgrades are deployment errors. + */ +export function requiresUpdateAcknowledgement( + active: UpdateRelease, + pending: UpdateRelease +): boolean { + if (pending.shellEpoch < active.shellEpoch) { + throw new Error( + `pending shell epoch ${pending.shellEpoch} is older than active epoch ${active.shellEpoch}` + ); + } + return pending.shellEpoch > active.shellEpoch; +} diff --git a/sw-core.js b/sw-core.js index bb4d537..81e3ee5 100644 --- a/sw-core.js +++ b/sw-core.js @@ -11,8 +11,8 @@ const CacheConfig = { return { version, name: `${prefix}v${version}`, - staticName: `${prefix}static`, - runtimeName: `${prefix}runtime`, + staticName: `${prefix}static-v${version}`, + runtimeName: `${prefix}runtime-v${version}`, prefix }; }, @@ -38,7 +38,6 @@ const CacheConfig = { '/components/ClinicModal.js', '/components/ChronicleArchive.js', '/components/InitialRecruit.js', - '/components/ItemInventory.js', '/components/KeyHelp.js', '/components/RunBriefing.js', '/components/SystemStart.js', @@ -151,7 +150,6 @@ const CacheConfig = { '/src/shell/sceneListeners.js', '/src/shell/sceneView.js', '/src/shell/statusLine.js', - '/src/shell/GameShell.js', '/src/shell/shellRuntime.js', '/src/statusActivityRows.js', ]; @@ -339,7 +337,9 @@ const ServiceWorkerCore = { console.log(`${logPrefix} ✓ Cached ${coreCachedCount}/${coreResources.length} core resources`); if (failedResources.length > 0) { - console.warn(`${logPrefix} Failed core resources:`, failedResources); + throw new Error( + `${logPrefix} Refusing incomplete install; failed core resources: ${JSON.stringify(failedResources)}` + ); } const staticCache = await caches.open(cacheNames.staticName); @@ -370,7 +370,7 @@ const ServiceWorkerCore = { } }, - async handleActivate(cacheNames, cachePrefix, logPrefix = '[SW]') { + async handleActivate(cacheNames, cachePrefix, cacheVersion, releaseInfo, logPrefix = '[SW]') { console.log(`${logPrefix} Service Worker activating...`); const existingCaches = await caches.keys(); @@ -388,6 +388,15 @@ const ServiceWorkerCore = { console.log(`${logPrefix} Service Worker activated`); console.log(`${logPrefix} Active caches: ${cachesToKeep.join(', ')}`); await self.clients.claim(); + + const clients = await self.clients.matchAll({ includeUncontrolled: true }); + clients.forEach(client => { + client.postMessage({ + type: 'SW_ACTIVATED', + version: cacheVersion, + release: releaseInfo + }); + }); }, async handleFetch(request, cacheNames, logPrefix = '[SW]', useNetworkFirstForHTML = false) { @@ -413,25 +422,12 @@ const ServiceWorkerCore = { } }, - async handleMessage(event, cacheName, cacheVersion, logPrefix = '[SW]') { + async handleMessage(event, cacheName, cacheVersion, releaseInfo, logPrefix = '[SW]') { console.log(`${logPrefix} Received message:`, event.data); if (event.data && event.data.type === 'SKIP_WAITING') { console.log(`${logPrefix} Skipping waiting and activating immediately...`); await self.skipWaiting(); - console.log(`${logPrefix} Service worker activated, claiming clients...`); - - await self.clients.claim(); - - const clients = await self.clients.matchAll({ includeUncontrolled: true }); - console.log(`${logPrefix} Claimed ${clients.length} client(s), ready for new version`); - - clients.forEach(client => { - client.postMessage({ - type: 'SW_ACTIVATED', - version: cacheVersion - }); - }); } if (event.data && event.data.type === 'GET_CACHE_INFO') { @@ -440,6 +436,10 @@ const ServiceWorkerCore = { version: cacheVersion }); } + + if (event.data && event.data.type === 'GET_RELEASE_INFO') { + event.ports[0].postMessage(releaseInfo); + } } }; diff --git a/sw-dev.js b/sw-dev.js index 8fa91ed..c2a3cab 100644 --- a/sw-dev.js +++ b/sw-dev.js @@ -1,7 +1,13 @@ // Service Worker for Kernel Panic - Development Version -const VERSION = '0.3.2b-dev'; +const VERSION = '0.3.4-dev'; +importScripts('/sw-release.js?v=0.3.3b'); importScripts(`/sw-core.js?v=${VERSION}`); +if (!self.KernelPanicRelease) { + throw new Error('[KernelPanic] Development release metadata is missing'); +} +const RELEASE_INFO = Object.freeze({ ...self.KernelPanicRelease, version: VERSION }); + const cacheConfig = CacheConfig.create(VERSION); const CACHE_VERSION = cacheConfig.version; const CACHE_NAMES = cacheConfig; @@ -33,7 +39,13 @@ self.addEventListener('install', event => { self.addEventListener('activate', event => { event.waitUntil( - ServiceWorkerCore.handleActivate(CACHE_NAMES, CACHE_PREFIX, LOG_PREFIX) + ServiceWorkerCore.handleActivate( + CACHE_NAMES, + CACHE_PREFIX, + CACHE_VERSION, + RELEASE_INFO, + LOG_PREFIX + ) ); }); @@ -53,9 +65,21 @@ self.addEventListener('fetch', event => { self.addEventListener('message', event => { if (event.data && event.data.type === 'SKIP_WAITING') { event.waitUntil( - ServiceWorkerCore.handleMessage(event, CACHE_NAMES.name, CACHE_VERSION, LOG_PREFIX) + ServiceWorkerCore.handleMessage( + event, + CACHE_NAMES.name, + CACHE_VERSION, + RELEASE_INFO, + LOG_PREFIX + ) ); } else { - ServiceWorkerCore.handleMessage(event, CACHE_NAMES.name, CACHE_VERSION, LOG_PREFIX); + ServiceWorkerCore.handleMessage( + event, + CACHE_NAMES.name, + CACHE_VERSION, + RELEASE_INFO, + LOG_PREFIX + ); } }); diff --git a/sw-release.js b/sw-release.js new file mode 100644 index 0000000..cf474b6 --- /dev/null +++ b/sw-release.js @@ -0,0 +1,12 @@ +// Canonical player-facing release metadata shared by production and dev workers. +// Increment shellEpoch only when the new app shell cannot safely coexist with +// the previous worker (for example, removed or renamed runtime modules). +self.KernelPanicRelease = Object.freeze({ + version: '0.3.3b', + shellEpoch: 2, + title: 'Update controls online', + highlights: Object.freeze([ + 'Release notes now accompany app updates.', + 'Breaking updates now require a safe restart.', + ]), +}); diff --git a/sw.js b/sw.js index 72d3db9..65e864f 100644 --- a/sw.js +++ b/sw.js @@ -1,8 +1,14 @@ // Service Worker for Kernel Panic - Production Version // Import shared caching core with cache-busting query parameter -const VERSION = '0.3.2b'; +const VERSION = '0.3.4'; +importScripts(`/sw-release.js?v=${VERSION}`); importScripts(`/sw-core.js?v=${VERSION}`); +if (!self.KernelPanicRelease || self.KernelPanicRelease.version !== VERSION) { + throw new Error(`[KernelPanic] Release metadata does not match worker version ${VERSION}`); +} +const RELEASE_INFO = self.KernelPanicRelease; + const cacheConfig = CacheConfig.create(VERSION); const CACHE_VERSION = cacheConfig.version; const CACHE_NAMES = cacheConfig; @@ -34,7 +40,13 @@ self.addEventListener('install', event => { self.addEventListener('activate', event => { event.waitUntil( - ServiceWorkerCore.handleActivate(CACHE_NAMES, CACHE_PREFIX, LOG_PREFIX) + ServiceWorkerCore.handleActivate( + CACHE_NAMES, + CACHE_PREFIX, + CACHE_VERSION, + RELEASE_INFO, + LOG_PREFIX + ) ); }); @@ -51,9 +63,21 @@ self.addEventListener('fetch', event => { self.addEventListener('message', event => { if (event.data && event.data.type === 'SKIP_WAITING') { event.waitUntil( - ServiceWorkerCore.handleMessage(event, CACHE_NAMES.name, CACHE_VERSION, LOG_PREFIX) + ServiceWorkerCore.handleMessage( + event, + CACHE_NAMES.name, + CACHE_VERSION, + RELEASE_INFO, + LOG_PREFIX + ) ); } else { - ServiceWorkerCore.handleMessage(event, CACHE_NAMES.name, CACHE_VERSION, LOG_PREFIX); + ServiceWorkerCore.handleMessage( + event, + CACHE_NAMES.name, + CACHE_VERSION, + RELEASE_INFO, + LOG_PREFIX + ); } }); diff --git a/tests/unit/serviceWorkerCore.test.ts b/tests/unit/serviceWorkerCore.test.ts new file mode 100644 index 0000000..2d695bf --- /dev/null +++ b/tests/unit/serviceWorkerCore.test.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +type WorkerCore = { + handleInstall( + cacheNames: { name: string; staticName: string }, + coreResources: string[], + staticAssets: string[], + logPrefix?: string + ): Promise; +}; + +type WorkerCacheConfig = { + getCoreResources(): string[]; +}; + +function loadWorkerCore(fetchImpl: (url: string) => Promise) { + const puts: Array<{ cache: string; url: string }> = []; + const self: Record = {}; + const sandbox = { + self, + URL, + console: { log() {}, warn() {}, error() {} }, + fetch: fetchImpl, + caches: { + async open(cacheName: string) { + return { + async put(url: string) { + puts.push({ cache: cacheName, url }); + }, + }; + }, + }, + }; + vm.runInNewContext(readFileSync('sw-core.js', 'utf8'), sandbox, { filename: 'sw-core.js' }); + return { + cacheConfig: self.CacheConfig as WorkerCacheConfig, + core: self.ServiceWorkerCore as WorkerCore, + puts, + }; +} + +test('every core precache URL maps to a repository source or static asset', () => { + const { cacheConfig } = loadWorkerCore(async () => ({ ok: true, status: 200 })); + const missing = Array.from(cacheConfig.getCoreResources()).filter(url => { + if (url === '/') return !existsSync('index.html'); + const relative = url.slice(1); + if (!relative.endsWith('.js')) return !existsSync(relative); + return !existsSync(relative.replace(/\.js$/, '.ts')); + }); + assert.deepEqual(missing, []); +}); + +test('service-worker install rejects an incomplete core precache', async () => { + const { core } = loadWorkerCore(async url => ({ + ok: url !== '/missing.js', + status: url === '/missing.js' ? 404 : 200, + })); + + await assert.rejects( + core.handleInstall( + { name: 'core-v2', staticName: 'static-v2' }, + ['/index.js', '/missing.js'], + ['/icon.svg'], + '[test]' + ), + /Refusing incomplete install.*missing\.js/ + ); +}); + +test('optional static-asset failure does not invalidate a complete core shell', async () => { + const { core, puts } = loadWorkerCore(async url => ({ + ok: url !== '/optional.png', + status: url === '/optional.png' ? 503 : 200, + })); + + await core.handleInstall( + { name: 'core-v2', staticName: 'static-v2' }, + ['/index.js'], + ['/optional.png'], + '[test]' + ); + assert.deepEqual(puts, [{ cache: 'core-v2', url: '/index.js' }]); +}); diff --git a/tests/unit/updateRelease.test.ts b/tests/unit/updateRelease.test.ts new file mode 100644 index 0000000..7798752 --- /dev/null +++ b/tests/unit/updateRelease.test.ts @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { parseUpdateRelease, requiresUpdateAcknowledgement } from '../../src/updateRelease.js'; + +const active = parseUpdateRelease({ + version: '1.0.0', + shellEpoch: 2, + title: 'Active release', + highlights: ['Already installed'], +}); + +test('same shell epoch keeps an update deferrable', () => { + const pending = parseUpdateRelease({ + version: '1.1.0', + shellEpoch: 2, + title: 'Compatible release', + highlights: ['New contract'], + }); + assert.equal(requiresUpdateAcknowledgement(active, pending), false); +}); + +test('higher shell epoch requires acknowledgement before continuing', () => { + const pending = parseUpdateRelease({ + version: '2.0.0', + shellEpoch: 3, + title: 'Breaking release', + highlights: ['App shell replaced'], + }); + assert.equal(requiresUpdateAcknowledgement(active, pending), true); +}); + +test('older pending shell epoch is rejected as a deployment error', () => { + const pending = parseUpdateRelease({ + version: '0.9.0', + shellEpoch: 1, + title: 'Downgrade', + highlights: ['Old shell'], + }); + assert.throws( + () => requiresUpdateAcknowledgement(active, pending), + /pending shell epoch 1 is older than active epoch 2/ + ); +}); + +test('release metadata rejects empty or malformed fields', () => { + assert.throws( + () => parseUpdateRelease({ version: '', shellEpoch: 1, title: 'Bad', highlights: ['x'] }), + /version/ + ); + assert.throws( + () => parseUpdateRelease({ version: '1', shellEpoch: 0, title: 'Bad', highlights: ['x'] }), + /shellEpoch/ + ); + assert.throws( + () => parseUpdateRelease({ version: '1', shellEpoch: 1, title: 'Bad', highlights: [] }), + /highlights/ + ); +});