vite-pwa / vite-plugin-pwa

Zero-config PWA for Vite
https://vite-pwa-org.netlify.app/
MIT License
3.2k stars 209 forks source link

Bust cache after a release? #33

Closed scambier closed 3 years ago

scambier commented 3 years ago

Hello, I'm not well versed in PWA settings, and your plugin works really well as a 0-config tool (thanks for that).

However, I'm faced with a cache issue when I'm redeploying my app. In that case, I had to rename several api endpoints on my backend, but once the Vue app was built and deployed, all I got was 404 errors because I was still served the app from the cache. Since it's still in dev I simply manually unregistered the worker, but I what to do if that happens in production?

If I understand correctly, even if all my .js files and assets have a versioned hash in their name, as long as the index.html file is cached, it will still serve the old assets.

Is there a configuration I can setup (maybe it's still WIP on your side) to fix this, or if applicable, is there something you recommend? I found several solutions for this issue, but nothing looks really clean or fail-proof.

userquin commented 3 years ago

Hi,

There is a pending think to do about refresh on new content detected.

You can use register-service-worker, and do a manual installation of service worker via VitePWA configuration on vite.config.ts file, something like this:

useServiceWorker.ts

import type { Ref } from 'vue'

import { register } from 'register-service-worker'
import { ref } from 'vue'

// https://medium.com/google-developer-experts/workbox-4-implementing-refresh-to-update-version-flow-using-the-workbox-window-module-41284967e79c
// https://github.com/yyx990803/register-service-worker
// https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading
export const useServiceWorker = (): {
  appNeedsRefresh: Ref<boolean>
  offlineAppReady: Ref<boolean>
} => {
  const offlineAppReady = ref(false)
  const appNeedsRefresh = ref(false)

  register(
    '/sw.js',
    {
      registrationOptions: { scope: '/' },
      updated: async() => { appNeedsRefresh.value = true },
      cached: () => { offlineAppReady.value = true },
    },
  )

  return {
    appNeedsRefresh,
    offlineAppReady,
  }
}

then on yourmain.ts file (entry point), just import it and then watch appNeedsRefresh and include:

const {
  appNeedsRefresh,
  offlineAppReady,
} = useServiceWorker()
watch(appNeedsRefresh, async() => {
  window.location.reload()
}, { immediate: true })

obviously, you will need to add some button on the screen, then show/enable it using appNeedsRefresh and then add an @click listener to this button to call window.location.reload().

scambier commented 3 years ago

Thanks, that looks like a reasonable solution. I'll keep this ticket open until I implement this properly :)

userquin commented 3 years ago

@antfu As soon I have time I'll try to fix it using workbox instead register-service-worker, I'm very busy at work...

In the meantime this is the approach I'm using on my projects with vitesse template.

userquin commented 3 years ago

@antfu I have a problem about using workbox-util, let me explain:

Then, the problem arises, I need to include workbox-util as a dependency not as a dev dependency: the problem is not here, is on the target project, for example, vitesse template where this plugin is installed as a dev dependency, and so, when building...

To put you in context:

My first attempt was to configure my pwa like this (vite.config.ts):

    VitePWA({
      ...
      workbox: {
        cleanupOutdatedCaches: true,
        skipWaiting: true,
        clientsClaim: true,
      }
    }),
    ...

This aproach has a problem, the pages remains on the cache, also after a refresh and my pages stop working, because I removed the old registered service worker (cleanupOutdatedCaches): refresing page with F5 in online mode, request are served from the cache, so the oldest assets are missing from my server and the page stop working.

My second attemp was just removing cleanupOutdatedCaches, this will at least keep my pages working, but with oldest assets!!!.

The problem with activating skipWaiting is that the pages remains with the original assets links, and there is no way no bypass it.

The solution described in Offer a page reload for users seems to work, but I haven't test it yet.

In this link you can find the problem and how can be solved, in fact it points to the Offer a page reload for users, just read it.

We can also find a warning in workbox using skipWaiting here

antfu commented 3 years ago

Thanks a lot for the detailed info!

Offer a page reload for users

This looks like a good solution to me! We can make an option for people to opt-in with this behavior.

cleanupOutdatedCaches & skipWaiting

Do we need these tho?

userquin commented 3 years ago

No, just to remove both and do it manually with the Offer a page reload for users script: the script (module) included there is what we need to create here

userquin commented 3 years ago

this script will replace manual registration: what this plugin currently does will be replace with the script in Offer a page reload for users

userquin commented 3 years ago

What I had in mind was to include useServiceWorker in @vueuse using workbox-util and include the logic described in Offer a page reload for users with a callback: createUIPrompt would be an option to useServiceWorker.

And then, here is the problem again (mixing build time and runtime)...

antfu commented 3 years ago

I think maybe we don't need to be Vue specific (this plugin is framework-agnostic).

We could update this https://github.com/antfu/vite-plugin-pwa/blob/c2054640b1a81650a8172e1fe1dadb28178e4957/src/html.ts#L9-L16 to put the snippet from Offer a page reload for users and ask users to install workbox-window if they want to have this feature.

After that, we can provide a minimal example of how to set it up for people to explore. (and Vitesse as well for sure)

userquin commented 3 years ago

The problem with this approach is that is not tied to the app ui, that is, we need to add a callback, for example, with my first approach using register-service-worker using appNeedsRefresh.

I think it would be nice to add the useServiceWorker here with the logic, exposing this 2 guys (appNeedsRefresh and offlineAppReady, and expose some wrapper callback to be called from the ui once the user click on the refresh option.

userquin commented 3 years ago

As you can see the problem is mixing again buildtime and runtime, we need to interact with the ui to activated the service worker.

userquin commented 3 years ago

My suggestion is to include a new custom option and generate useServiceWorker.ts, but where to put it?. Then instruct/ask the user to include workbox-build/workbox-window as dependency and show how to use it (something similar in my response to @scambier).

antfu commented 3 years ago

I am thinking about we can serve the register script in a virtual module, so the usage would be like this, where they can be bundled with their UI logics

// virtual module
import registerSW from 'vite-plugin-pwa-register'

const sw = registerSW({
  onNeedRefresh() {
    // show an UI for user to refresh
  }
})
userquin commented 3 years ago

wait to @scambier response, I think it must work (if not using skipWaiting , clientsClaim and cleanupOutdatedCaches)...

scambier commented 3 years ago

@userquin

You can use register-service-worker, and do a manual installation of service worker via VitePWA configuration on vite.config.ts file, something like this: ...

The code you proposed actually doesn't solve my issue it seems. In the end it just triggers a location.reload(), but a simple refresh isn't enough. Even a ctrl+f5 with the cache disabled in the network tab doesn't work, I have to unregister the service worker to finally get the latest files.

userquin commented 3 years ago

@scambier yes, this is the problem I have, but with my SPA acting as a MPA (my server build route pages instead just returning/forwarding to index.html), and I havenn't tested on a real SPA, your answer confirms my suspicions...

can you try this one (just change the callback)?:

updated: async(registration) => { 
  registration.update()
  appNeedsRefresh.value = true
},

@antfu try to make a branch where we can test it before merging into master, the problem is I haven't time...

userquin commented 3 years ago

upps, add await keyword: await registration.update()

userquin commented 3 years ago

@scambier the problem is that the service worker is installed but not activated, we need to provide a callback to the service worker to control the opened pages/tabs: the lifecycle of the service worker is a little complicated.

What I'm talking with @antfu is to try to activate the service worker once is updated, but we need to change the code.

For example, vuejs 3 (using workbox 4, here we are using 6.1.1), has some code, I'll try to investigate, to activate and claim clients to take control of opened windows/tabs controlled by the service worker (I need to see where attaching the listener to show the dialog for New content available):

from https://v3.vuejs.org/ => at the top of the service-worker.js file

self.addEventListener('message', (event) => {
  if (event.data && event.data.type === 'SKIP_WAITING') {
    self.skipWaiting();
  }
});

from https://v3.vuejs.org/ => at the bottom of the service-worker.js file

addEventListener('message', event => {
  const replyPort = event.ports[0]
  const message = event.data
  if (replyPort && message && message.type === 'skip-waiting') {
    event.waitUntil(
      self.skipWaiting().then(
        () => replyPort.postMessage({ error: null }),
        error => replyPort.postMessage({ error })
      )
    )
  }
})
userquin commented 3 years ago

@antfu vuejs next docs is using @vuepress/pwa and here is the code generating the service-worker.js file (similiar to this one but touching also the app entry).

It uses a global registered component to show the dialog on new content available.

userquin commented 3 years ago

Following the code, it seems using Offer a page reload for users can simplify the logic on @vuepress/pwa: see lib directory.

scambier commented 3 years ago

@scambier yes, this is the problem I have, but with my SPA acting as a MPA (my server build route pages instead just returning/forwarding to index.html), and I havenn't tested on a real SPA, your answer confirms my suspicions...

can you try this one (just change the callback)?:

updated: async(registration) => { 
  registration.update()
  appNeedsRefresh.value = true
},

@antfu try to make a branch where we can test it before merging into master, the problem is I haven't time...

This seems to do the job.

Here's what I have at the moment:

useServiceWorker.ts

register(
    '/sw.js',
    {
      registrationOptions: { scope: '/' },
      updated: async (registration) => {
        await registration.update()
        registration.unregister()
        appNeedsRefresh.value = true
      },
      cached: () => {
        offlineAppReady.value = true
      }
    }
  )

main.ts

  const { appNeedsRefresh, offlineAppReady } = useServiceWorker()
  watch(appNeedsRefresh, async (val) => {
    if (val) {
      console.log('app updated and sw unregistered, will refresh')
      // TODO: prompt user
      location.reload()
    }
  }, { immediate: true })

There's just a point I'm missing. In your first response, you wrote

do a manual installation of service worker via VitePWA configuration

But I'm not quite sure which setting to change.

Edit: also, I guess unregistering the worker before showing the prompt isn't really a good idea, but I'll change that later. For now I just need to properly clean the cache after an update :)

userquin commented 3 years ago

vite.config.ts

VitePWA({
  injectRegister: null,
})
userquin commented 3 years ago

to clear the cache use this:

vite.config.ts


VitePWA({
  ...
  workbox: {
    cleanupOutdatedCaches: true,
  }
}),
userquin commented 3 years ago

if you don't have injectRegister: null you're registering the service worker twice, former in the html and second one in main.ts.

Setting injectRegister: null will just remove the registration of the sw from the index.html file (see current generated index.html and you will see navigator.serviceWorker.register or a script tag pointing to registerSW.js).

And please, confirm that update call just works or adding unregister.

userquin commented 3 years ago

Testing on my MPA it seems we need to unregister first, then update and then refresh page, but still service worker not controlling page/tabs.

About the cache @scambier see screenshot below.

I'm checking if clientsClain: true, will do the work...

@antfu With this approach there is no need to modify code, just wait until my test can confirm that works...

imagen

scambier commented 3 years ago

Ok, the app correctly cleans the cache after a new deployment, but only if I refresh or open a new tab. And in that case, it only reloads the first tab. The updated: async (registration) => {} never triggers by itself after a new deployment.

To recap, here's what I currently have.

useServiceWorker.ts:

import type { Ref } from 'vue'

import { register } from 'register-service-worker'
import { ref } from 'vue'

// https://medium.com/google-developer-experts/workbox-4-implementing-refresh-to-update-version-flow-using-the-workbox-window-module-41284967e79c
// https://github.com/yyx990803/register-service-worker
// https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading
export const useServiceWorker = (): {
  appNeedsRefresh: Ref<boolean>
  offlineAppReady: Ref<boolean>
} => {
  const offlineAppReady = ref(false)
  const appNeedsRefresh = ref(false)

  register(
    '/sw.js',
    {
      registrationOptions: { scope: '/' },
      updated: async (registration) => {
        await registration.update()
        registration.unregister()
        appNeedsRefresh.value = true
      },
      cached: () => {
        offlineAppReady.value = true
      }
    }
  )

  return {
    appNeedsRefresh,
    offlineAppReady
  }
}

main.ts (after createApp(App)):

  const { appNeedsRefresh } = useServiceWorker()
  watch(appNeedsRefresh, async (val) => {
    console.log('Does app need refresh? ' + val)
    if (val) {
      // if (confirm('App updated. Do you want to refresh?')) {
      location.reload()
      // }
    }
  }, { immediate: true })

PWA config in vite.config.ts:

VitePWA({
      injectRegister: null,
      manifest: {
        /* */
      },
      workbox: {
        cleanupOutdatedCaches: true
      }

    })

Thanks a lot for your explanations. I'll take time to read some more doc about service workers next week :)

userquin commented 3 years ago

I'm trying to simulate Workbox approach, just copy/paste workbox-window into my MPA: if working I'll provide the virtual module to be used...

userquin commented 3 years ago

@antfu confirmed that works at least on my MPA with the / route, below the virtual module.

Just update the imports for workbox-window, remove the import for useRouter and remove the router.isReady() callback and remove all unnecesary comments.

The usage is calling updateServiceWorker instead reload the window once the user click on the UI once appNeedsRefresh is activated.

import type { Ref } from 'vue'

import { Workbox } from '/~/logics/service-worker/Workbox'
import { messageSW } from '/~/logics/service-worker/messageSW'

import { ref } from 'vue'
import { useRouter } from 'vue-router'

export const useServiceWorker = (immediate = false): {
  offlineAppReady: Ref<boolean>
  appNeedsRefresh: Ref<boolean>
  updateServiceWorker: () => Promise<void>
} => {
  const offlineAppReady = ref(false)
  const appNeedsRefresh = ref(false)
  const router = useRouter()

  let registration: ServiceWorkerRegistration
  let wb: Workbox

  const updateServiceWorker = async() => {
    // Assuming the user accepted the update, set up a listener
    // that will reload the page as soon as the previously waiting
    // service worker has taken control.
    wb.addEventListener('controlling', (event) => {
      if (event.isUpdate)
        window.location.reload()
    })

    if (registration && registration.waiting) {
      // Send a message to the waiting service worker,
      // instructing it to activate.
      // Note: for this to work, you have to add a message
      // listener in your service worker. See below.
      await messageSW(registration.waiting, { type: 'SKIP_WAITING' })
    }
  }

  router.isReady().then(() => {
    if ('serviceWorker' in navigator) {
      wb = new Workbox('/sw.js', { scope: '/' })

      const showSkipWaitingPrompt = () => {
        // `event.wasWaitingBeforeRegister` will be false if this is
        // the first time the updated service worker is waiting.
        // When `event.wasWaitingBeforeRegister` is true, a previously
        // updated service worker is still waiting.
        // You may want to customize the UI prompt accordingly.

        // Assumes your app has some sort of prompt UI element
        // that a user can either accept or reject.
        appNeedsRefresh.value = true
      }

      wb.addEventListener('controlling', (event) => {
        if (!event.isUpdate)
          offlineAppReady.value = true
      })
      // Add an event listener to detect when the registered
      // service worker has installed but is waiting to activate.
      wb.addEventListener('waiting', showSkipWaitingPrompt)
      // @ts-ignore
      wb.addEventListener('externalwaiting', showSkipWaitingPrompt)

      wb.register({ immediate }).then(r => registration = r!)
    }
  })

  return {
    offlineAppReady,
    appNeedsRefresh,
    updateServiceWorker,
  }
}
userquin commented 3 years ago

THIS WILL NOT WORK, SO FORGET IT: seems we need to register controlling event to the right registration (this approach will register on old sw) SO USE MODULE FROM PREVIOUS COMMENT.

after a revision:

import type { Ref } from 'vue'

import { Workbox } from '/~/logics/service-worker/Workbox'
import { messageSW } from '/~/logics/service-worker/messageSW'

import { ref } from 'vue'
import { useRouter } from 'vue-router'

export const useServiceWorker = (immediate = false): {
  offlineAppReady: Ref<boolean>
  appNeedsRefresh: Ref<boolean>
  updateServiceWorker: () => Promise<void>
} => {
  const offlineAppReady = ref(false)
  const appNeedsRefresh = ref(false)
  const router = useRouter()

  let registration: ServiceWorkerRegistration

  const updateServiceWorker = async() => {
    if (registration && registration.waiting) {
      // Send a message to the waiting service worker,
      // instructing it to activate.
      // Note: for this to work, you have to add a message
      // listener in your service worker. See below.
      await messageSW(registration.waiting, { type: 'SKIP_WAITING' })
    }
  }

  router.isReady().then(() => {
    if ('serviceWorker' in navigator) {
      const wb = new Workbox('/sw.js', { scope: '/' })

      const showSkipWaitingPrompt = () => {
        // `event.wasWaitingBeforeRegister` will be false if this is
        // the first time the updated service worker is waiting.
        // When `event.wasWaitingBeforeRegister` is true, a previously
        // updated service worker is still waiting.
        // You may want to customize the UI prompt accordingly.

        // Assumes your app has some sort of prompt UI element
        // that a user can either accept or reject.
        appNeedsRefresh.value = true
      }

      wb.addEventListener('controlling', (event) => {
        // Assuming the user accepted the update, set up a listener
        // that will reload the page as soon as the previously waiting
        // service worker has taken control.
        if (event.isUpdate)
          window.location.reload()
        else
          offlineAppReady.value = true
      })
      // Add an event listener to detect when the registered
      // service worker has installed but is waiting to activate.
      wb.addEventListener('waiting', showSkipWaitingPrompt)
      // @ts-ignore
      wb.addEventListener('externalwaiting', showSkipWaitingPrompt)

      wb.register({ immediate }).then(r => registration = r!)
    }
  })

  return {
    offlineAppReady,
    appNeedsRefresh,
    updateServiceWorker,
  }
}
userquin commented 3 years ago

also working on my MPA, just configuring templatesURL on workbox including all dynamic routes generated on server side.

The configuration for VitePWA will be as @scambier reported:

VitePWA({
      injectRegister: null,
      manifest: {
        /* */
      },
      workbox: {
        cleanupOutdatedCaches: true
      }
})
userquin commented 3 years ago

@antfu I attach here the copy/paste files from workbox 6.0 that are the same for 6.1.1 if you want to play with it before starting...

service-worker.zip

userquin commented 3 years ago

@scambier can you just download the zip from previous comment, unzip it on your src/logics directory (create it if does not exist) and change the logic to import this new one useServiceWorker calling updateServiceWorker method from the button?

If you are using router just leave it otherwise remove the import and remove the router.isReady() callback.

userquin commented 3 years ago

I upload here new service-worker, second approach doesn't work, we need to register on right registration!!!

service-worker.zip

userquin commented 3 years ago

@antfu ping

antfu commented 3 years ago

Thanks for looking into it. But I am afraid if we need that complicated approach, and wish we are not coupled with Vue. Maybe I will take some time to give a shot later.

userquin commented 3 years ago

This is an example for my tests. We can provide some virtual module abstracting callbacks and workbox things.

Try to see logic on vuepress....

El dom., 7 mar. 2021 6:43, Anthony Fu notifications@github.com escribió:

Thanks for looking into it. But I am afraid if we need that complicated approach, and wish we are not coupled with Vue. Maybe I will take some time to give a shot later.

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/antfu/vite-plugin-pwa/issues/33#issuecomment-792221848, or unsubscribe https://github.com/notifications/unsubscribe-auth/ABQEZTYDFTPLW7LTE2JL4RTTCMHA7ANCNFSM4YTIY7EQ .

userquin commented 3 years ago

The problem is that if you dont provide this, the pwa just stop working, see the title of this issue...

userquin commented 3 years ago

One last thing to change about the ready to work offline message, we need to listen to activated event instead controlling one:

      wb.addEventListener('activated', (event) => {
        // this will only controls the offline request.
        // `event.isUpdate` will be true if another version of the service
        // worker was controlling the page when this version was registered.
        if (!event.isUpdate)
          offlineAppReady.value = true
      })

A proporsal will be somethig like this:

export type RegisterSWOptions = {
  scriptURL?: string
  scope?: string
  immediate?: boolean
  onNeedRefresh: () => void
  onOfflineReady?: () => void
}

export type UpdateServiceWorkerFn = () => Promise<void>

export declare function registerSW(registerSWOptions: RegisterSWOptions): UpdateServiceWorkerFn

the implementation will be something like this:

import { Workbox, messageSW } from 'workbox-window'

export const registerSW = (registerSWOptions: RegisterSWOptions): UpdateServiceWorkerFn => {

  let registration: ServiceWorkerRegistration
  let wb: Workbox

  const updateServiceWorker = async() => {
    // Assuming the user accepted the update, set up a listener
    // that will reload the page as soon as the previously waiting
    // service worker has taken control.
    wb && wb.addEventListener('controlling', (event) => {
      if (event.isUpdate)
        window.location.reload()
    })
    if (registration && registration.waiting) {
      // Send a message to the waiting service worker,
      // instructing it to activate.
      // Note: for this to work, you have to add a message
      // listener in your service worker. See below.
      await messageSW(registration.waiting, { type: 'SKIP_WAITING' })
    }
  }

  if ('serviceWorker' in navigator) {
    wb = new Workbox(registerSWOptions.scriptURL || '/sw.js', { scope: registerSWOptions.scope || '/' })

    const showSkipWaitingPrompt = () => {
      // `event.wasWaitingBeforeRegister` will be false if this is
      // the first time the updated service worker is waiting.
      // When `event.wasWaitingBeforeRegister` is true, a previously
      // updated service worker is still waiting.
      // You may want to customize the UI prompt accordingly.

      // Assumes your app has some sort of prompt UI element
      // that a user can either accept or reject.
      registerSWOptions.onNeedRefresh()
    }

    wb.addEventListener('activated', (event) => {
      // this will only controls the offline request.
      // `event.isUpdate` will be true if another version of the service
      // worker was controlling the page when this version was registered.
      if (!event.isUpdate && registerSWOptions.onOfflineReady)
        registerSWOptions.onOfflineReady()
    })
    // Add an event listener to detect when the registered
    // service worker has installed but is waiting to activate.
    wb.addEventListener('waiting', showSkipWaitingPrompt)
    // @ts-ignore
    wb.addEventListener('externalwaiting', showSkipWaitingPrompt)
    // register the service worker
    wb.register({ immediate: registerSWOptions.immediate || false }).then(r => registration = r!)
  }

  return updateServiceWorker
}

And so, for vue (usage) we can have something like this:

import registerSW from 'vite-plugin-pwa-register'

export const useServiceWorker = (immediate = false, scriptURL = '/sw.js', scope = '/'): {
  offlineAppReady: Ref<boolean>
  appNeedsRefresh: Ref<boolean>
  updateServiceWorker: () => Promise<void>
} => {
  const offlineAppReady = ref(false)
  const appNeedsRefresh = ref(false)

  const updateServiceWorker = registerSW({
    scriptURL,
    scope,
    immediate,
    onNeedRefresh: () => {
      appNeedsRefresh.value = true
    },
    onOfflineReady: () => {
      offlineAppReady.value = true
    },
  })

  return {
    offlineAppReady,
    appNeedsRefresh,
    updateServiceWorker,
  }
}
userquin commented 3 years ago

@antfu we need that complicated approach just because of lifecycle of the service worker , there is no another way to do it...

If you look at @vuepress/pwa, that is scary: modify the script generated by workbox-build, also modifying the ui and register listeners ...

antfu commented 3 years ago

Wow @userquin, that's exactly what I was thinking about. Great work!

In the addition of

import registerSW from 'vite-plugin-pwa-register'

When we could have something like

import { useServiceWorker } from 'vite-plugin-pwa-register/vue'
import { useServiceWorker } from 'vite-plugin-pwa-register/react'

as the extensions.

userquin commented 3 years ago

It only remains to make it nice and unify the types a little

I started to make the virtual module in the plugin, but I mess with the workspaces, and how to structure everything within the project, although I think it should be outside

antfu commented 3 years ago

@userquin Just sent you the invitation to this project. Appreciated all you have done on this topic!

Please feel free to create a new branch and draft a PR. Let me know if you have any other questions, I am happy to review and help with the discussion and implementation! Thanks

userquin commented 3 years ago

@antfu a few questions:

1) How to start it? Make a new folder for virtual module. Where to put dependencies? 2) How about extensions? I don't have any idea how to begin 3) About react I haven't used it, only vue and some little things with svelte.

Maybe you can add a new branch where I can start (clone it), at least with the structure created...

antfu commented 3 years ago

Re: 1, serving virtual module is actually quite simple, you can see the docs here: https://vitejs.dev/guide/api-plugin.html#importing-a-virtual-file

Re: 2, they are just like other virtual modules

Re: 3, no pressure on that, we can leave them for the community to contribute, or if you want, you can do the vue part for sure.

userquin commented 3 years ago

@antfu I have a few problems with virtual modules, I cannot use typescript, just plain javascript.

I'm testing example module and I'll make an initial push to my fork.

I have gotten it to at least compile:

example App.vue

<script lang="ts">
import { defineComponent, ref } from 'vue'
import { registerSW } from 'vite-plugin-pwa-register'
export default defineComponent({
  setup() {
    const offlineAppReady = ref(false)
    const appNeedsRefresh = ref(false)
    const { updateServiceWorker } = registerSW({
      immediate: false,
      onNeedRefresh: () => {
        appNeedsRefresh.value = true
      },
      onOfflineReady: () => {
        offlineAppReady.value = true
      },
    })
    return {
      offlineAppReady,
      appNeedsRefresh,
      updateServiceWorker,
    }
  },
})
</script>
<template>
  <div v-if="offlineAppReady">Ready to work offline</div>
  <div v-if="appNeedsRefresh">
    New content available
    <button @click="updateServiceWorker">Refresh</button>
  </div>
  <div>Hello World</div>
</template>

GENERATED virtual module

import { Workbox, messageSW } from 'workbox-window'

export function registerSW(
  immediate,
  onNeedRefresh,
  onOfflineReady,
) {

  let registration
  let wb

  function updateServiceWorker() {
    // Assuming the user accepted the update, set up a listener
    // that will reload the page as soon as the previously waiting
    // service worker has taken control.
    wb && wb.addEventListener('controlling', function(event) {
      if (event.isUpdate)
        window.location.reload()
    })
    if (registration && registration.waiting) {
      // Send a message to the waiting service worker,
      // instructing it to activate.
      // Note: for this to work, you have to add a message
      // listener in your service worker. See below.
      messageSW(registration.waiting, { type: 'SKIP_WAITING' })
    }
  }

  if ('serviceWorker' in navigator) {
    wb = new Workbox('/sw.js', { scope: '/' })

    wb.addEventListener('activated', function(event) {
      // this will only controls the offline request.
      // event.isUpdate will be true if another version of the service
      // worker was controlling the page when this version was registered.
      if (!event.isUpdate && typeof onOfflineReady === 'function')
        onOfflineReady()
    })
    // Add an event listener to detect when the registered
    // service worker has installed but is waiting to activate.
    wb.addEventListener('waiting', onNeedRefresh)
    // @ts-ignore
    wb.addEventListener('externalwaiting', onNeedRefresh)
    // register the service worker
    wb.register({ immediate }).then(r => registration = r)
  }

  return updateServiceWorker
}
antfu commented 3 years ago

Great, looking forward to it! Yeah, you should use plain js + .d.ts to make it work. We can still write them in ts and transpile them into js in the dist to serve. If you don't get what I mean, you can leave the ts version in the source code and I can take care of the build setup

userquin commented 3 years ago

ok, later I'll try to have both variants, one commented, just coment/remove what you want.

For you info:

1) added modules.ts, just to create the virtual module(s): currently only plain supported (we need to add vue and react: I'll try to add former) 2) modify index.ts to include virtualFileId and added resolveId and load hooks to revolve virtual modules 3) App.vue: just for testing 4) added register option on injectRegister option 5) added workbox-window as dependency

can you tell me your timezone? I'm from Spain, GMT + 1

antfu commented 3 years ago

UTC+8, and it's 2 AM for me now :P

I am going to have some sleep now, will get back to you later.

userquin commented 3 years ago

@antfu tested on example project and first push to my branch: https://github.com/userquin/vite-plugin-pwa

Changes made:

1) added src/modules.ts: virtual modules with javascript (typescript on a second round). Also a first version to generate vite-plugin-pwa-register/vue virtual module. 2) modified src/types.ts to include register as another option for injectRegister. 3) modified src/index.ts to include virtualFileId and added resolveId and load hooks to revolve virtual modules. 4) added workbox-window as dependency to package.json. Only generated when injectRegister: 'register'. We need to review current options. 5) modified example/vite.config.ts to remove outdated caches and changed injectRegister to register: just added workbox: { cleanupOutdatedCaches: true }. Also modified base entry to allow bypass hardcoded base: 'https://github.com/' and added sourcemap to allow see source on local tests: build: { sourcemap: process.env.SOURCE_MAP === 'true', } (see next entry, crossenv passed on script). 6) modified example/package.json to include node-static dependency to do local tests and added run-build script entry to build + serve via node-static example: I have added some crossenv variables to allow test on my local ip, just read it. 7) modified package.json: added example:run-build script to allow build + run example: pnpm example:run-build just build the example and run it on node via example/test-pwa-with-node-static.js (see 9). 8) modified example/src/App.vue: just for testing and with vite-plugin-pwa-register. A real example => how to configure it. 9) added example/test-pwa-with-node-static.js to test on example build with node-static. 10) .gitignore to exclude certificates and .env.local.json: see 2) bellow.

Anyway, go to my fork and just compare it with this to see changes I have made.

If you want to test it, you will need:

1) an ssl certificate, for example, use tls-keygen, then put stuff on example directory. 2) add example/.env.local.json file: see .env.local.json and .env.local.json.content images bellow . You will need to configure node-static ssl stuff and optionally you can configure hostname, https port and http port (default to localhost, 443 and 80 respectivelly). Just see example/test-pwa-with-node-static.js script. 3) for first test (app ready to work offline, see app-ready-offline image bellow) you only need to execute pnpm example:run-build, open a browser with dev tools opened. 4) to test New content available, stop node script, then go to example/src/Vue.app and change return 'App ready to work offline' with return 'Application ready to work offline', then run again pnpm example:run-build and go to page on browser opened on previous step, click on navigation bar and just press enter, wait a few seconds, until refresh icon appears, and then press on refresh button (see new content available image bellow).

.env.local.json image .env.local.json

.env.local.json.content image imagen

Here some screenshots:

app-ready-offline image ezgif com-resize(1)

first test assets image imagen

first test assets directory image imagen

new content available image ezgif com-resize

new content available assets image imagen

new content available assets dir image imagen

userquin commented 3 years ago

@scambier @antfu here an animated gif to see the problem described in this issue solved with the new virtual module.

ezgif com-video-to-gif(1)