-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
chore(service-worker): Scaffolding for calling postMessage with the service-worker #119085
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3299ddc
d64ddd6
201cfb7
2473cfe
6b77834
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| import {Button} from '@sentry/scraps/button'; | ||
| import {Flex, Stack} from '@sentry/scraps/layout'; | ||
| import {Text} from '@sentry/scraps/text'; | ||
|
|
||
| import {useServiceWorker} from 'sentry/serviceWorker/client/serviceWorkerContext'; | ||
| import * as Storybook from 'sentry/stories'; | ||
|
|
||
| export default Storybook.story('ServiceWorker', story => { | ||
| story('Basic Events', () => { | ||
| const {controller} = useServiceWorker(); | ||
|
|
||
| return ( | ||
| <Stack gap="md"> | ||
| <Flex> | ||
| <Button onClick={() => controller.postMessage({name: 'ping', type: 'event'})}> | ||
| Send Ping event | ||
| </Button> | ||
| </Flex> | ||
| <Text>Look in the console for the ping event.</Text> | ||
| </Stack> | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import {createContext, useContext, useEffect} from 'react'; | ||
| import * as Sentry from '@sentry/react'; | ||
|
|
||
| import {useFrontendVersion} from 'sentry/components/frontendVersionContext'; | ||
| import {ServiceWorkerController} from 'sentry/serviceWorker/client/serviceWorkerInterface'; | ||
|
|
||
| const DEBUG_LOGGING = false; | ||
|
|
||
| function log(message: string, options?: Sentry.metrics.MetricOptions) { | ||
| Sentry.metrics.count(`service-worker.register.${message}`, 1, options); | ||
| if (DEBUG_LOGGING) { | ||
| // eslint-disable-next-line no-console | ||
| console.log(`service-worker.register.${message}`); | ||
| } | ||
| } | ||
|
|
||
| function getWorkerUrl(): string { | ||
| return window.__SENTRY_DEV_UI ? '/entrypoints/service-worker.js' : '/service-worker.js'; | ||
| } | ||
|
|
||
| const Context = createContext({ | ||
| controller: new ServiceWorkerController(), | ||
| }); | ||
|
|
||
| export function ServiceWorkerProvider({children}: {children: React.ReactNode}) { | ||
| const context = useContext(Context); | ||
|
|
||
| useRegisterServiceWorker(); | ||
| useServiceWorkerUpdateCheck(); | ||
| useLogControllerChangeEvent(); | ||
|
|
||
| return <Context.Provider value={context}>{children}</Context.Provider>; | ||
| } | ||
|
|
||
| /** | ||
| * @public | ||
| * @returns The service worker context. | ||
| * @example | ||
| * const {controller} = useServiceWorker(); | ||
| * controller.postMessage({name: 'ping', type: 'event'}); | ||
| */ | ||
| export function useServiceWorker() { | ||
| return useContext(Context); | ||
| } | ||
|
|
||
| /** | ||
| * Register a service worker and send event `worker.init` to the newest worker | ||
| * available. | ||
| */ | ||
| function useRegisterServiceWorker() { | ||
| useEffect(() => { | ||
| if (!('serviceWorker' in navigator)) { | ||
| log('not-supported'); | ||
| return; | ||
| } | ||
|
|
||
| navigator.serviceWorker | ||
| // https://rspack.rs/guide/features/web-workers | ||
| .register(getWorkerUrl(), {scope: '/'}) | ||
| .then(registration => { | ||
| log('registered', { | ||
| attributes: { | ||
| // An old version could be active while the new instance is incoming | ||
| active: registration.active ? 'true' : 'false', | ||
| // The new instance should be `installing` to start, and should skip | ||
| // `waiting` to become `active` soon. | ||
| installing: registration.installing ? 'true' : 'false', | ||
| waiting: registration.waiting ? 'true' : 'false', | ||
| }, | ||
| }); | ||
|
|
||
| const worker = registration.installing; | ||
| worker?.addEventListener('statechange', () => { | ||
| log('statechange', { | ||
| attributes: { | ||
| state: worker.state, | ||
| }, | ||
| }); | ||
| }); | ||
| }) | ||
| .catch(error => { | ||
| log('error'); | ||
| Sentry.captureException(error); | ||
| }); | ||
| }, []); | ||
| } | ||
|
|
||
| function useServiceWorkerUpdateCheck() { | ||
| const {state} = useFrontendVersion(); | ||
|
|
||
| useEffect(() => { | ||
| if (!('serviceWorker' in navigator) || state === 'current') { | ||
| return; | ||
| } | ||
|
ryan953 marked this conversation as resolved.
|
||
|
|
||
| // A long-lived tab only learns about a new worker on the browser's | ||
| // infrequent periodic update check. Re-check whenever the tab becomes | ||
| // visible so a fresh deploy is picked up (trigger: install -> activate -> | ||
| // clients.claim -> controllerchange) as soon as the user returns to it. | ||
| const checkForUpdate = () => { | ||
| if (document.visibilityState !== 'visible') { | ||
| return; | ||
| } | ||
| log('update-check'); | ||
| navigator.serviceWorker.ready | ||
| .then(registration => registration.update()) | ||
| .catch(error => { | ||
| Sentry.captureException(error); | ||
| }); | ||
| }; | ||
|
|
||
| document.addEventListener('visibilitychange', checkForUpdate); | ||
| return () => { | ||
| document.removeEventListener('visibilitychange', checkForUpdate); | ||
| }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Stale SW update never runsMedium Severity
Reviewed by Cursor Bugbot for commit 6b77834. Configure here. |
||
| }, [state]); | ||
| } | ||
|
|
||
| function useLogControllerChangeEvent() { | ||
| useEffect(() => { | ||
| if (!('serviceWorker' in navigator)) { | ||
| return; | ||
| } | ||
|
|
||
| // Log whenever controllerchange happens, which means we have a new worker | ||
| // ready to listen to handle fetch and push requests. | ||
| // This event means that users have multiple tabs open at the same time, and | ||
| // the new workers are taking control of the pages. | ||
| const handler = () => log('controllerchange'); | ||
|
|
||
| navigator.serviceWorker.addEventListener('controllerchange', handler); | ||
| return () => { | ||
| navigator.serviceWorker.removeEventListener('controllerchange', handler); | ||
| }; | ||
| }, []); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import * as Sentry from '@sentry/react'; | ||
|
|
||
| import type {EventMessage} from 'sentry/serviceWorker/types'; | ||
|
|
||
| /** | ||
| * Sends messages from the page to the service worker. | ||
| * | ||
| * Every message must be an `EventMessage` (see `sentry/serviceWorker/types`). | ||
| * Use to notify the worker that something has happened on the page. | ||
| */ | ||
| export class ServiceWorkerController { | ||
| /** | ||
| * Find the service worker that this page should send messages to. | ||
| * | ||
| * Since we only need to post messages & not intercept network requests, | ||
| * we use `navigator.serviceWorker.ready` to get the active worker. | ||
| * We don't need to wait for the page to be controlled by the worker. | ||
| */ | ||
| private async getWorker(): Promise<ServiceWorker | null> { | ||
| if (!('serviceWorker' in navigator)) { | ||
| return null; | ||
| } | ||
| // For more read: https://web.dev/articles/service-worker-lifecycle | ||
| const registration = await navigator.serviceWorker.ready; | ||
| return registration.installing ?? registration.waiting ?? registration.active; | ||
|
ryan953 marked this conversation as resolved.
ryan953 marked this conversation as resolved.
|
||
| } | ||
|
|
||
| public postMessage(message: EventMessage): Promise<unknown> { | ||
| return Sentry.startSpan( | ||
| { | ||
| name: 'service-worker.controller', | ||
| op: 'sw.postMessage', | ||
| attributes: {type: message.type, name: message.name}, | ||
| }, | ||
| async () => { | ||
| const worker = await this.getWorker(); | ||
| if (!worker) { | ||
| return; | ||
| } | ||
| if (message.type === 'event') { | ||
| worker.postMessage(message); | ||
| return; | ||
| } | ||
| } | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| export type EventMessage = { | ||
| name: 'ping'; | ||
| type: 'event'; | ||
| }; |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this right?
installationproperty returns instance of a worker?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ya, it returns the worker that is in the 'installing' phase.
This is a good starting point imo: https://web.dev/articles/service-worker-lifecycle
But maybe i can distill it too:
navigator.serviceWorker.register(some_url)is called -> this downloads the script code, runs it in sw global scope (so you can setup event listeners), and triggers theinstalleventwaitingphase: the page is still 'uncontrolled'In this PR i'm using
postMessage()which doesn't depend on whether the page is 'controlled' or 'uncontrolled'. So really none of these steps above really have an effect on that, we just want to send our postMessage to the newest available worker version. This line in particular is just for logging that the newest version of the worker is spinning up fully.There are a few lines in this PR that skip the waiting period and get the newest code loaded and activated as quickly as possible;
skipWaiting()andclaim()calls. Since we're not doing the fetch interception thing I'm not worried right now about consistency problems that can happen if a new worker version get activated on a page that's already rendered. In future we haveuseFrontendVersion()which can help us rotate all tabs into the newest version to avoid those kinds of problems as well.