diff --git a/.env.example b/.env.example index 11f553dc..ce64ebbe 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,10 @@ VITE_FIREBASE_MEASUREMENT_ID=null # https://firebase.google.com/docs/app-check/web/debug-provider#localhost # Go to the App Check section in the console, and generate a token # VITE_FIREBASE_APPCHECK_DEBUG_TOKEN= +# +# For Web Push with Firebase Cloud Messaging +# Does not work with local demo emulators, but it does work with a fully emulated staging environment. +# VITE_FIREBASE_VAPID_PUBLIC_KEY= # Used for public landing page images etc. VITE_STATIC_ASSETS_BUCKET=https://storage.googleapis.com/wtmg-static diff --git a/.github/workflows/firebase-hosting-merge-staging.yml b/.github/workflows/firebase-hosting-merge-staging.yml index dfb8f509..0dedcfd1 100644 --- a/.github/workflows/firebase-hosting-merge-staging.yml +++ b/.github/workflows/firebase-hosting-merge-staging.yml @@ -16,6 +16,7 @@ jobs: VITE_FIREBASE_MESSAGING_SENDER_ID: ${{secrets.FIREBASE_MESSAGING_SENDER_ID}} VITE_FIREBASE_APP_ID: ${{secrets.FIREBASE_APP_ID}} VITE_FIREBASE_MEASUREMENT_ID: ${{secrets.FIREBASE_MEASUREMENT_ID}} + VITE_FIREBASE_VAPID_PUBLIC_KEY: ${{vars.FIREBASE_VAPID_PUBLIC_KEY}} VITE_MAPBOX_ACCESS_TOKEN: ${{secrets.MAPBOX_ACCESS_TOKEN}} VITE_THUNDERFOREST_API_KEY: ${{secrets.THUNDERFOREST_API_KEY}} VITE_DIRECT_TRAIN_API_URLS: ${{secrets.DIRECT_TRAIN_API_URLS}} diff --git a/api/src/chat.js b/api/src/chat.js index 3b99b420..a386fed8 100644 --- a/api/src/chat.js +++ b/api/src/chat.js @@ -7,6 +7,8 @@ const removeDiacritics = require('./util/removeDiacritics'); const { sendMessageReceivedEmail } = require('./mail'); const removeEndingSlash = require('./util/removeEndingSlash'); const { auth, db } = require('./firebase'); +const { sendNotification } = require('./push'); +const fail = require('./util/fail'); /** * @typedef {import("../../src/lib/models/User").UserPrivate} UserPrivate @@ -85,15 +87,39 @@ exports.onMessageCreate = async (snap, context) => { const nameParts = sender.displayName.split(/[^A-Za-z-]/); const messageUrl = `${baseUrl}/chat/${normalizeName(nameParts[0])}/${chatId}`; - await sendMessageReceivedEmail({ - email: recipient.email, - firstName: recipient.displayName, - senderName: sender.displayName, + const commonPayload = { + senderName: sender.displayName ?? '', message: normalizeMessage(message.content), messageUrl, superfan: recipientUserPublicDocData.superfan ?? false, language: recipientUserPrivateDocData.communicationLanguage ?? 'en' + }; + + if (!recipient.email || !recipient.displayName) { + console.error(`Email or display name of ${recipientId} are not valid`); + fail('internal'); + } + + // Send email + await sendMessageReceivedEmail({ + ...commonPayload, + email: recipient.email, + firstName: recipient.displayName }); + + // Send a notification to all registered devices via FCM + const pushRegistrations = ( + await recipientUserPrivateDocRef.collection('push-registrations').get() + ).docs.map((d) => d.data()); + + await Promise.all( + pushRegistrations.map((pR) => + sendNotification({ + ...commonPayload, + fcmToken: pR.fcmToken + }) + ) + ); } catch (ex) { console.log(ex); } diff --git a/api/src/push.js b/api/src/push.js new file mode 100644 index 00000000..df550bfe --- /dev/null +++ b/api/src/push.js @@ -0,0 +1,45 @@ +// eslint-disable-next-line import/no-unresolved +const { getMessaging } = require('firebase-admin/messaging'); +/** + * @typedef {Object} PushConfig + * @property {string} fcmToken + * @property {string} senderName + * @property {string} message + * @property {string} messageUrl + * @property {boolean} superfan + * @property {string} language + */ + +/** + * @param {PushConfig} config + * @returns + */ +exports.sendNotification = async (config) => { + const { fcmToken, senderName, message, messageUrl } = config; + + /** + * @type {import('firebase-admin/messaging').Message} + */ + const fcmPayload = { + notification: { + title: `Message from ${senderName}`, + body: message + }, + webpush: { + fcmOptions: { link: messageUrl } + }, + token: fcmToken + }; + + // Send a message to the device corresponding to the provided + // registration token. + return getMessaging() + .send(fcmPayload) + .then((response) => { + // Response is a message ID string. + console.log('Successfully sent message notification:', response); + }) + .catch((error) => { + console.log('Error sending message notification:', error); + }); +}; diff --git a/firestore.rules b/firestore.rules index 25c92e23..ec2cdd7e 100644 --- a/firestore.rules +++ b/firestore.rules @@ -122,6 +122,10 @@ service cloud.firestore { && incomingData().visible is bool; allow delete: if isValidTrailAccess(userId); } + match /push-registrations/{registrationId} { + allow read: if isSignedIn() && isOwner(userId); + allow write: if isSignedIn() && isOwner(userId); + } } // Garden functions diff --git a/package.json b/package.json index 178e9d62..60a8dcab 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "firebase:demo": "firebase --project demo-test emulators:start", "firebase:demo-seed": "firebase --project demo-test emulators:exec --ui api/seeders/simple.js", "firebase:debug": "firebase --project demo-test emulators:start --inspect-functions", - "firebase:staging": "firebase --project wtmg-dev emulators:start --only functions" + "firebase:staging": "firebase --project wtmg-dev emulators:start --only functions", + "firebase:staging-all": "firebase --project wtmg-dev emulators:start" }, "publishConfig": { "access": "public" @@ -38,6 +39,7 @@ "@types/md5": "^2.3.2", "@types/nprogress": "^0.2.0", "@types/smoothscroll-polyfill": "^0.3.1", + "@types/ua-parser-js": "^0.7.36", "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", "@zerodevx/svelte-img": "^1.2.10", @@ -65,6 +67,7 @@ "svg-inline-loader": "^0.8.2", "tslib": "^2.3.1", "typescript": "^5.0.0", + "ua-parser-js": "https://github.com/faisalman/ua-parser-js.git#2.0.0-alpha.2", "vite": "^4.4.7", "vite-bundle-visualizer": "0.6.0", "vitest": "^0.33.0" diff --git a/src/lib/api/collections.ts b/src/lib/api/collections.ts index 77458e6b..f73edfaf 100644 --- a/src/lib/api/collections.ts +++ b/src/lib/api/collections.ts @@ -4,4 +4,7 @@ export const STATS = 'stats'; export const USERS = 'users'; export const USERS_PRIVATE = 'users-private'; export const MESSAGES = 'messages'; +// Subcollection of users-private export const TRAILS = 'trails'; +// Subcollection of users-private +export const PUSH_REGISTRATIONS = 'push-registrations'; diff --git a/src/lib/api/firebase.ts b/src/lib/api/firebase.ts index c4b394b1..4a58a67a 100644 --- a/src/lib/api/firebase.ts +++ b/src/lib/api/firebase.ts @@ -5,6 +5,12 @@ import { type Firestore, getFirestore, connectFirestoreEmulator } from 'firebase import { connectStorageEmulator, getStorage, type FirebaseStorage } from 'firebase/storage'; import { connectFunctionsEmulator, getFunctions, type Functions } from 'firebase/functions'; import { initializeEuropeWest1Functions, initializeUsCentral1Functions } from './functions'; +import { + getMessaging, + isSupported as isWebPushSupported, + onMessage, + type Messaging +} from 'firebase/messaging'; import envIsTrue from '../util/env-is-true'; const FIREBASE_CONFIG = { @@ -24,13 +30,15 @@ type FirestoreWarning = { auth: string; storage: string; functions: string; + messaging: string; }; export const FIREBASE_WARNING: FirestoreWarning = [ 'app', 'firestore', 'auth', 'storage', - 'functions' + 'functions', + 'messaging' ].reduce( (warningsObj, service) => ({ ...warningsObj, [service]: messageFor(service) }), {} @@ -83,8 +91,20 @@ export const storage: () => FirebaseStorage = guardNull( 'storage' ); +// TODO: configure via env var? +// Note: can be changed to an internal IP +// Warnng: setting to another setting than 'localhost' has implications on the testability of services. +// - Service Workers (web push) only work on localhost OR HTTPS +// - Firebase Emulators CAN'T USE HTTPS +// https://github.com/firebase/firebase-tools/issues/1908#issuecomment-1677219899 +// - Requests will fail if HTTPS hosting is configured for Sveltekit, and it tries to fetch HTTP content from Firebase Emulators. +const emulatorHostName = 'localhost'; + +let messagingRef: Messaging; +export const messaging: () => Messaging = guardNull(() => messagingRef, 'messaging'); + // TODO: window may not be available on server-side SvelteKit -const isRunningLocally = window && window.location.hostname.match('localhost|127.0.0.1'); +const isRunningLocally = window && window.location.hostname.match(`${emulatorHostName}|127.0.0.1`); const shouldUseEmulator = (specificEmulatorOverride?: boolean | undefined | null) => // If an override is defined, only look at that value. @@ -113,17 +133,18 @@ export async function initialize(): Promise { dbRef = getFirestore(appRef); if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_FIRESTORE_EMULATOR))) { - connectFirestoreEmulator(dbRef, 'localhost', 8080); + connectFirestoreEmulator(dbRef, emulatorHostName, 8080); } authRef = getAuth(appRef); + authRef.useDeviceLanguage(); if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_AUTH_EMULATOR))) { - connectAuthEmulator(authRef, 'http://localhost:9099'); + connectAuthEmulator(authRef, `http://${emulatorHostName}:9099`); } storageRef = getStorage(appRef); if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_STORAGE_EMULATOR))) { - connectStorageEmulator(storageRef, 'localhost', 9199); + connectStorageEmulator(storageRef, emulatorHostName, 9199); } // The default functions ref is us-central1 @@ -136,8 +157,22 @@ export async function initialize(): Promise { initializeEuropeWest1Functions(europeWest1FunctionsRef); if (shouldUseEmulator(envIsTrue(import.meta.env.VITE_USE_API_EMULATOR))) { - connectFunctionsEmulator(usCentral1FunctionsRef, 'localhost', 5001); - connectFunctionsEmulator(europeWest1FunctionsRef, 'localhost', 5001); + connectFunctionsEmulator(usCentral1FunctionsRef, emulatorHostName, 5001); + connectFunctionsEmulator(europeWest1FunctionsRef, emulatorHostName, 5001); + } + + if ( + // Note: Safari 16.4 *in normal mode* does not support Web Push, but there IS support in Home Screen app mode + // https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/ + // Be careful: service worker support is required, and that only works on localhost and HTTPS ! + (await isWebPushSupported()) && + typeof import.meta.env.VITE_FIREBASE_VAPID_PUBLIC_KEY !== 'undefined' + ) { + messagingRef = getMessaging(appRef); + + onMessage(messagingRef, (payload) => { + console.log('Message received. ', payload); + // ... + }); } - authRef.useDeviceLanguage(); } diff --git a/src/lib/api/push-registrations.ts b/src/lib/api/push-registrations.ts new file mode 100644 index 00000000..9b56e34e --- /dev/null +++ b/src/lib/api/push-registrations.ts @@ -0,0 +1,156 @@ +import { getToken } from 'firebase/messaging'; +import { db, messaging } from './firebase'; +import { + CollectionReference, + DocumentReference, + addDoc, + collection, + deleteDoc, + doc, + onSnapshot, + query, + serverTimestamp +} from 'firebase/firestore'; +import { PUSH_REGISTRATIONS, USERS_PRIVATE } from './collections'; +import { getUser } from '$lib/stores/auth'; +import type { FirebasePushRegistration } from '$lib/types/PushRegistration'; +import { UAParser } from 'ua-parser-js'; +import { pushRegistrations } from '$lib/stores/pushRegistrations'; +import removeUndefined from '$lib/util/remove-undefined'; + +export type PushSubscriptionPOJO = PushSubscriptionJSON; + +export const createPushRegistrationObserver = () => { + const q = query( + collection( + db(), + USERS_PRIVATE, + getUser().id, + PUSH_REGISTRATIONS + ) as CollectionReference + ); + + return onSnapshot(q, async (querySnapshot) => { + const subscriptions = querySnapshot.docs.map((ss) => ({ ...ss.data(), id: ss.id })); + pushRegistrations.set(subscriptions); + const currentSub = await getCurrentSubscription(); + // If we locally have a subscription that isn't available remotely, then add it. + // This normally shouldn't happen, except when a database was wiped (in testing) + // TODO: this might be counter intuitive: we need to be able to revoke the permission + // when a sub was marked for deletion (elsewhere)! Can we? + // yes! https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/unsubscribe + // maybe with a status field? + if ( + currentSub && + !subscriptions.find((pR) => pR.subscription.endpoint === currentSub.endpoint) + ) { + createPushRegistration(); + } + }); +}; + +export const getCurrentSubscription = async () => + navigator.serviceWorker.ready.then((serviceWorkerRegistration) => + getSubscriptionFromSW(serviceWorkerRegistration) + ); + +/** + * Note: this should only be called from a user gesture on iOS + */ +const subscribeToWebPush = () => { + if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) { + navigator.serviceWorker.ready.then((serviceWorkerRegistration) => { + // This probably does the native Web Push `serviceWorkerRegistration.pushManager.subscribe(options)` internally somewhere, to get the Subscription info + // It then converts it to a FCM registration to identify this browser + // Safari expects the options: { userVisibleOnly: true } to be set, the FCM implementation seems to do this. + // + // TODO: "If a notification permission isn't already granted, this method asks the user for permission." - so that code can be removed + getToken(messaging(), { + vapidKey: import.meta.env.VITE_FIREBASE_VAPID_PUBLIC_KEY, + serviceWorkerRegistration: serviceWorkerRegistration + }) + .then(async (currentToken) => { + if (currentToken) { + // Handle a current registration token (obtained earlier with the Notification.requestPermissions API) + // + // TODO: i'm not really sure where this token comes from. FB? the SW? + // I guess it identifies this browser, and can be stored + // We will also store the native subscription object, so we might leverage it later. + const subscriptionObject = await getSubscriptionFromSW(serviceWorkerRegistration); + + if (!subscriptionObject) { + // This should, I suppose, not happen + console.error( + 'Could not retrieve subscription object, even though a FCM token is available.' + ); + return; + } + + const colRef = collection( + db(), + USERS_PRIVATE, + getUser().uid, + PUSH_REGISTRATIONS + ) as CollectionReference; + + // TODO: search for existing docs with same token? + // + const { os, browser, device } = UAParser(navigator.userAgent); + await addDoc(colRef, { + fcmToken: currentToken, + subscription: subscriptionObject, + ua: removeUndefined({ + os: os.name, + browser: browser.name, + // Destructure helps to convert into POJO + device: removeUndefined({ ...device }) + }), + host: location.host, + createdAt: serverTimestamp(), + refreshedAt: serverTimestamp() + }); + } else { + // Show permission request UI + // NOTE: I guess this means + console.log('No registration token available. Request permission to generate one.'); + } + }) + .catch((err) => { + console.error('An error occurred while retrieving a token. ', err); + }); + }); + } +}; + +export const createPushRegistration = () => { + console.log('Requesting permission'); + if (!('Notification' in window)) { + alert('This browser does not support notifications'); + return; + } + if (Notification.permission === 'granted') { + subscribeToWebPush(); + } else { + Notification.requestPermission().then((permission) => { + if (permission === 'granted') { + console.log('Notification permission granted.'); + subscribeToWebPush(); + } + }); + } +}; + +export const deletePushRegistration = async (id: string) => { + const docRef = doc( + db(), + USERS_PRIVATE, + getUser().uid, + PUSH_REGISTRATIONS, + id + ) as DocumentReference; + await deleteDoc(docRef); +}; + +async function getSubscriptionFromSW(serviceWorkerRegistration: ServiceWorkerRegistration) { + return await serviceWorkerRegistration.pushManager.getSubscription().then((s) => s?.toJSON()); +} diff --git a/src/lib/stores/pushRegistrations.ts b/src/lib/stores/pushRegistrations.ts new file mode 100644 index 00000000..72115646 --- /dev/null +++ b/src/lib/stores/pushRegistrations.ts @@ -0,0 +1,4 @@ +import type { LocalPushRegistration } from '$lib/types/PushRegistration'; +import { writable } from 'svelte/store'; + +export const pushRegistrations = writable([]); diff --git a/src/lib/stores/user.js b/src/lib/stores/user.js index 09fb05ae..e3b94200 100644 --- a/src/lib/stores/user.js +++ b/src/lib/stores/user.js @@ -1,5 +1,4 @@ import { writable } from 'svelte/store'; -export const gettingPrivateUserProfile = writable(false); export const updatingMailPreferences = writable(false); export const updatingSavedGardens = writable(false); diff --git a/src/lib/types/PushRegistration.ts b/src/lib/types/PushRegistration.ts new file mode 100644 index 00000000..305cf688 --- /dev/null +++ b/src/lib/types/PushRegistration.ts @@ -0,0 +1,41 @@ +import type { FieldValue, Timestamp } from 'firebase/firestore'; +import type { IOS, IBrowser, IDevice } from 'ua-parser-js'; +export type FirebasePushRegistration = { + /** + * The Firebase Cloud Messaging registration token. + */ + fcmToken: string; + /** + * The Web Push Subscription underlying the FCM registration. + * I'm 100% not sure if we can ever leverage this directly without going through FCM, + * but keeping it gives us a chance, and might help with the identification/refreshing of a registration. + */ + subscription: PushSubscriptionJSON; + /** + * Processed user agent details from the USParser.js v2 library. + * https://faisalman.github.io/ua-parser-js-docs/v2/intro/why-ua-parser-js.html + * + * The goal of this information is to help the user identify the browser of the registration. + * We therefore don't store browser/OS versions, or other hard-to-see technical details. + */ + ua: { + os: IOS['name']; + browser: IBrowser['name']; + device: IDevice; + }; + /** The host that this subscription was created for. This could be useful when we have multiple apps + * connecting to the same Firestore. Format without protocol. + * On localhost testing, it seems that the port matters for Web Push registrations. + */ + host: string; + /** + * The time that this registration was *first* registered. + */ + createdAt: Timestamp; + /** + * The time that this registration was last seen active from the client side. + */ + refreshedAt: Timestamp; +}; + +export type LocalPushRegistration = FirebasePushRegistration & { id: string }; diff --git a/src/lib/util/remove-undefined.ts b/src/lib/util/remove-undefined.ts new file mode 100644 index 00000000..9c69fec8 --- /dev/null +++ b/src/lib/util/remove-undefined.ts @@ -0,0 +1,5 @@ +export default (obj: T): Exclude => + Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined)) as Exclude< + T, + undefined + >; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte index 7694047f..5c4b806e 100644 --- a/src/routes/account/+page.svelte +++ b/src/routes/account/+page.svelte @@ -15,6 +15,7 @@ import ReloadSuggestion from '$lib/components/ReloadSuggestion.svelte'; import EmailChangeModal from './EmailChangeModal.svelte'; import { countryNames } from '$lib/stores/countryNames'; + import NotificationSection from './NotificationSection.svelte'; let showAccountDeletionModal = false; let showEmailChangeModal = false; @@ -147,6 +148,7 @@ +

{$_('account.garden.title')}

diff --git a/src/routes/account/NotificationSection.svelte b/src/routes/account/NotificationSection.svelte new file mode 100644 index 00000000..bc273e1c --- /dev/null +++ b/src/routes/account/NotificationSection.svelte @@ -0,0 +1,77 @@ + + +
+

Notifications

+ + + + + + + {/each} +
+ {#each $pushRegistrations as { id, createdAt, ua: { browser, device }, subscription: { endpoint } }} +
{browser} on {device.model} + {currentSub?.endpoint === endpoint ? '(current)' : ''}Enabled on {new Intl.DateTimeFormat($locale, { dateStyle: 'medium' }).format( + createdAt.toDate() + )}
+ {#if currentSub === null} +

Get push notifications on this browser or device

+ + {/if} +
+ + diff --git a/src/service-worker.ts b/src/service-worker.ts index 58ed2b2c..48c99a15 100644 --- a/src/service-worker.ts +++ b/src/service-worker.ts @@ -2,6 +2,9 @@ /// /// /// + +import { initializeApp } from 'firebase/app'; +import { getMessaging, onBackgroundMessage } from 'firebase/messaging/sw'; // See https://kit.svelte.dev/docs/service-workers#type-safety const sw = self as unknown as ServiceWorkerGlobalScope; @@ -110,3 +113,53 @@ sw.addEventListener('fetch', (event) => { event.respondWith(respondWithCachedAsset()); }); + +// FCM configuration +// The guide (https://firebase.google.com/docs/cloud-messaging/js/client#access_the_registration_token) claims that a file named +// "firebase-messaging-sw.js" is required, but that name would be confusing, since our SW also does other stuff. +// It is actually possible to register our own service worker name +// https://firebase.google.com/docs/reference/js/messaging_.gettokenoptions.md?authuser=0#properties + +// Initialize the Firebase app in the service worker by passing in +// your app's Firebase config object. +// https://firebase.google.com/docs/web/setup#config-object +const FIREBASE_CONFIG = { + apiKey: import.meta.env.VITE_FIREBASE_API_KEY as string, + authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string, + databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL as string, + projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID as string, + storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET as string, + messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string, + appId: import.meta.env.VITE_FIREBASE_APP_ID as string +}; + +const firebaseApp = initializeApp(FIREBASE_CONFIG); + +// Retrieve an instance of Firebase Messaging so that it can handle background +// messages. +const messaging = getMessaging(firebaseApp); + +// Handle incoming messages. Called when: +// - a message is received while the app has focus +// - the user clicks on an app notification created by a service worker +// `messaging.onBackgroundMessage` handler. + +// Probably this internally does: self.addEventListener('push', ... ), and then +// it check if the app is in the foreground or background. If it is in the foreground, +// it will forward the data to the onMessage() handler on the main app instead. +onBackgroundMessage(messaging, (payload) => { + console.log('[service worker] Received background message ', payload); + // Firebase will already send the notification. + // + // Customize notification here + // Note: be careful for double notifications. +}); + +// FCM will handle this with its own fcmOptions +// sw.addEventListener('notificationclick', async function (event) { +// if (!event.action) return; + +// // This always opens a new browser tab, +// // even if the URL happens to already be open in a tab. +// clients.openWindow(event.action); +// }); diff --git a/static/manifest.json b/static/manifest.json index 42ec8030..c767cdbc 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -1,4 +1,5 @@ { + "$schema": "https://json.schemastore.org/web-manifest-combined.json", "name": "Welcome To My Garden", "short_name": "WTMG", "description": "Welcome To My Garden is a not-for-profit network of citizens offering free camping spots in their gardens to slow travellers.", diff --git a/svelte.config.js b/svelte.config.js index 18524a0e..c5f434b2 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -8,7 +8,6 @@ const config = { // Consult https://github.com/sveltejs/svelte-preprocess // for more information about preprocessors preprocess: preprocess(), - kit: { // https://stackoverflow.com/a/74222951/4973029 alias: { @@ -20,7 +19,6 @@ const config = { assets: 'dist', fallback: 'index.html' }), - prerender: { crawl: true, // Prevents: "The following routes were marked as prerenderable, but were not prerendered because they were not found while crawling your app:" diff --git a/yarn.lock b/yarn.lock index fecb6b9b..4d7d89d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2839,6 +2839,13 @@ __metadata: languageName: node linkType: hard +"@types/ua-parser-js@npm:^0.7.36": + version: 0.7.36 + resolution: "@types/ua-parser-js@npm:0.7.36" + checksum: 8c24d4dc12ed1b8b98195838093391c358c81bf75e9cae0ecec8f7824b441e069daaa17b974a3e257172caddb671439f0c0b44bf43bfcf409b7a574a25aab948 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:^5.27.0": version: 5.48.1 resolution: "@typescript-eslint/eslint-plugin@npm:5.48.1" @@ -7098,6 +7105,13 @@ __metadata: languageName: node linkType: hard +"ua-parser-js@https://github.com/faisalman/ua-parser-js.git#2.0.0-alpha.2": + version: 2.0.0-alpha.2 + resolution: "ua-parser-js@https://github.com/faisalman/ua-parser-js.git#commit=5d2acd8fe7e8029f09afebf9c8afb5bcf4bcd951" + checksum: ff8a5cd8d824348ef49f5d7ab1609e14fc3703c3658be556f52b10db323ff598953d48363d13bd9ace743f541bbf5efa61fb08159b36058d99c8abf26cb19123 + languageName: node + linkType: hard + "ufo@npm:^1.1.2": version: 1.1.2 resolution: "ufo@npm:1.1.2" @@ -7394,6 +7408,7 @@ __metadata: "@types/md5": ^2.3.2 "@types/nprogress": ^0.2.0 "@types/smoothscroll-polyfill": ^0.3.1 + "@types/ua-parser-js": ^0.7.36 "@typescript-eslint/eslint-plugin": ^5.27.0 "@typescript-eslint/parser": ^5.27.0 "@zerodevx/svelte-img": ^1.2.10 @@ -7421,6 +7436,7 @@ __metadata: svg-inline-loader: ^0.8.2 tslib: ^2.3.1 typescript: ^5.0.0 + ua-parser-js: "https://github.com/faisalman/ua-parser-js.git#2.0.0-alpha.2" vite: ^4.4.7 vite-bundle-visualizer: 0.6.0 vitest: ^0.33.0