SvelteKit
Introduction
As a simple wrapper over SvelteKit’s built-in service worker support, @serwist/svelte
does not do the versioning itself; rather, it relies on SvelteKit to give it a revision, which will be used to construct a valid precache manifest. However, this revision is shared across all assets, excluding static assets if staticRevisions
is set, and each time your app is rebuilt, its value changes, causing your service worker to have to precache everything all over again. Sometimes you may want the precaching to be more efficient in that the revision of each asset is unique and stable, and that’s where @serwist/vite
comes in: it can generate a valid and efficient precache manifest for you. This page serves as a guide to help you integrate @serwist/vite
into your SvelteKit application.
Install
Run the following command:
npm i -D @serwist/build @serwist/vite @serwist/window serwist
Implementation
Step 1: Update tsconfig.json
If you use TypeScript, you should add the following content to tsconfig.json in order to get the correct types:
{
// Other stuff...
"compilerOptions": {
// Other options...
"types": [
// Other types...
// This allows Serwist to correctly type "virtual:serwist".
"@serwist/vite/typings"
],
},
}
Otherwise, safely skip this step.
Step 2: Update .gitignore
If you use Git, update your .gitignore like so:
# Serwist
public/sw*
public/swe-worker*
Otherwise, safely skip this step.
Step 3: Configure SvelteKit
Then, configure SvelteKit so that it does not automatically register your service worker. This is, by default, done when it detects a service worker in your source tree.
/** @type {import("@sveltejs/kit").Config} */
const config = {
// Other options...
kit: {
serviceWorker: {
register: false,
},
},
};
export default config;
Step 4: Configure Vite
Next, write a small Vite plugin:
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { errors } from "@serwist/build";
import { createApi, createContext, dev as devPlugin, main as mainPlugin } from "@serwist/vite";
import type { PluginOptions, SerwistViteApi, SerwistViteContext } from "@serwist/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import type { Plugin } from "vite";
import { defineConfig } from "vite";
import config from "./svelte.config";
// Source: https://github.com/sveltejs/kit/blob/6419d3eaa7bf1b0a756b28f06a73f71fe042de0a/packages/kit/src/utils/filesystem.js
// License: MIT
/**
* Resolves a file path without extension. Also handles `/index` if the path
* actually points to a directory.
* @param ctx
* @param api
* @returns
*/
const resolveEntry = (entry: string): string | null => {
if (fs.existsSync(entry)) {
const stats = fs.statSync(entry);
if (stats.isDirectory()) {
return resolveEntry(path.join(entry, "index"));
}
return entry;
}
const dir = path.dirname(entry);
if (fs.existsSync(dir)) {
const base = path.basename(entry);
const files = fs.readdirSync(dir);
const found = files.find((file) => file.replace(/\.[^.]+$/, "") === base);
if (found) return path.join(dir, found);
}
return null;
};
// We do not rely on `@serwist/vite`'s built-in `buildPlugin` because
// it runs during the client build, but SvelteKit builds the service worker
// during the server build, which takes place after the client one.
/**
* Custom Serwist build plugin for your custom SvelteKit integration.
* @param ctx
* @param api
* @returns
*/
const buildPlugin = (ctx: SerwistViteContext, api: SerwistViteApi) => {
return <Plugin>{
name: "@serwist/vite:build",
apply: "build",
enforce: "pre",
closeBundle: {
sequential: true,
order: ctx.userOptions?.integration?.closeBundleOrder,
async handler() {
if (api && !api.disabled && ctx.viteConfig.build.ssr) {
await api.generateSW();
}
},
},
buildEnd(error) {
if (error) throw error;
},
};
};
// Here is the main logic: it stores your Serwist configuration, creates `@serwist/vite`'s
// context and API, and constructs the necessary Vite plugins.
const serwist = (): Plugin[] => {
let buildAssetsDir = config.kit?.appDir ?? "_app/";
if (buildAssetsDir[0] === "/") {
buildAssetsDir = buildAssetsDir.slice(1);
}
if (buildAssetsDir[buildAssetsDir.length - 1] !== "/") {
buildAssetsDir += "/";
}
// This part is your Serwist configuration.
const options: PluginOptions = {
// We will set these later in `configureOptions`.
swSrc: null!,
swDest: null!,
swUrl: "/service-worker.js",
// We will set this later in `configureOptions`.
globDirectory: null!,
globPatterns: [
// Static assets.
"client/**/*.{js,css,ico,png,svg,webp,json,webmanifest}",
// Note: comment out the following if you don't have prerendered pages.
"prerendered/pages/**/*.html",
// Note: comment out the following if your prerendered pages do not have any data.
"prerendered/dependencies/**/__data.json",
],
globIgnores: ["server/*.*", "client/service-worker.js"],
injectionPoint: "self.__SW_MANIFEST",
integration: {
closeBundleOrder: "pre",
// These options depend on `viteConfig`, so we have to use `@serwist/vite`'s configuration hook.
configureOptions(viteConfig, options) {
const clientOutDir = path.resolve(viteConfig.root, viteConfig.build.outDir, "../client");
// Kit fixes the service worker's name to 'service-worker.js'
// This tells Serwist to replace `injectionPoint` with the precache manifest in the bundled service worker.
if (viteConfig.isProduction) {
options.swSrc = path.resolve(clientOutDir, "service-worker.js");
options.swDest = path.resolve(clientOutDir, "service-worker.js");
} else {
// In development, you may want `@serwist/vite` to bundle your service worker and make it available at `swUrl`.
// Resolve `swSrc` the same way as SvelteKit's.
const swSrc = resolveEntry(path.join(viteConfig.root, config.kit?.files?.serviceWorker ?? "src/service-worker"));
if (swSrc) {
options.swSrc = swSrc;
// We want to save the resulting development service worker somewhere on the filesystem
// so that `@serwist/build` can pick it up.
options.swDest = path.join(os.tmpdir(), `serwist-vite-integration-svelte-${crypto.randomUUID()}.js`);
} else {
throw new Error(errors["invalid-sw-src"]);
}
}
// `clientOutDir` is '.svelte-kit/output/client'. However, since we also want to precache prerendered
// pages in the '.svelte-kit/output/prerendered' directory, we have to move one directory up.
options.globDirectory = path.resolve(clientOutDir, "..");
options.manifestTransforms = [
// This `manifestTransform` makes the precache manifest valid.
async (entries) => {
const manifest = entries.map((e) => {
// Static assets are in the ".svelte-kit/output/client" directory.
// Prerender pages are in the ".svelte-kit/output/prerendered/pages" directory.
// Remove the prefix, but keep the ending slash.
if (e.url.startsWith("client/")) {
e.url = e.url.slice(6);
} else if (e.url.startsWith("prerendered/pages/")) {
e.url = e.url.slice(17);
} else if (e.url.startsWith("prerendered/dependencies/")) {
e.url = e.url.slice(24);
}
if (e.url.endsWith(".html")) {
// trailingSlash: 'always'
// https://kit.svelte.dev/docs/page-options#trailingslash
// "/abc/index.html" -> "/abc/"
// "/index.html" -> "/"
if (e.url.endsWith("/index.html")) {
e.url = e.url.slice(0, e.url.lastIndexOf("/") + 1);
}
// trailingSlash: 'ignored'
// trailingSlash: 'never'
// https://kit.svelte.dev/docs/page-options#trailingslash
// "/xxx.html" -> "/xxx"
else {
e.url = e.url.substring(0, e.url.lastIndexOf("."));
}
}
// Finally, prepend `viteConfig.base`.
// "/path" -> "/base/path"
// "/" -> "/base/"
e.url = path.posix.join(viteConfig.base, e.url);
return e;
});
return { manifest };
},
];
},
},
// We don't want to version 'client/_app/immutable/**/*' files because they are
// already versioned by Vite via their URLs.
dontCacheBustURLsMatching: new RegExp(`^client/${buildAssetsDir}immutable/`),
};
const ctx = createContext(options, undefined);
const api = createApi(ctx);
return [mainPlugin(ctx, api), devPlugin(ctx, api), buildPlugin(ctx, api)];
};
export default defineConfig({
plugins: [sveltekit(), serwist()],
});
The configuration in the code above aims to precache all static assets and prerendered pages, same as @serwist/svelte
’s behaviour. However, each asset is versioned based on either its content or its URL rather than the whole app’s version.
For immutable assets, we tell Serwist that they are uniquely versioned via their URLs through the use of dontCacheBustURLsMatching
. This causes the revision
of these assets to be null
.
Step 5: Write a service worker
As always, you need to write a service worker:
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" />
import { defaultCache } from "@serwist/vite/worker";
import type { PrecacheEntry, SerwistGlobalConfig } from "serwist";
import { Serwist } from "serwist";
// This declares the value of `injectionPoint` to TypeScript.
// `injectionPoint` is the string that will be replaced by the
// actual precache manifest. By default, this string is set to
// `"self.__SW_MANIFEST"`.
declare global {
interface WorkerGlobalScope extends SerwistGlobalConfig {
__SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
}
}
declare const self: ServiceWorkerGlobalScope;
// See https://serwist.pages.dev/docs/serwist/core/serwist.
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
precacheOptions: {
cleanupOutdatedCaches: true,
concurrency: 20,
ignoreURLParametersMatching: [/^x-sveltekit-invalidated$/],
},
skipWaiting: true,
clientsClaim: true,
navigationPreload: false,
disableDevLogs: true,
runtimeCaching: defaultCache,
});
serwist.addEventListeners();
Step 6: Add a web application manifest
We are almost there! To turn your website into a PWA, write a web application manifest:
{
"name": "My awesome PWA app",
"short_name": "PWA App",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#FFFFFF",
"background_color": "#FFFFFF",
"start_url": "/",
"display": "standalone",
"orientation": "portrait"
// ...
}
Step 7: Register the service worker and add metadata
Finally, update the app’s main layout:
<script>
import { getSerwist } from "virtual:serwist";
const { children } = $props();
$effect(() => {
const loadSerwist = async () => {
if ("serviceWorker" in navigator) {
const serwist = await getSerwist();
serwist?.addEventListener("installed", () => {
console.log("Serwist installed!");
});
void serwist?.register();
}
}
loadSerwist();
});
</script>
<svelte:head>
<title>My Awesome PWA App</title>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="application-name" content="PWA App" />
<meta name="description" content="Best PWA app in the world!" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="PWA App" />
<meta property="og:title" content="My Awesome PWA App" />
<meta property="og:description" content="Best PWA app in the world!" />
<meta name="twitter:title" content="My Awesome PWA App" />
<meta name="twitter:description" content="Best PWA app in the world!" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="theme-color" content="#FFFFFF" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="My Awesome PWA App" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="format-detection" content="telephone=no" />
</svelte:head>
{@render children()}