Repository navigation
feat: [SDK-5120] inline notification icons from the service worker so CDN issues preserve the brand icon - #1531
Conversation
abdulraqeeb33
left a comment
There was a problem hiding this comment.
A failed fetch falls back to the cache, then the remote URL, and the click path still gets the original URL. A good download is dropped when the cache write throws, and any small 2xx body is stored with its raw content type.
initSaveState writes defaultIcon for every user. The worker already uses that key when a payload has no icon, and nothing in src wrote it before, so those notifications start showing the site icon with the flag off.
refreshFeatureFlags writes localStorage and then db.put. That put resolves without writing when IndexedDB is wedged, and the worker only reads IndexedDB, so a flag turned off on the page can stay on in the worker.
The flag-on service worker test returns the same bytes for every URL and expects the icon and the badge to be the same data URL, so swapping them still passes.
|
Should make a stacked pr and split the feature flag work as the first pr |
Fair enough |
There was a problem hiding this comment.
Multi-model review
Act on (3/3): void refreshFeatureFlags() is not joined before initSaveState reads isFeatureEnabled to persist defaultIcon. get() retries 10s/20s/30s, so the load that turns the flag on typically writes defaultIcon: null while the worker mirror later becomes on. displayNotification then cannot fill a missing icon or treat the Site Setup icon as default when image is present. The off-transition leaves defaultIcon set after the worker flag is cleared. Write defaultIcon from the list refreshFeatureFlags actually stored, or await that refresh (including failure) before the Options write.
Consider (2/3): os_feature_flags and Options.featureFlags are not keyed by app id, and the app-id reset in initSaveState does not clear them. A failed or in-flight features request after an app swap inherits the previous app's rollout (including sdk_identity_verification).
Consider (2/3): With the flag on, showNotification waits up to 5s for icon/badge, and notifications in one push are shown sequentially. A dead CDN — the case this feature targets — delays display by the full timeout, then the browser fetches the remote URL again.
Consider (1/3, verified): readUpTo returns an empty Uint8Array for a 0-length 2xx image response. That value is truthy, so cache.put replaces the last good copy with empty bytes.
Noted: The worker kill switch needs a later page visit. CORS-less hosts stay a silent extra fetch (called out in the PR). icon === defaultIcon is an exact string match (author confirmed payload equality). A late-committing wedged db.put can still diverge the page list from the worker list.
Dismissed: Writing defaultIcon: null when the flag is off (intentional after the last review). ensureImageResourceHttps now returning the original string (intentional, ungated). The comment that data stays as received (title/icon were already mutated before this PR).
Sent by Cursor Automation: PR Reviews
d024b51 to
e0c60a6
Compare
e0c60a6 to
3593213
Compare
fadi-george
left a comment
There was a problem hiding this comment.
Non-blocking: src/shared/libraries/workerMessenger/sw.ts:52 (not in this diff), the DisplayNotification message handler's promise is dropped with no event.waitUntil. That was fine when it showed the notification right away, but it now waits up to 5s on image fetches first. Worth wrapping it so the worker stays alive until it's shown.
ensureImageResourceHttps returned undefined when new URL() threw, which dropped the icon from the notification. Production logs show this TypeError for customer payloads. The browser may still load such a URL, so return it unchanged instead.
…eature flag Chromium fetches notification icons at display time with no fallback to the HTTP cache or Cache Storage. On Android a failed fetch shows a grey letter placeholder in place of the brand icon, so a short outage of the image CDN marks every notification in that window. When the sdk_web_inline_notification_images flag is on, the worker now: 1. Fetches the icon and the badge inside the push handler with a 5 s timeout. 2. Stores the bytes in Cache Storage (10 most recent images) and passes them to showNotification as a data: URL. 3. On a failed fetch, uses the last cached copy. With no cached copy, or for an image over 128 KB, it passes the remote URL as before. The notification object in `data` keeps the original URLs. A cross-origin image host must send Access-Control-Allow-Origin for the worker to read the bytes; without it the fetch rejects and the remote URL is used. The worker has no localStorage, so refreshFeatureFlags now mirrors the active flag list (server list plus local overrides) into the IndexedDB Options store, and isFeatureEnabledInWorker reads it there.
…ge image On Chrome for macOS the server replaces chrome_web_icon with the chrome_web_image URL. That per-notification image should not be inlined or cached, so the worker skips inlining when the icon equals the image. The badge is still inlined. Raises the worker size limit to the measured 12.96 kB.
A real send with both global_image and chrome_web_image set shows that the server can deliver an icon that is one image URL while image is a different one, so an icon === image check misses the macOS swap. The swap only happens when an image is present, so the worker now inlines the icon in that case only when it equals the app default icon from the Options store. A per-notification icon with an image stays a remote URL. Raises the worker size limit to the measured 12.97 kB.
…pped image The worker read Options.defaultIcon but no code wrote it, so the guard that inlines only the app default icon when a notification has an image never matched. The page now copies siteInfo.defaultIconUrl from the server config into the Options store at init, and clears it when the app has no default icon. A real send confirmed the server delivers the Site Setup icon as the exact same string.
Review follow-ups on the inline images change: - Build the data URL before the cache write, and treat the cache write and read as best effort, so a quota error does not drop bytes that were already fetched. - Inline and cache only image/png, image/jpeg, image/gif, and image/webp. Any other response falls back to the cached copy, which also covers a captive portal that answers 200 text/html. - Return before the body when Content-Length is over 128 KB, and stop the stream once the cap is passed instead of buffering the whole body. - Write Options.defaultIcon only while the flag is on, so the existing no-icon fallback in the worker does not turn on for every user. - Give each URL its own bytes in the worker test so a swapped icon and badge would fail.
initSaveState read the page flag list before refreshFeatureFlags finished, so the load that turned the flag on stored null and the load that turned it off could store the URL again. The worker filled a missing icon from that key with no flag check, so a stale write kept the fill on after the flag went off. The page now always stores siteInfo.defaultIconUrl. The worker reads it only while isFeatureEnabledInWorker is on, for the missing-icon fill and for the app default icon check, so the flag applies as soon as the mirror updates.
…pty body A valid image over 128 KB took the failed-fetch path, so after the icon at a URL was replaced with a larger file, the old cached copy kept showing while the network worked. The worker now passes the remote URL in that case. A 2xx image response with an empty body was cached and returned as an empty data URL, which replaced the last good copy. An empty body now counts as a failed fetch, so the cached copy is used.
WorkerMessengerSW called each listener and dropped its promise. The DisplayNotification handler now waits up to 5 s for image fetches before showNotification, and that message has no push event to keep the worker alive, so the browser could stop the worker before the notification showed. The message event now waits for the promises that the listeners return.
3593213 to
101db79
Compare
|
Lgtm but could do some size improvements. See: Idea for trimming the worker bundle, not blocking. I put it in one commit on top of this PR's head: What the commit changes
Left out on purpose
Checks: all 610 tests pass, New src/sw/helpers/inlineImage.tsimport Log from 'src/shared/libraries/Log';
const CACHE_NAME = 'onesignal-notification-images';
const FETCH_TIMEOUT_MS = 5_000;
const MAX_INLINE_BYTES = 128 * 1024;
const MAX_CACHED_IMAGES = 10;
const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
// Chromium fetches notification images at display time and shows a placeholder
// when that fetch fails. The worker fetches the image first, keeps the last
// good copy in Cache Storage, and hands the browser the bytes as a data URL.
// A cross-origin host must send Access-Control-Allow-Origin for the worker
// to read the bytes; otherwise the remote URL is used as before.
export async function inlineImage(url: string | undefined): Promise<string | undefined> {
if (!url || url.startsWith('data:')) return url;
const fresh = await fetchDataUrl(url);
if (fresh === null) return url;
try {
const cache = await caches.open(CACHE_NAME);
if (!fresh) return (await (await cache.match(url))?.text()) || url;
await cache.put(url, new Response(fresh));
// Cache keys come back in insertion order, so the first keys are the oldest.
const keys = await cache.keys();
await Promise.all(keys.slice(0, -MAX_CACHED_IMAGES).map((key) => cache.delete(key)));
} catch (e) {
Log._debug('notification image cache failed:', e);
}
return fresh || url;
}
// Resolves to null for a valid image over the inline limit, since the cached
// copy may be an older image at the same URL. Resolves to undefined for a
// failed fetch, a non-image response, or an empty body, so the caller falls
// back to the cached copy.
async function fetchDataUrl(url: string): Promise<string | null | undefined> {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
const type = response.headers.get('content-type')?.split(';')[0].trim().toLowerCase();
if (!response.ok || !type || !IMAGE_TYPES.includes(type) || !response.body) {
void response.body?.cancel();
return undefined;
}
if (Number(response.headers.get('content-length')) > MAX_INLINE_BYTES) {
void response.body.cancel();
return null;
}
const reader = response.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_INLINE_BYTES) {
void reader.cancel();
return null;
}
chunks.push(value);
}
return total ? await readDataUrl(new Blob(chunks, { type })) : undefined;
} catch {
return undefined;
}
}
function readDataUrl(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} |
Cache the data URL string instead of the raw bytes, build it with Blob and FileReader, and fold the cache read, write, and prune into one block. Worker bundle drops from 13.24 kB to 13.05 kB gzipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, taken as is: cherry-picked as 100fc02. 610 tests, |
All previous review comments addressed


Description
1 Line Summary
The worker fetches the notification icon and badge, keeping the last good copy in Cache Storage, and passes the bytes to
showNotificationas adata:URL. For now, this is gated behind thesdk_web_inline_notification_imagesflag. Stacked on #1534, which adds the flag reader.Details
Chromium fetches notification icons at display time and has no fallback when that fetch fails. On Android a failed fetch shows a grey letter placeholder in place of the brand icon, corresponding to the first letter of the host site's URL e.g.
W.Worker change (
src/sw/helpers/inlineImage.ts)When the flag is on,
displayNotificationresolvesiconandbadgethroughinlineImagebeforeshowNotification:fetch(url)with a 5 s timeout. A 2xx response with aContent-Typeofimage/png,image/jpeg,image/gif, orimage/webpand a body of 128 KB or less is handed to the browser asdata:<type>;base64,.... The body is read as a stream and dropped as soon as it passes the cap; aContent-Lengthover the cap skips the body.data:URL is also written to Cache Storage (onesignal-notification-images, 10 most recent entries). The write is best effort: a quota error is logged and the fetched image is still used.200 text/html), or an empty body, the last cached copy for that URL is used. A valid image over the cap uses the remote URL, because the cached copy can be an older image at the same URL.The original image URL remains in the notification object in
data, so click handlers and webhooks are not affected. The largeimageis not inlined.On Chrome for macOS the server sets
chrome_web_iconto a per-notification image URL at delivery time when an image is set. A notification with bothglobal_imageandchrome_web_imageshowed that the deliverediconandimagecan be two different URLs, so the worker does not compare them. Instead, when the notification has animage, the icon is inlined only if it equals the app default icon from theOptionsstore. A per-notification icon that comes with an image stays a remote URL and is never cached. The badge is still inlined.The worker already read
Options.defaultIcon, but no code wrote it. The page now copiesconfig.siteInfo.defaultIconUrlfrom the server config into theOptionsstore at init (initSaveState) for every app, and writesnullwhen the app has no default icon. The worker reads that key only whileisFeatureEnabledInWorkeris on, both to fill a missing icon and to check for the app default icon. The page write does not read the flag, so a stale page list at init has no effect, and the flag applies as soon as the worker mirror updates. Testing confirmed that a notification includes the Site Setup icon in the payload as the exact same string asdefaultIconUrl, so the comparison should hold. The cache key is the full URL in every case, so a cached brand icon is never shown in place of a different image.Dependencies
A cross-origin host must send
Access-Control-Allow-Originfor the worker to read the bytes.img.os-content.comandimg.onesignal.comdo not send it today (confirmed by runningcurl -Ion 2026-10-13), so for those hosts the fetch rejects and the behavior is unchanged until we add the header via infra.Feature flag
Stacked on #1534 (
sherwin/sdk-5597), which adds the flag reader and the IndexedDB mirror the worker reads. This PR adds thesdk_web_inline_notification_imagesconstant and theisFeatureEnabledInWorkercall indisplayNotification. Theos_feature_overrideslocalStorage key turns the flag on for local testing, in the page and in the worker.Worker lifetime for
DisplayNotificationThe page sends
DisplayNotificationwhen the app callsdisplay()afterpreventDefault(), so no push event keeps the worker alive.WorkerMessengerSWnow passes the promise from each page message listener toevent.waitUntil, so the worker stays alive while the image fetches run.Included fix
ensureImageResourceHttpsreturnedundefinedwhennew URL()threw, which dropped the image from the notification. It now returns the original string.Bundle size
Over the flag reader PR: page 43.29 kB → 43.32 kB (+30 B: the default icon write). Worker 12.48 kB → 13.05 kB (+570 B: the fetch, the streamed read with the cap, the type allowlist, the cache, and the
waitUntilin the messenger). Limits set to 43.33 kB and 13.1 kB.Out of scope, needed before the flag can go on
Access-Control-Allow-Origin: *onimg.os-content.comforGET/HEADraster image responses: staging infra#7654, production infra#7615.img.onesignal.comis being decommissioned; The Sun's icon still resolves there until OneSignal#20459 backfills it.sdk_web_inline_notification_imagessoGET apps/{appId}/sdk/features/web/{version}returns it for an app first.Systems Affected
Validation
Tests
Info
vp test run— 64 files, 610 tests pass.vp check— no new errors or warnings.maincurrently has 3 formatting issues and 2 type errors inBell.test.tsfrom the Vite+ 1.3.0 upgrade (chore: upgrade to Vite+ 1.3.0 and TypeScript 7 #1532); they are unchanged here.vp run build:prod— passes with the new limits.src/sw/helpers/inlineImage.test.ts(19),ServiceWorker.test.ts > push event > notification images(9, each URL gets its own bytes; with the flag off a stored default icon does not fill a missing icon),ServiceWorker.test.tsDisplayNotification keeps the worker alive until the notification shows (1),init.test.tsdefault icon write (2),app.test.tsdefault icon mapping (1).Manual test on Chrome for Android
Pixel 9 emulator (Android 17, Chrome 154), dev build from this branch served over
adb reverse, flag on throughos_feature_overrides, worker console read over CDP. Icon host:avatars.githubusercontent.com(sendsAccess-Control-Allow-Origin: *).icon= avatar URLshowNotificationgot a 7,027-chardata:image/jpegURL;data.iconkept the remote URL; Cache Storage holds 1 entry (5,252 bytes).icon= avatar URLblocked), the cached bytes were used, icon shown. Onmainthis is the grey letter.image=chrome_web_imageURL, noiconicon= default icon URL,image=chrome_web_imageURLiconequalssiteInfo.defaultIconUrlexactly.Chrome for macOS (Arc) payload checks: with both image fields set the server delivers
icon=global_imageURL andimage=chrome_web_imageURL; with no image and no default icon it delivers noicon.Checklist
Programming Checklist
Interfaces:
Functions:
Typescript:
Other:
elem of arraysyntax. PreferforEachor usemapcontextif possible. Instead, we can pass it to function/constructor so that we don't callOneSignal.contextScreenshots
Info
None yet; a device recording will follow the end-to-end run.
Checklist
Related Tickets