diff --git a/.env.example b/.env.example index c0660ea..54db381 100644 --- a/.env.example +++ b/.env.example @@ -63,3 +63,7 @@ AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false VITE_APP_NAME="${APP_NAME}" + +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VITE_VAPID_PUBLIC_KEY="${VAPID_PUBLIC_KEY}" diff --git a/AGENTS.md b/AGENTS.md index 4a4a8ea..d1aa2a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,11 +5,6 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. -## Persistent Project Context (IMPORTANT) - -- You MUST read, understand, and strictly follow the underlying business logic and feature requests documented in `IDEA.md` in every session. -- You MUST adhere to all style architecture, backend transaction, and integration decisions recorded in `DECISIONS.md`. Update `DECISIONS.md` when any major design decisions are made during a session. - ## Foundational Context This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. @@ -58,12 +53,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Stick to existing directory structure; don't create new base folders without approval. - Do not change the application's dependencies without approval. -## Activity Service - -- The `app/Services/ActivityService.php` class is used to create system messages and activities. -- To create a system message, use the `createMessage` method. The `$user` parameter should be `null` to indicate a system message. -- The `createMutation` method can be used to create a mutation and its associated system message. - ## Frontend Bundling - If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. @@ -137,7 +126,6 @@ This project has domain-specific skills available in `**/skills/**`. You MUST ac - Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass. - Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter. -- **Environment Isolation during Test Runs (IMPORTANT)**: The test runner environment (e.g. `vendor/bin/pest` or `php artisan test`) can be polluted by the main project's local `.env` file settings if run directly in the active shell. This pollution can override test-specific configurations defined in `phpunit.xml` and lead to unexpected failures, such as CSRF (`419 Page Expired`) errors or database connection issues. Always prefix test commands with `env -i PATH=$PATH HOME=$HOME TERM=$TERM` (e.g., `env -i PATH=$PATH HOME=$HOME TERM=$TERM vendor/bin/pest`) to enforce a clean, isolated environment run. === inertia-laravel/core rules === diff --git a/app/Http/Controllers/WebPushController.php b/app/Http/Controllers/WebPushController.php new file mode 100644 index 0000000..4456c72 --- /dev/null +++ b/app/Http/Controllers/WebPushController.php @@ -0,0 +1,37 @@ +validate([ + 'endpoint' => 'required', + 'keys.p256dh' => 'required', + 'keys.auth' => 'required', + ]); + + $endpoint = $request->endpoint; + $token = $request->keys['auth']; + $key = $request->keys['p256dh']; + + Auth::user()->updatePushSubscription($endpoint, $key, $token); + + return response()->json(['success' => true]); + } + + public function destroy(Request $request) + { + $request->validate([ + 'endpoint' => 'required', + ]); + + Auth::user()->deletePushSubscription($request->endpoint); + + return response()->json(['success' => true]); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 73840d5..4aa28b1 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,6 +13,7 @@ use Illuminate\Support\Carbon; use Laravel\Fortify\Contracts\PasskeyUser; use Laravel\Fortify\PasskeyAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable; +use NotificationChannels\WebPush\HasPushSubscriptions; /** * @property int $id @@ -32,7 +33,7 @@ use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable implements PasskeyUser { /** @use HasFactory */ - use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable; + use HasFactory, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable, HasPushSubscriptions; public function dynamics() { diff --git a/app/Notifications/NewActivityNotification.php b/app/Notifications/NewActivityNotification.php new file mode 100644 index 0000000..404d3ee --- /dev/null +++ b/app/Notifications/NewActivityNotification.php @@ -0,0 +1,61 @@ +activity = $activity; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return [WebPushChannel::class]; + } + + /** + * Get the web push representation of the notification. + */ + public function toWebPush(object $notifiable): WebPushMessage + { + return (new WebPushMessage) + ->title('New Activity') + ->icon('/apple-touch-icon.png') + ->body($this->activity['content']) + ->action('View', 'view') + ->data(['url' => $this->activity['url']]); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + // + ]; + } +} diff --git a/app/Services/ActivityService.php b/app/Services/ActivityService.php index 5a94246..22e2cef 100644 --- a/app/Services/ActivityService.php +++ b/app/Services/ActivityService.php @@ -11,6 +11,8 @@ use App\Models\ReadCursor; use Illuminate\Support\Carbon; use Illuminate\Support\Str; +use App\Notifications\NewActivityNotification; + class ActivityService { /** @@ -54,6 +56,8 @@ class ActivityService 'subject_type' => $subject ? get_class($subject) : null, ]); + $this->notify($message); + return $message; } @@ -70,6 +74,21 @@ class ActivityService return $mutation; } + public function notify(Message $message) + { + $dynamic = $message->chat->chatable; + + if ($dynamic instanceof Dynamic) { + $participants = $dynamic->participants; + + foreach ($participants as $participant) { + if ($message->user_id !== $participant->id) { + $participant->notify(new NewActivityNotification($message)); + } + } + } + } + /** * Retrieve all activities for a given entity. */ diff --git a/composer.json b/composer.json index c1b0148..34e72fd 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.4", "inertiajs/inertia-laravel": "^3.0", + "laravel-notification-channels/webpush": "^11.0", "laravel/chisel": "^0.1.0", "laravel/fortify": "^1.37.2", "laravel/framework": "^13.7", diff --git a/composer.lock b/composer.lock index 6d9c794..121a434 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4f6fe33dc680e6446bd6318d5bdd9ec9", + "content-hash": "f4c79dcf0b7f9f54487404715d1085c1", "packages": [ { "name": "bacon/bacon-qr-code", @@ -1461,6 +1461,72 @@ }, "time": "2026-04-30T15:30:29+00:00" }, + { + "name": "laravel-notification-channels/webpush", + "version": "11.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel-notification-channels/webpush.git", + "reference": "85b577e64459a9df06a24062e2b300abbaa99fa9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel-notification-channels/webpush/zipball/85b577e64459a9df06a24062e2b300abbaa99fa9", + "reference": "85b577e64459a9df06a24062e2b300abbaa99fa9", + "shasum": "" + }, + "require": { + "illuminate/notifications": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "minishlink/web-push": "^10.0.1", + "php": "^8.2" + }, + "require-dev": { + "larastan/larastan": "^3.1", + "laravel/pint": "^1.25", + "mockery/mockery": "^1.0", + "orchestra/testbench": "^9.2|^10.0|^11.0", + "phpunit/phpunit": "^11.5.3|^12.5.12|^13.1.11", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NotificationChannels\\WebPush\\WebPushServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "NotificationChannels\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cretu Eusebiu", + "email": "me@cretueusebiu.com", + "homepage": "http://cretueusebiu.com", + "role": "Developer" + }, + { + "name": "Joost de Bruijn", + "email": "joost@aqualabs.nl", + "role": "Maintainer" + } + ], + "description": "Web Push Notifications driver for Laravel.", + "homepage": "https://github.com/laravel-notification-channels/webpush", + "support": { + "issues": "https://github.com/laravel-notification-channels/webpush/issues", + "source": "https://github.com/laravel-notification-channels/webpush/tree/11.0.0" + }, + "time": "2026-05-24T13:22:27+00:00" + }, { "name": "laravel/chisel", "version": "v0.1.1", @@ -2759,6 +2825,77 @@ ], "time": "2026-03-08T20:05:35+00:00" }, + { + "name": "minishlink/web-push", + "version": "v10.1.0", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/c922021b4ed1a61e6604d8dc33a2e0378b4382e3", + "reference": "c922021b4ed1a61e6604d8dc33a2e0378b4382e3", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "guzzlehttp/guzzle": "^7.9.2", + "php": ">=8.2", + "psr/log": "^2.0|^3.0", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v10.1.0" + }, + "time": "2026-05-28T09:37:37+00:00" + }, { "name": "monolog/monolog", "version": "3.10.0", @@ -5027,6 +5164,71 @@ ], "time": "2024-06-11T12:45:25+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, { "name": "spomky-labs/cbor-php", "version": "3.2.3", @@ -6702,6 +6904,86 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.38.1", @@ -8475,6 +8757,95 @@ ], "time": "2026-05-31T15:00:08+00:00" }, + { + "name": "web-token/jwt-library", + "version": "4.1.7", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/fbcbf2c276d04d8b056f5c2957815abd5dfb704d", + "reference": "fbcbf2c276d04d8b056f5c2957815abd5dfb704d", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.1.7" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-06-06T18:12:39+00:00" + }, { "name": "webmozart/assert", "version": "2.4.0", @@ -12251,5 +12622,5 @@ "php": "^8.4" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/database/migrations/2026_06_21_211120_create_push_subscriptions_table.php b/database/migrations/2026_06_21_211120_create_push_subscriptions_table.php new file mode 100644 index 0000000..567a743 --- /dev/null +++ b/database/migrations/2026_06_21_211120_create_push_subscriptions_table.php @@ -0,0 +1,36 @@ +create(config('webpush.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->morphs('subscribable', 'push_subscriptions_subscribable_morph_idx'); + $table->string('endpoint', 500)->unique(); + $table->string('public_key')->nullable(); + $table->string('auth_token')->nullable(); + $table->string('content_encoding')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::connection(config('webpush.database_connection'))->dropIfExists(config('webpush.table_name')); + } +}; diff --git a/resources/js/app.ts b/resources/js/app.ts index 45e170b..1a97687 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -49,3 +49,7 @@ initializeTheme(); // This will listen for flash toast data from the server... initializeFlashToast(); + +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js'); +} diff --git a/resources/js/composables/usePushNotifications.ts b/resources/js/composables/usePushNotifications.ts new file mode 100644 index 0000000..b62e9bc --- /dev/null +++ b/resources/js/composables/usePushNotifications.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue'; + +export function usePushNotifications() { + const isSubscribed = ref(false); + + async function subscribe() { + if (!('serviceWorker' in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(import.meta.env.VITE_VAPID_PUBLIC_KEY), + }); + + await axios.post('/subscriptions', subscription); + + isSubscribed.value = true; + } + + async function unsubscribe() { + if (!('serviceWorker' in navigator)) { + return; + } + + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + + if (subscription) { + await axios.post('/subscriptions/delete', { endpoint: subscription.endpoint }); + await subscription.unsubscribe(); + } + + isSubscribed.value = false; + } + + function urlBase64ToUint8Array(base64String: string) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; + } + + return { + isSubscribed, + subscribe, + unsubscribe, + }; +} diff --git a/resources/js/layouts/AppLayout.vue b/resources/js/layouts/AppLayout.vue index 627449c..9efc800 100644 --- a/resources/js/layouts/AppLayout.vue +++ b/resources/js/layouts/AppLayout.vue @@ -1,14 +1,25 @@ diff --git a/resources/js/sw.js b/resources/js/sw.js new file mode 100644 index 0000000..3ac6707 --- /dev/null +++ b/resources/js/sw.js @@ -0,0 +1,40 @@ +// This file is based on the one found in the laravel-notification-channels/webpush package. + +// We need this to get the VAPID_PUBLIC_KEY from the .env file +const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY; + +self.addEventListener('push', (event) => { + const payload = event.data ? event.data.json() : {}; + + const options = { + body: payload.body, + icon: payload.icon, + badge: payload.badge, + data: { + url: payload.url, + }, + }; + + event.waitUntil(self.registration.showNotification(payload.title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.openWindow(event.notification.data.url) + ); +}); + +if (vapidPublicKey) { + self.addEventListener('load', async () => { + if ('serviceWorker' in navigator) { + try { + const registration = await navigator.serviceWorker.register('/sw.js'); + console.log('Service worker registered.', registration); + } catch (error) { + console.error('Error registering service worker:', error); + } + } + }); +} diff --git a/routes/web.php b/routes/web.php index f577bce..e8fc889 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,9 @@ use Illuminate\Support\Facades\Route; Route::inertia('/', 'Welcome')->name('home'); +Route::post('subscriptions', [\App\Http\Controllers\WebPushController::class, 'store'])->name('subscriptions.store'); +Route::post('subscriptions/delete', [\App\Http\Controllers\WebPushController::class, 'destroy'])->name('subscriptions.destroy'); + Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', [DashboardController::class, 'index'])->name('dashboard'); diff --git a/vite.config.ts b/vite.config.ts index 5dbc299..da0b920 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,7 +12,7 @@ export default defineConfig({ }, plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.ts'], + input: ['resources/css/app.css', 'resources/js/app.ts', 'resources/js/sw.js'], refresh: true, fonts: [ bunny('Instrument Sans', {