Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ lerna-debug.log*
# Yarn
.yarn/*
!.yarn/releases
.wrangler/
1 change: 1 addition & 0 deletions example/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "example",
"description": "Demo App with Routing built-in (recommended)",
"type": "module",
"engines": {
"node": ">=15.0.0"
},
Expand Down
2 changes: 2 additions & 0 deletions example/public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
User-agent: *
Allow: /
9 changes: 8 additions & 1 deletion example/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { component$ } from "@builder.io/qwik";
import { component$, useVisibleTask$ } from "@builder.io/qwik";
import { usePWA } from '@qwikdev/pwa/client'
import type { DocumentHead } from "@builder.io/qwik-city";

import Counter from "~/components/starter/counter/counter";
Expand All @@ -7,6 +8,12 @@ import Infobox from "~/components/starter/infobox/infobox";
import Starter from "~/components/starter/next-steps/next-steps";

export default component$(() => {
const pwa = usePWA()
useVisibleTask$(({ track }) => {
track(pwa.isPWAInstalled)
console.log(pwa)
})

return (
<>
<Hero />
Expand Down
1 change: 1 addition & 0 deletions example/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { qwikVite } from "@builder.io/qwik/optimizer";
import { qwikCity } from "@builder.io/qwik-city/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { type PWAOptions, qwikPwa } from "@qwikdev/pwa";
console.log(import.meta.resolve('@qwikdev/pwa'))

const config: PWAOptions | undefined = process.env.CUSTOM_CONFIG === "true"
? { config: true }
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@
"default": "./lib/sw.qwik.cjs"
}
},
"./client": {
"import": {
"types": "./lib-types/client.d.mts",
"default": "./lib/client.qwik.js"
},
"require": {
"types": "./lib-types/client.d.cts",
"default": "./lib/client.qwik.cjs"
}
},
"./*": "./*"
},
"main": "lib/index.qwik.js",
Expand All @@ -60,6 +70,9 @@
"sw": [
"./lib-types/sw.d.ts"
],
"client": [
"./lib-types/client.d.ts"
],
"*": [
"./*"
]
Expand Down
119 changes: 119 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { $, NoSerialize, Signal, noSerialize } from '@builder.io/qwik'
// @ts-ignore
import webManifest from 'virtual:qwik-pwa/manifest'
import { useOnWindow, useSignal, useStore, useVisibleTask$ } from "@builder.io/qwik";

const installPromptKey = 'qwik-pwa:hide-install'

export function usePWA() {
const registrationError = useSignal(false)
const swActivated = useSignal(false)
const showInstallPrompt = useSignal(false)
const hideInstall = useSignal(false)
const swRegistration = useSignal<NoSerialize<ServiceWorkerRegistration> | null>()

const offlineReady = useSignal(false)
const needRefresh = useSignal(false)

const isPWAInstalled = useSignal(false)

let deferredPrompt: Signal<NoSerialize<InstallPromptEvent> | null> = useSignal(null)
let beforeInstallPrompt: Signal<NoSerialize<EventListener> | null> = useSignal(null)

let install = $(async () => {
if (hideInstall.value) {
return
}
if (!showInstallPrompt.value || !deferredPrompt.value) {
showInstallPrompt.value = false
return
}

showInstallPrompt.value = false
deferredPrompt.value.prompt()
await deferredPrompt.value.userChoice
})
let cancelInstall = $(() => {
if (hideInstall.value) {
return
}
deferredPrompt.value = null
showInstallPrompt.value = false
window.removeEventListener('beforeinstallprompt', beforeInstallPrompt.value!)
hideInstall.value = true
localStorage.setItem(installPromptKey, 'true')
})

useOnWindow('load', $(async () => {
debugger
// https://thomashunter.name/posts/2021-12-11-detecting-if-pwa-twa-is-installed
const ua = navigator.userAgent
const ios = ua.match(/iPhone|iPad|iPod/)
const useDisplay = webManifest.display === 'standalone' || webManifest.display === 'minimal-ui' ? `${webManifest.display}` : 'standalone'
const standalone = window.matchMedia(`(display-mode: ${useDisplay})`).matches
isPWAInstalled.value = !!(standalone || (ios && !ua.match(/Safari/)))
hideInstall.value = localStorage.getItem(installPromptKey) === 'true'

window.matchMedia(`(display-mode: ${useDisplay})`).addEventListener('change', (e) => {
// PWA on fullscreen mode will not match standalone nor minimal-ui
if (!isPWAInstalled.value && e.matches)
isPWAInstalled.value = true
})

const registrations = await navigator.serviceWorker.getRegistrations()
swRegistration.value = noSerialize(registrations.find((r) => {
if (!r.active?.scriptURL) {
return false
}
const url = new URL(r.active?.scriptURL)
if (url.pathname === '/service-worker.js') {
return true
}
}))

swRegistration.value?.installing?.addEventListener('statechange', (e) => {
swActivated.value = (e.target as ServiceWorker).state === 'activated'
})

;(await navigator.serviceWorker.getRegistrations()).forEach((registration) => {
registration.addEventListener('statechange', (e) => {
console.log('state', e)
})
})

if (!hideInstall.value) {
beforeInstallPrompt.value = noSerialize<EventListener>((e: Event) => {
e.preventDefault()
deferredPrompt.value = noSerialize(e as InstallPromptEvent)
showInstallPrompt.value = true
})
window.addEventListener('beforeinstallprompt', beforeInstallPrompt.value!)

window.addEventListener('appinstalled', () => {
deferredPrompt.value = null
showInstallPrompt.value = false
})
}
}))

const cancelPrompt = $(() => {

})

return {
registrationError,
swActivated,
showInstallPrompt,
hideInstall,
isPWAInstalled,
install,
cancelInstall,
cancelPrompt
}
}

type InstallPromptEvent = Event & {
prompt: () => void
userChoice: Promise<{ outcome: 'dismissed' | 'accepted' }>
}

25 changes: 20 additions & 5 deletions src/plugins/assets.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import { readFile } from "node:fs/promises";
import type { QwikPWAContext } from "../context";
import type { Plugin } from "vite";
import path from "node:path";

const VIRTUAL = "virtual:qwik-pwa/head";
const RESOLVED_VIRTUAL = `\0${VIRTUAL}`;
const VIRTUAL_HEAD = "virtual:qwik-pwa/head";
const VIRTUAL_MANIFEST = "virtual:qwik-pwa/manifest"
const RESOLVED_VIRTUAL_HEAD = `\0${VIRTUAL_HEAD}`;
const RESOLVED_VIRTUAL_MANIFEST = `\0${VIRTUAL_MANIFEST}`;

export default function AssetsPlugin(ctx: QwikPWAContext): Plugin {
return {
name: "qwik-pwa:assets",
enforce: "post",
resolveId(id) {
return id === VIRTUAL ? RESOLVED_VIRTUAL : undefined;
switch(id) {
case VIRTUAL_HEAD:
return RESOLVED_VIRTUAL_HEAD
case VIRTUAL_MANIFEST:
return RESOLVED_VIRTUAL_MANIFEST
default:
return undefined
}
},
async load(id) {
if (id === RESOLVED_VIRTUAL) {
if (id === RESOLVED_VIRTUAL_HEAD) {
const assets = await ctx.assets;
return (
(await assets?.resolveHtmlLinks()) ??
Expand All @@ -21,6 +32,10 @@ export const meta = [];
`
);
}
if (id === RESOLVED_VIRTUAL_MANIFEST) {
const manifest = await readFile(path.join(ctx.publicDir, ctx.webManifestUrl), 'utf-8')
return `export default ${manifest}`
}
},
buildStart() {
// add web manifest to watcher, and so we can reload the page when it changes
Expand All @@ -41,7 +56,7 @@ export const meta = [];
// - invalidate resolved virtual module or
// - send full page reload if resolved virtual module is not found
const resolvedVirtual =
server.moduleGraph.getModuleById(RESOLVED_VIRTUAL);
server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_HEAD);
if (resolvedVirtual) {
return [resolvedVirtual];
}
Expand Down
37 changes: 15 additions & 22 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,31 +63,24 @@ export function setupPwa(mode: "auto-update" | "prompt" = "auto-update") {
}

if (mode === "prompt") {
if (import.meta.env.DEV) {
console.warn(
`Qwik PWA v${version}\nWARNING: "prompt" mode not available yet`,
);
}
/*
self.addEventListener("message", (event) => {
if (event.data.type === "SKIP_WAITING") {
self.skipWaiting();
}
});
*/
}
// else {
// Skip-Waiting Service Worker-based solution
self.addEventListener("activate", async () => {
// after we've taken over, iterate over all the current clients (windows)
const clients = await self.clients.matchAll({ type: "window" });
clients.forEach((client) => {
// ...and refresh each one of them
client.navigate(client.url);
else {
// Skip-Waiting Service Worker-based solution
self.addEventListener("activate", async () => {
// after we've taken over, iterate over all the current clients (windows)
const clients = await self.clients.matchAll({ type: "window" });
clients.forEach((client) => {
// ...and refresh each one of them
client.navigate(client.url);
});
});
});
self.skipWaiting();
// }
self.skipWaiting();
}

const base = "/build/"; // TODO: it should be dynamic based on the build
const qprefetchEvent = new MessageEvent<ServiceWorkerMessage>("message", {
Expand Down Expand Up @@ -121,10 +114,10 @@ export type AppSymbols = Map<string, string>;
export type AppBundle =
| [bundleName: string, importedBundleIds: number[]]
| [
bundleName: string,
importedBundleIds: number[],
symbolHashesInBundle: string[],
];
bundleName: string,
importedBundleIds: number[],
symbolHashesInBundle: string[],
];

export type LinkBundle = [routePattern: RegExp, bundleIds: number[]];

Expand Down
3 changes: 2 additions & 1 deletion vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default defineConfig({
build: {
target: "es2020",
lib: {
entry: ["./src/index.ts", "./src/sw.ts", "./src/head.ts"],
entry: ["./src/index.ts", "./src/sw.ts", "./src/head.ts", "./src/client.ts"],
formats: ["es", "cjs"],
fileName: (format, entryName) =>
`${entryName}.qwik.${format === "es" ? "js" : "cjs"}`,
Expand All @@ -23,6 +23,7 @@ export default defineConfig({
external: [
"fast-glob",
"virtual:qwik-pwa/head",
"virtual:qwik-pwa/manifest",
...excludeAll(builtinModules),
...builtinModules,
/^node:.*/,
Expand Down