@serwist/turbopack
Version:
A module that integrates Serwist into your Next.js / Turbopack application.
149 lines (142 loc) • 6 kB
text/typescript
// Workaround for Next.js + Turbopack, while plugins are still
// not supported. This relies on Next.js Route Handlers and file
// name determinism.
import path from "node:path";
import { type BuildResult, getFileManifestEntries, rebasePath } from "@serwist/build";
import { SerwistConfigError, validationErrorMap } from "@serwist/build/schema";
import { cyan, dim, yellow } from "kolorist";
import { NextResponse } from "next/server.js";
import { z } from "zod";
import { injectManifestOptions } from "./index.schema.js";
import { logger } from "./lib/index.js";
import type { InjectManifestOptions, InjectManifestOptionsComplete } from "./types.js";
// TODO(workarond): `esbuild` doesn't load when in Turbopack production
const esbuild = import("esbuild-wasm");
const logSerwistResult = (filePath: string, buildResult: Pick<BuildResult, "count" | "size" | "warnings">) => {
const { count, size, warnings } = buildResult;
const hasWarnings = warnings && warnings.length > 0;
// The route is reinitiated for each `path` param, so we only log results
// if we're prerendering for sw.js.
if (filePath === "sw.js" && (hasWarnings || count > 0)) {
logger[hasWarnings ? "warn" : "event"](
`${cyan(count)} precache entries ${dim(`(${(size / 1024).toFixed(2)} KiB)`)}${
hasWarnings ? `\n${yellow(["⚠ warnings", ...warnings.map((w) => ` ${w}`), ""].join("\n"))}` : ""
}`,
);
}
};
const validateGetManifestOptions = async (input: unknown): Promise<InjectManifestOptionsComplete> => {
const result = await injectManifestOptions.spa(input, {
error: validationErrorMap,
});
if (!result.success) {
throw new SerwistConfigError({
moduleName: "@serwist/turbopack",
message: z.prettifyError(result.error),
});
}
return result.data;
};
const isDev = process.env.NODE_ENV === "development";
const contentTypeMap: Record<string, string> = {
".js": "application/javascript",
".map": "application/json; charset=UTF-8",
};
/**
* Creates a Route Handler for Serwist files.
* @param options Options for {@linkcode getFileManifestEntries}.
*/
export const createSerwistRoute = (options: InjectManifestOptions) => {
const dynamic = "force-static" as const,
dynamicParams = false as const,
revalidate = false as const;
const validation = validateGetManifestOptions(options).then((config) => {
return {
...config,
disablePrecacheManifest: isDev,
additionalPrecacheEntries: isDev ? [] : config.additionalPrecacheEntries,
globIgnores: [
...config.globIgnores,
// Make sure we leave swSrc out of the precache manifest.
rebasePath({
file: config.swSrc,
baseDirectory: config.globDirectory,
}),
],
manifestTransforms: [
...(config.manifestTransforms ?? []),
(manifestEntries) => {
const manifest = manifestEntries.map((m) => {
// Replace all references to .next/ with "/_next/".
if (m.url.startsWith(".next/")) m.url = `/_next/${m.url.slice(6)}`;
// Replace all references to public/ with "$(basePath)/".
if (m.url.startsWith("public/")) m.url = path.posix.join(config.basePath, m.url.slice(7));
return m;
});
return { manifest, warnings: [] };
},
],
};
});
let map: Map<string, string> | null = null;
// NOTE: ALL FILES MUST HAVE DETERMINISTIC NAMES. THIS IS BECAUSE
// THE FOLLOWING MAP IS LOADED SEPARATELY FOR `generateStaticParams`
// AND EVERY `GET` REQUEST TO EACH OF THE FILES.
const loadMap = async (filePath: string) => {
const config = await validation;
const { count, size, manifestEntries, warnings } = await getFileManifestEntries(config);
// See https://github.com/GoogleChrome/workbox/issues/2230
const injectionPoint = config.injectionPoint ? config.injectionPoint : "";
const manifestString = manifestEntries === undefined ? "undefined" : JSON.stringify(manifestEntries, null, 2);
logSerwistResult(filePath, { count, size, warnings });
const result = await (await esbuild).build({
sourcemap: true,
format: "esm",
target: ["chrome64", "edge79", "firefox67", "opera51", "safari12"],
treeShaking: true,
minify: !isDev,
bundle: true,
...config.esbuildOptions,
platform: "browser",
define: {
...config.esbuildOptions.define,
...(injectionPoint ? { [injectionPoint]: manifestString } : {}),
},
outdir: config.cwd,
write: false,
entryNames: "[name]",
// Asset and chunk names must be at the top, as our path is `/serwist/[path]`,
// not `/serwist/[...path]`, meaning that we can't resolve paths deeper
// than one level.
assetNames: "[name]-[hash]",
chunkNames: "[name]-[hash]",
entryPoints: [{ in: config.swSrc, out: "sw" }],
});
if (result.errors.length) {
console.error("Failed to build the service worker.", result.errors);
throw new Error();
}
if (result.warnings.length) {
console.warn(result.warnings);
}
return new Map(result.outputFiles.map((e) => [e.path, e.text]));
};
const generateStaticParams = async () => {
const config = await validation;
if (!map) map = await loadMap("root");
return [...map.keys().map((e) => ({ path: path.relative(config.cwd, e) }))];
};
const GET = async (_: Request, { params }: { params: Promise<{ path: string }> }) => {
// TODO: obviously, files get stale in development when we pull this off.
const { path: filePath } = await params;
const config = await validation;
if (!map) map = await loadMap(filePath);
return new NextResponse(map.get(path.join(config.cwd, filePath)), {
headers: {
"Content-Type": contentTypeMap[path.extname(filePath)] || "text/plain",
"Service-Worker-Allowed": "/",
},
});
};
return { dynamic, dynamicParams, revalidate, generateStaticParams, GET };
};