UNPKG

@serwist/next

Version:

A module that integrates Serwist into your Next.js application.

270 lines (260 loc) 12.8 kB
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 };