@serwist/next
Version:
A module that integrates Serwist into your Next.js application.
270 lines (260 loc) • 12.8 kB
JavaScript
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { InjectManifest } from '@serwist/webpack-plugin';
import { ChildCompilationPlugin, relativeToOutputPath } from '@serwist/webpack-plugin/internal';
import { globSync } from 'glob';
import crypto from 'node:crypto';
import { createRequire } from 'node:module';
import chalk from 'chalk';
import { validationErrorMap, SerwistConfigError } from '@serwist/build/schema';
import { z } from 'zod';
import { a as injectManifestOptions } from './chunks/schema.js';
import '@serwist/webpack-plugin/schema';
const findFirstTruthy = (arr, fn)=>{
for (const i of arr){
const resolved = fn(i);
if (resolved) {
return resolved;
}
}
return undefined;
};
const getFileHash = (file)=>crypto.createHash("md5").update(fs.readFileSync(file)).digest("hex");
const getContentHash = (file, isDev)=>{
if (isDev) {
return "development";
}
return getFileHash(file).slice(0, 16);
};
createRequire(import.meta.url);
const loadTSConfig = (baseDir, relativeTSConfigPath)=>{
try {
const tsConfigPath = findFirstTruthy([
relativeTSConfigPath ?? "tsconfig.json",
"jsconfig.json"
], (filePath)=>{
const resolvedPath = path.join(baseDir, filePath);
return fs.existsSync(resolvedPath) ? resolvedPath : undefined;
});
if (!tsConfigPath) {
return undefined;
}
return JSON.parse(fs.readFileSync(tsConfigPath, "utf-8"));
} catch {
return undefined;
}
};
const mapLoggingMethodToConsole = {
wait: "log",
error: "error",
warn: "warn",
info: "log",
event: "log"
};
const prefixes = {
wait: `${chalk.white(chalk.bold("○"))} (serwist)`,
error: `${chalk.red(chalk.bold("X"))} (serwist)`,
warn: `${chalk.yellow(chalk.bold("⚠"))} (serwist)`,
info: `${chalk.white(chalk.bold("○"))} (serwist)`,
event: `${chalk.green(chalk.bold("✓"))} (serwist)`
};
const prefixedLog = (prefixType, ...message)=>{
const consoleMethod = mapLoggingMethodToConsole[prefixType];
const prefix = prefixes[prefixType];
if ((message[0] === "" || message[0] === undefined) && message.length === 1) {
message.shift();
}
if (message.length === 0) {
console[consoleMethod]("");
} else {
console[consoleMethod](` ${prefix}`, ...message);
}
};
const info = (...message)=>{
prefixedLog("info", ...message);
};
const event = (...message)=>{
prefixedLog("event", ...message);
};
const validateInjectManifestOptions = (input)=>{
const result = injectManifestOptions.safeParse(input, {
error: validationErrorMap
});
if (!result.success) {
throw new SerwistConfigError({
moduleName: "@serwist/next",
message: z.prettifyError(result.error)
});
}
return result.data;
};
const dirname = "__dirname" in globalThis ? __dirname : fileURLToPath(new URL(".", import.meta.url));
let warnedTurbopack = false;
const withSerwistInit = (userOptions)=>{
if (!warnedTurbopack && process.env.TURBOPACK && !userOptions.disable && !process.env.SERWIST_SUPPRESS_TURBOPACK_WARNING) {
warnedTurbopack = true;
console.warn(`[@serwist/next] WARNING: You are using '@serwist/next' with \`next dev --turbopack\`, but Serwist doesn't support Turbopack at the moment. It is recommended that you set \`disable\` to \`process.env.NODE_ENV !== \"production\"\`. Follow https://github.com/serwist/serwist/issues/54 for progress on Serwist + Turbopack. You can also suppress this warning by setting SERWIST_SUPPRESS_TURBOPACK_WARNING=1.`);
}
return (nextConfig = {})=>({
...nextConfig,
webpack (config, options) {
const webpack = options.webpack;
const { dev } = options;
const basePath = options.config.basePath || "/";
const tsConfigJson = loadTSConfig(options.dir, nextConfig?.typescript?.tsconfigPath);
const { cacheOnNavigation, disable, scope = basePath, swUrl, register, reloadOnOnline, globPublicPatterns, ...buildOptions } = validateInjectManifestOptions(userOptions);
if (typeof nextConfig.webpack === "function") {
config = nextConfig.webpack(config, options);
}
if (disable) {
options.isServer && info("Serwist is disabled.");
return config;
}
if (!config.plugins) {
config.plugins = [];
}
const _sw = path.posix.join(basePath, swUrl);
const _scope = path.posix.join(scope, "/");
config.plugins.push(new webpack.DefinePlugin({
"self.__SERWIST_SW_ENTRY.sw": `'${_sw}'`,
"self.__SERWIST_SW_ENTRY.scope": `'${_scope}'`,
"self.__SERWIST_SW_ENTRY.cacheOnNavigation": `${cacheOnNavigation}`,
"self.__SERWIST_SW_ENTRY.register": `${register}`,
"self.__SERWIST_SW_ENTRY.reloadOnOnline": `${reloadOnOnline}`
}));
const swEntryJs = path.join(dirname, "sw-entry.js");
const entry = config.entry;
config.entry = async ()=>{
const entries = await entry();
if (entries["main.js"] && !entries["main.js"].includes(swEntryJs)) {
if (Array.isArray(entries["main.js"])) {
entries["main.js"].unshift(swEntryJs);
} else if (typeof entries["main.js"] === "string") {
entries["main.js"] = [
swEntryJs,
entries["main.js"]
];
}
}
if (entries["main-app"] && !entries["main-app"].includes(swEntryJs)) {
if (Array.isArray(entries["main-app"])) {
entries["main-app"].unshift(swEntryJs);
} else if (typeof entries["main-app"] === "string") {
entries["main-app"] = [
swEntryJs,
entries["main-app"]
];
}
}
return entries;
};
if (!options.isServer) {
if (!register) {
info("The service worker will not be automatically registered, please call 'window.serwist.register()' in 'componentDidMount' or 'useEffect'.");
if (!tsConfigJson?.compilerOptions?.types?.includes("@serwist/next/typings")) {
info("You may also want to add '@serwist/next/typings' to your TypeScript/JavaScript configuration file at 'compilerOptions.types'.");
}
}
const { swSrc: userSwSrc, swDest: userSwDest, additionalPrecacheEntries, exclude, manifestTransforms = [], ...otherBuildOptions } = buildOptions;
let swSrc = userSwSrc;
let swDest = userSwDest;
if (!path.isAbsolute(swSrc)) {
swSrc = path.join(options.dir, swSrc);
}
if (!path.isAbsolute(swDest)) {
swDest = path.join(options.dir, swDest);
}
const publicDir = path.resolve(options.dir, "public");
const { dir: destDir, base: destBase } = path.parse(swDest);
const cleanUpList = globSync([
"swe-worker-*.js",
"swe-worker-*.js.map",
destBase,
`${destBase}.map`
], {
absolute: true,
nodir: true,
cwd: destDir
});
for (const file of cleanUpList){
fs.rm(file, {
force: true
}, (err)=>{
if (err) throw err;
});
}
const shouldBuildSWEntryWorker = cacheOnNavigation;
let swEntryPublicPath;
let swEntryWorkerDest;
if (shouldBuildSWEntryWorker) {
const swEntryWorkerSrc = path.join(dirname, "sw-entry-worker.js");
const swEntryName = `swe-worker-${getContentHash(swEntryWorkerSrc, dev)}.js`;
swEntryPublicPath = path.posix.join(basePath, swEntryName);
swEntryWorkerDest = path.join(destDir, swEntryName);
config.plugins.push(new ChildCompilationPlugin({
src: swEntryWorkerSrc,
dest: swEntryWorkerDest
}));
}
config.plugins.push(new webpack.DefinePlugin({
"self.__SERWIST_SW_ENTRY.swEntryWorker": swEntryPublicPath && `'${swEntryPublicPath}'`
}));
event(`Bundling the service worker script with the URL '${_sw}' and the scope '${_scope}'...`);
let resolvedManifestEntries = additionalPrecacheEntries;
if (!resolvedManifestEntries) {
const publicScan = globSync(globPublicPatterns, {
nodir: true,
cwd: publicDir,
ignore: [
"swe-worker-*.js",
destBase,
`${destBase}.map`
]
});
resolvedManifestEntries = publicScan.map((f)=>({
url: path.posix.join(basePath, f),
revision: getFileHash(path.join(publicDir, f))
}));
}
const publicPath = config.output?.publicPath;
config.plugins.push(new InjectManifest({
swSrc,
swDest,
disablePrecacheManifest: dev,
additionalPrecacheEntries: dev ? [] : resolvedManifestEntries,
exclude: [
...exclude,
({ asset, compilation })=>{
const swDestRelativeOutput = relativeToOutputPath(compilation, swDest);
const swAsset = compilation.getAsset(swDestRelativeOutput);
return asset.name === swAsset?.name || asset.name.startsWith("server/") || /^[^/]*\.json$/.test(asset.name) || dev && !asset.name.startsWith("static/runtime/");
}
],
manifestTransforms: [
...manifestTransforms,
async (manifestEntries, compilation)=>{
const publicDirRelativeOutput = relativeToOutputPath(compilation, publicDir);
const publicFilesPrefix = `${publicPath}${publicDirRelativeOutput}`;
const manifest = manifestEntries.map((m)=>{
m.url = m.url.replace("/_next//static/image", "/_next/static/image").replace("/_next//static/media", "/_next/static/media");
if (m.url.startsWith(publicFilesPrefix)) {
m.url = path.posix.join(basePath, m.url.replace(publicFilesPrefix, ""));
}
m.url = m.url.replace(/\[/g, "%5B").replace(/\]/g, "%5D").replace(/@/g, "%40");
return m;
});
return {
manifest,
warnings: []
};
}
],
...otherBuildOptions
}));
}
return config;
}
});
};
export { withSerwistInit as default, validateInjectManifestOptions };