Skip to content

feat: [SDK-5120] inline notification icons from the service worker so CDN issues preserve the brand icon - #1531

Merged
sherwinski merged 11 commits into
sherwin/sdk-5597from
sherwin/sdk-5120
Oct 28, 2026
Merged

sherwinski merged 11 commits into
sherwin/sdk-5597from
sherwin/sdk-5120

Conversation

@sherwinski

@sherwinski sherwinski commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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 showNotification as a data: URL. For now, this is gated behind the sdk_web_inline_notification_images flag. 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, displayNotification resolves icon and badge through inlineImage before showNotification:

  1. fetch(url) with a 5 s timeout. A 2xx response with a Content-Type of image/png, image/jpeg, image/gif, or image/webp and a body of 128 KB or less is handed to the browser as data:<type>;base64,.... The body is read as a stream and dropped as soon as it passes the cap; a Content-Length over the cap skips the body.
  2. The 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.
  3. On a rejected fetch, a non-2xx response, a non-image response (for example a captive portal that answers 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.
  4. With no cached copy, the remote URL is passed as before.

The original image URL remains in the notification object in data, so click handlers and webhooks are not affected. The large image is not inlined.

On Chrome for macOS the server sets chrome_web_icon to a per-notification image URL at delivery time when an image is set. A notification with both global_image and chrome_web_image showed that the delivered icon and image can be two different URLs, so the worker does not compare them. Instead, when the notification has an image, the icon is inlined only if it equals the app default icon from the Options store. 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 copies config.siteInfo.defaultIconUrl from the server config into the Options store at init (initSaveState) for every app, and writes null when the app has no default icon. The worker reads that key only while isFeatureEnabledInWorker is 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 as defaultIconUrl, 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-Origin for the worker to read the bytes. img.os-content.com and img.onesignal.com do not send it today (confirmed by running curl -I on 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 the sdk_web_inline_notification_images constant and the isFeatureEnabledInWorker call in displayNotification. The os_feature_overrides localStorage key turns the flag on for local testing, in the page and in the worker.

Worker lifetime for DisplayNotification

The page sends DisplayNotification when the app calls display() after preventDefault(), so no push event keeps the worker alive. WorkerMessengerSW now passes the promise from each page message listener to event.waitUntil, so the worker stays alive while the image fetches run.

Included fix

ensureImageResourceHttps returned undefined when new 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 waitUntil in the messenger). Limits set to 43.33 kB and 13.1 kB.

Out of scope, needed before the flag can go on

  • Infra: Access-Control-Allow-Origin: * on img.os-content.com for GET/HEAD raster image responses: staging infra#7654, production infra#7615. img.onesignal.com is being decommissioned; The Sun's icon still resolves there until OneSignal#20459 backfills it.
  • Backend/ConfigCat: define sdk_web_inline_notification_images so GET apps/{appId}/sdk/features/web/{version} returns it for an app first.

Systems Affected

  • WebSDK
  • Backend
  • Dashboard

Validation

Tests

Info

  • vp test run — 64 files, 610 tests pass.
  • vp check — no new errors or warnings. main currently has 3 formatting issues and 2 type errors in Bell.test.ts from 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.
  • New tests: 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.ts DisplayNotification keeps the worker alive until the notification shows (1), init.test.ts default icon write (2), app.test.ts default 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 through os_feature_overrides, worker console read over CDP. Icon host: avatars.githubusercontent.com (sends Access-Control-Allow-Origin: *).

Send Payload Result
Chrome icon, no image icon = avatar URL Icon shown. showNotification got a 7,027-char data:image/jpeg URL; data.icon kept the remote URL; Cache Storage holds 1 entry (5,252 bytes).
Same, icon host blocked in the worker icon = avatar URL Live fetch failed (blocked), the cached bytes were used, icon shown. On main this is the grey letter.
Both image fields, no icon image = chrome_web_image URL, no icon Image shown remote; the worker made no request for it.
Site Setup default icon + both image fields icon = default icon URL, image = chrome_web_image URL Run before the default icon write: icon stayed remote (expected). The payload icon equals siteInfo.defaultIconUrl exactly.

Chrome for macOS (Arc) payload checks: with both image fields set the server delivers icon = global_image URL and image = chrome_web_image URL; with no image and no default icon it delivers no icon.

Checklist

  • All the automated tests pass or I explained why that is not possible
  • I have personally tested this on my machine or explained why that is not possible
  • I have included test coverage for these changes or explained why they are not needed

Programming Checklist
Interfaces:

  • Don't use default export
  • New interfaces are in model files

Functions:

  • Don't use default export
  • All function signatures have return types
  • Helpers should not access any data but rather be given the data to operate on.

Typescript:

  • No Typescript warnings
  • Avoid silencing null/undefined warnings with the exclamation point

Other:

  • Iteration: refrain from using elem of array syntax. Prefer forEach or use map
  • Avoid using global OneSignal accessor for context if possible. Instead, we can pass it to function/constructor so that we don't call OneSignal.context

Screenshots

Info

None yet; a device recording will follow the end-to-end run.

Checklist

  • I have included screenshots/recordings of the intended results or explained why they are not needed

Related Tickets

@sherwinski sherwinski changed the title feat: [SDK-5120] inline notification icons from the worker so a CDN blip keeps the brand icon feat: [SDK-5120] inline notification icons from the service worker so CDN issues preserve the brand icon Oct 13, 2026

@abdulraqeeb33 abdulraqeeb33 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/sw/helpers/inlineImage.ts Outdated
Comment thread src/sw/helpers/inlineImage.ts Outdated
Comment thread src/sw/helpers/inlineImage.ts Outdated
Comment thread src/shared/helpers/init.ts
@sherwinski
sherwinski marked this pull request as ready for review October 23, 2026 00:22
@fadi-george

Copy link
Copy Markdown
Contributor

Should make a stacked pr and split the feature flag work as the first pr

@sherwinski

Copy link
Copy Markdown
Contributor Author

Should make a stacked pr and split the feature flag work as the first pr

Fair enough

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Open in Web View Automation 

Sent by Cursor Automation: PR Reviews

Comment thread src/shared/helpers/init.ts Outdated
Comment thread src/shared/features/featureFlags.ts Outdated
Comment thread src/sw/helpers/inlineImage.ts Outdated
@sherwinski
sherwinski changed the base branch from main to sherwin/sdk-5597 October 23, 2026 00:55
@sherwinski
sherwinski added this pull request to stack #1535 October 23, 2026 17:57

@fadi-george fadi-george left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/shared/helpers/init.ts Outdated
Comment thread src/sw/serviceWorker/ServiceWorker.ts
Comment thread src/sw/helpers/inlineImage.ts
Comment thread src/sw/helpers/inlineImage.ts Outdated
Comment thread src/sw/serviceWorker/ServiceWorker.ts Outdated
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.
@fadi-george

fadi-george commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

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: fadi/sdk-5120-slim-inline-image. You can take it with git cherry-pick bc25d61b3. It takes the worker from 13.24 kB to 13.05 kB gzipped, about a quarter of what this PR adds, and behavior stays the same.

What the commit changes

src/sw/helpers/inlineImage.ts

  • Cache stores the data: URL string instead of raw bytes. cache.put(url, new Response(dataUrl)) on write, (await cache.match(url))?.text() on read. This removes ImageBytes, readCachedImage, and the content-type check on cached entries, since we only ever write our own strings. Tradeoff: each entry is about 33% bigger (base64), so at most 10 × ~170 KB.
  • Data URL built with new Blob(chunks, { type }) and FileReader.readAsDataURL. This replaces the chunk concat in readUpTo and the chunked btoa loop in toDataUrl. FileReader is available in service workers. type is the parsed content type, so parameters like ; charset=binary still get dropped.
  • Cache read, write, and prune folded into one try in inlineImage, with one Log._debug string. The debug strings survive the prod build, so two of them cost real bytes. cacheImage and pruneCache are inlined; prune is now keys.slice(0, -MAX_CACHED_IMAGES).
  • fetchImage becomes fetchDataUrl and returns null for too large instead of the 'too-large' sentinel. undefined still means "fall back to the cache", and an empty body still counts as a miss.
  • Kept as is: the 5 s timeout, the Content-Length check, the streamed 128 KB cap, and the type allowlist.

src/sw/helpers/inlineImage.test.ts

  • The two cache assertions now check expect(await cached!.text()).toBe(PNG_DATA_URL) instead of comparing raw bytes and the content-type header. All other tests are unchanged.

package.json

  • Worker size limit 13.25 kB → 13.1 kB.

Left out on purpose

  • Reading with response.blob() instead of streaming saves about 50 B more, but a response with no Content-Length would be read fully into memory, limited only by the timeout.
  • Accepting any image/* saves about 30 B, but lets SVG and other formats through.

Checks: all 610 tests pass, vp check has no new warnings, and build:prod passes with the new limit.

New src/sw/helpers/inlineImage.ts
import 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>
@sherwinski

Copy link
Copy Markdown
Contributor Author

Thanks, taken as is: cherry-picked as 100fc02. 610 tests, vp check, and build:prod pass; worker is 13.05 kB with the 13.1 kB limit. I will run the production end-to-end check on the emulator with this build once the ConfigCat flag reaches the SDK endpoint, which also exercises FileReader inside the service worker.

@sherwinski
sherwinski dismissed abdulraqeeb33’s stale review October 28, 2026 16:38

All previous review comments addressed

@sherwinski
sherwinski merged commit 544ee76 into main Oct 28, 2026
3 of 4 checks passed
@sherwinski
sherwinski deleted the sherwin/sdk-5120 branch October 28, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants