feat(mobile): install Muzick to the home screen, and get there quickly
Adds the manifest, icons and service worker that make the app installable, and offers it as a toast once Chrome says it qualifies. Declining snoozes the offer for a month; installing ends it. A waiting service worker never activates on its own. Reloading the page under a listener to swap in a new build would cut the song they are in the middle of, so updates land on the next cold start instead. Audio is kept out of the cache entirely: range requests and multi-megabyte bodies do not belong in a shell cache. Artwork is cached, and the SPA navigation fallback denies /api so it cannot swallow the event stream. Installed on Android the app paints edge to edge, so the transport pads itself past the gesture bar. MediaSession gains setPositionState, which is what gives the notification shade a seek bar that moves. Three things kept the bundle from ever being compressed, each hiding the next: the nginx image ships with gzip off, gzip_proxied defaults to off and skips anything carrying a Via header, and gzip_http_version defaults to 1.1 while the host proxy speaks 1.0. With those fixed and the pages split per route, the first load goes from 555KB to 60KB of app code plus a vendor chunk that survives redeploys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+7
-1
@@ -2,7 +2,13 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<!-- viewport-fit=cover lets the app paint under the Android gesture bar; the
|
||||||
|
transport pads itself back out with env(safe-area-inset-bottom). -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#14110D" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<title>Muzick</title>
|
<title>Muzick</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,12 +1,60 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
|
||||||
|
# The nginx image ships with gzip off, so the bundle went out raw: 555KB of
|
||||||
|
# JS where gzip sends 167KB. Only text formats are listed — images, fonts
|
||||||
|
# (woff2) and audio are already compressed and would only burn CPU.
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
# The public URL sits behind another proxy, which adds a Via header. With
|
||||||
|
# the default gzip_proxied off, nginx refuses to compress those responses.
|
||||||
|
gzip_proxied any;
|
||||||
|
# That same proxy speaks HTTP/1.0 upstream (nginx proxy_pass defaults to it),
|
||||||
|
# and gzip_http_version defaults to 1.1 — so without this nothing here is
|
||||||
|
# ever compressed, however the client asks.
|
||||||
|
gzip_http_version 1.0;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/manifest+json
|
||||||
|
image/svg+xml
|
||||||
|
text/css
|
||||||
|
text/plain;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# A cached service worker or shell would pin the app to an old build, since
|
||||||
|
# both are the files that point at every hashed asset. Assets themselves are
|
||||||
|
# content-hashed by Vite, so they can be held forever.
|
||||||
|
# nginx's stock mime.types has no .webmanifest entry, so it would otherwise
|
||||||
|
# go out as application/octet-stream.
|
||||||
|
location = /manifest.webmanifest {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
default_type application/manifest+json;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sw.js {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "no-cache, must-revalidate";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
# Admin endpoints need the admin key, not the regular API key. This prefix
|
# Admin endpoints need the admin key, not the regular API key. This prefix
|
||||||
# location is longer than "/api", and nginx picks the longest matching
|
# location is longer than "/api", and nginx picks the longest matching
|
||||||
# prefix location, so it wins for /api/admin/* while /api handles the rest.
|
# prefix location, so it wins for /api/admin/* while /api handles the rest.
|
||||||
|
|||||||
Generated
+4109
-41
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@
|
|||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
|
"vite-plugin-pwa": "^1.3.0",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 890 B |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -9,6 +9,7 @@ import { LyricsOverlay } from './LyricsOverlay';
|
|||||||
import { Toaster } from './Toaster';
|
import { Toaster } from './Toaster';
|
||||||
import { CommandPalette } from './CommandPalette';
|
import { CommandPalette } from './CommandPalette';
|
||||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||||
|
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
||||||
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
||||||
|
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
@@ -19,6 +20,8 @@ export default function AppShell() {
|
|||||||
|
|
||||||
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
|
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
|
||||||
|
|
||||||
|
useInstallPrompt();
|
||||||
|
|
||||||
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
|
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
|
||||||
useKeyboard({
|
useKeyboard({
|
||||||
code: 'KeyK',
|
code: 'KeyK',
|
||||||
|
|||||||
@@ -586,6 +586,40 @@ export const AudioEngine = () => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// --- MediaSession: publish position -----------------------------------------
|
||||||
|
//
|
||||||
|
// Without a position state the Android notification renders a scrubber that is
|
||||||
|
// stuck at zero. setPositionState throws if position exceeds duration, which
|
||||||
|
// happens transiently at a track handover, so both are clamped.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
|
||||||
|
|
||||||
|
const publish = (position: number, duration: number) => {
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||||
|
try {
|
||||||
|
navigator.mediaSession.setPositionState({
|
||||||
|
duration,
|
||||||
|
position: Math.min(Math.max(position, 0), duration),
|
||||||
|
playbackRate: 1,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Stale position against a just-changed duration — the next tick fixes it.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let lastPosition = -1;
|
||||||
|
let lastDuration = -1;
|
||||||
|
|
||||||
|
return usePlaybackStore.subscribe((state) => {
|
||||||
|
// timeupdate fires ~4x a second; only republish on a whole-second change.
|
||||||
|
const second = Math.floor(state.position);
|
||||||
|
if (second === lastPosition && state.duration === lastDuration) return;
|
||||||
|
lastPosition = second;
|
||||||
|
lastDuration = state.duration;
|
||||||
|
publish(state.position, state.duration);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Drop in-flight fades and any pending handover when the engine unmounts.
|
// Drop in-flight fades and any pending handover when the engine unmounts.
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
|
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass border-t border-border/70 px-3 py-2 shrink-0 z-20 sm:h-20 sm:px-4 sm:py-0">
|
<div className="glass safe-x safe-b border-t border-border/70 pt-2 shrink-0 z-20 [--safe-b-base:16px] [--safe-x-base:24px] sm:h-20 sm:pt-0 sm:[--safe-b-base:0px] sm:[--safe-x-base:32px]">
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
|
||||||
{/* Track info */}
|
{/* Track info */}
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useToastStore, toast } from '../store/useToastStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome fires `beforeinstallprompt` when the app qualifies for installation.
|
||||||
|
* Not in lib.dom yet, so the shape is declared here.
|
||||||
|
*/
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNOOZE_KEY = 'muzick.install-prompt.snoozed-at';
|
||||||
|
const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// Long enough that the suggestion lands after the app has proved useful, not
|
||||||
|
// while the first page is still painting.
|
||||||
|
const DELAY_MS = 20_000;
|
||||||
|
|
||||||
|
function snoozed(): boolean {
|
||||||
|
try {
|
||||||
|
const at = Number(localStorage.getItem(SNOOZE_KEY));
|
||||||
|
return Number.isFinite(at) && at > 0 && Date.now() - at < SNOOZE_MS;
|
||||||
|
} catch {
|
||||||
|
return false; // Private mode or blocked storage — just ask.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function snooze() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SNOOZE_KEY, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offers "Add to Home screen" as a toast, once the browser says the app is
|
||||||
|
* installable. Declining snoozes the suggestion for a month; installing
|
||||||
|
* silences it for good.
|
||||||
|
*/
|
||||||
|
export function useInstallPrompt() {
|
||||||
|
useEffect(() => {
|
||||||
|
// Already installed — the standalone display mode is the reliable signal.
|
||||||
|
if (window.matchMedia?.('(display-mode: standalone)').matches) return;
|
||||||
|
if (snoozed()) return;
|
||||||
|
|
||||||
|
let deferred: BeforeInstallPromptEvent | null = null;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let unsubscribe: (() => void) | undefined;
|
||||||
|
|
||||||
|
const offer = () => {
|
||||||
|
if (!deferred) return;
|
||||||
|
const event = deferred;
|
||||||
|
let accepted = false;
|
||||||
|
|
||||||
|
const id = toast.info('Muzick runs better from your home screen.', {
|
||||||
|
ttl: 0,
|
||||||
|
action: {
|
||||||
|
label: 'Install',
|
||||||
|
onClick: () => {
|
||||||
|
accepted = true;
|
||||||
|
unsubscribe?.();
|
||||||
|
// A prompt can only be shown once per event; drop it either way.
|
||||||
|
deferred = null;
|
||||||
|
void event.prompt().then(() => event.userChoice).then((choice) => {
|
||||||
|
if (choice.outcome === 'dismissed') snooze();
|
||||||
|
}).catch(() => snooze());
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// The toast's own close button calls dismiss() directly, so the only way
|
||||||
|
// to notice a decline is to watch the toast leave the store.
|
||||||
|
unsubscribe = useToastStore.subscribe((state) => {
|
||||||
|
if (accepted) return;
|
||||||
|
if (!state.toasts.some((t) => t.id === id)) {
|
||||||
|
snooze();
|
||||||
|
unsubscribe?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBeforeInstallPrompt = (e: Event) => {
|
||||||
|
// Suppress Chrome's own mini-infobar so only our toast asks.
|
||||||
|
e.preventDefault();
|
||||||
|
deferred = e as BeforeInstallPromptEvent;
|
||||||
|
timer = setTimeout(offer, DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInstalled = () => {
|
||||||
|
deferred = null;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
snooze();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||||
|
window.addEventListener('appinstalled', onInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||||
|
window.removeEventListener('appinstalled', onInstalled);
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
unsubscribe?.();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -112,6 +112,19 @@ input[type='range'] {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Safe area ────────────────────────────────────────────────────────────────
|
||||||
|
Installed on Android the app paints edge to edge (viewport-fit=cover), so the
|
||||||
|
gesture bar and any display cutout sit on top of the layout. These add the
|
||||||
|
inset on top of whatever padding the element already carries. */
|
||||||
|
.safe-b {
|
||||||
|
padding-bottom: calc(var(--safe-b-base, 0px) + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-x {
|
||||||
|
padding-left: calc(var(--safe-x-base, 0px) + env(safe-area-inset-left));
|
||||||
|
padding-right: calc(var(--safe-x-base, 0px) + env(safe-area-inset-right));
|
||||||
|
}
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
input[type='range']::-webkit-slider-thumb {
|
input[type='range']::-webkit-slider-thumb {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
|
|||||||
+25
-13
@@ -2,22 +2,29 @@ import {
|
|||||||
createRootRoute,
|
createRootRoute,
|
||||||
createRoute,
|
createRoute,
|
||||||
createRouter,
|
createRouter,
|
||||||
|
lazyRouteComponent,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import AppShell from './components/AppShell';
|
import AppShell from './components/AppShell';
|
||||||
|
import { LoadingState } from './components/LoadingState';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
import Artists from './pages/Artists';
|
|
||||||
import ArtistDetail from './pages/ArtistDetail';
|
// Home is imported eagerly: it is the landing route, and deferring it would put
|
||||||
import Albums from './pages/Albums';
|
// a second round trip in front of the first paint. Every other page is a
|
||||||
import AlbumDetail from './pages/AlbumDetail';
|
// separate chunk, fetched when the listener first goes there and then held by
|
||||||
import Tracks from './pages/Tracks';
|
// the service worker.
|
||||||
import Genres from './pages/Genres';
|
const Artists = lazyRouteComponent(() => import('./pages/Artists'));
|
||||||
import Vibe from './pages/Vibe';
|
const ArtistDetail = lazyRouteComponent(() => import('./pages/ArtistDetail'));
|
||||||
import Recommendations from './pages/Recommendations';
|
const Albums = lazyRouteComponent(() => import('./pages/Albums'));
|
||||||
import Search from './pages/Search';
|
const AlbumDetail = lazyRouteComponent(() => import('./pages/AlbumDetail'));
|
||||||
import Settings from './pages/Settings';
|
const Tracks = lazyRouteComponent(() => import('./pages/Tracks'));
|
||||||
import Quarantine from './pages/Quarantine';
|
const Genres = lazyRouteComponent(() => import('./pages/Genres'));
|
||||||
import Jobs from './pages/Jobs';
|
const Vibe = lazyRouteComponent(() => import('./pages/Vibe'));
|
||||||
|
const Recommendations = lazyRouteComponent(() => import('./pages/Recommendations'));
|
||||||
|
const Search = lazyRouteComponent(() => import('./pages/Search'));
|
||||||
|
const Settings = lazyRouteComponent(() => import('./pages/Settings'));
|
||||||
|
const Quarantine = lazyRouteComponent(() => import('./pages/Quarantine'));
|
||||||
|
const Jobs = lazyRouteComponent(() => import('./pages/Jobs'));
|
||||||
|
|
||||||
// Root route renders the AppShell (NavRail + TopBar + PlaybackBar + NowPlayingPanel)
|
// Root route renders the AppShell (NavRail + TopBar + PlaybackBar + NowPlayingPanel)
|
||||||
// with an <Outlet/> where the active child route renders.
|
// with an <Outlet/> where the active child route renders.
|
||||||
@@ -122,7 +129,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
jobsRoute,
|
jobsRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({ routeTree });
|
export const router = createRouter({
|
||||||
|
routeTree,
|
||||||
|
// Shown while a route's chunk is in flight. Without it the shell holds the
|
||||||
|
// previous page and the tap reads as a dropped input.
|
||||||
|
defaultPendingComponent: () => <LoadingState />,
|
||||||
|
});
|
||||||
|
|
||||||
// Type-safety: register the router instance type globally.
|
// Type-safety: register the router instance type globally.
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
|
|||||||
+68
-1
@@ -1,8 +1,65 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
includeAssets: ['favicon-32.png', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Muzick',
|
||||||
|
short_name: 'Muzick',
|
||||||
|
description: 'Self-hosted music player and recommendation engine',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
background_color: '#14110D',
|
||||||
|
theme_color: '#14110D',
|
||||||
|
icons: [
|
||||||
|
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||||
|
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||||
|
{
|
||||||
|
src: '/icon-maskable-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,woff2}'],
|
||||||
|
|
||||||
|
// The SPA fallback must never swallow the API: /api/playback/stream is a
|
||||||
|
// long-lived SSE channel and the track endpoints stream audio. Both look
|
||||||
|
// like navigations to Workbox unless they are denied here.
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
|
|
||||||
|
// A waiting service worker would otherwise activate and reload the page
|
||||||
|
// mid-song. Letting it wait means updates land on the next cold start,
|
||||||
|
// which is the right trade for a player that runs in the background.
|
||||||
|
skipWaiting: false,
|
||||||
|
clientsClaim: false,
|
||||||
|
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
// Artwork only. Audio is deliberately absent: range requests and
|
||||||
|
// multi-megabyte bodies do not belong in the shell cache.
|
||||||
|
urlPattern: ({ request }) => request.destination === 'image',
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'muzick-artwork',
|
||||||
|
expiration: { maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
cacheableResponse: { statuses: [0, 200] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: './src/test/setup.ts',
|
setupFiles: './src/test/setup.ts',
|
||||||
@@ -18,5 +75,15 @@ export default defineConfig({
|
|||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
// The framework changes on an npm upgrade, the app changes every
|
||||||
|
// deploy. Splitting them means a redeploy re-downloads app code only,
|
||||||
|
// which matters here because the service worker precaches the lot.
|
||||||
|
manualChunks: {
|
||||||
|
vendor: ['react', 'react-dom', '@tanstack/react-router', '@tanstack/react-query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user