diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8cb9cd71..5b42bb05c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,12 @@ Cloudflare's Templates Platform extracts `name`, `description`, and a `cloudflar - Can only be provided by a Cloudflare team member. Image files for icons and preview images are stored in the Cloudflare Templates CF account. - Preview icon should be an SVG representing the application. +### Worker source maps + +Enable `upload_source_maps` in every template's Wrangler configuration. When a framework builds the Worker, also enable external source maps in its server build. With the Cloudflare Vite plugin, set `build.sourcemap: true` for the Worker environment (`worker` or `ssr`), rather than the client environment. + +Run `pnpm check:source-maps` after building the templates to verify that each Worker emits usable source maps and that server maps are excluded from public assets. Browser source maps remain a separate choice. + ### `package-lock.json` All of our templates and Deploy to Cloudflare projects automatically set up Workers CI. In our testing, including a package lock file in the template repository speeds up module resolution by 80% or more. diff --git a/agent-commerce-analytics-template/vite.config.ts b/agent-commerce-analytics-template/vite.config.ts index f74e318a1..8dc6aa65d 100644 --- a/agent-commerce-analytics-template/vite.config.ts +++ b/agent-commerce-analytics-template/vite.config.ts @@ -3,5 +3,8 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ - plugins: [react(), cloudflare()], + environments: { + worker: { build: { sourcemap: true } }, + }, + plugins: [react(), cloudflare({ viteEnvironment: { name: "worker" } })], }); diff --git a/agent-visibility-template/vite.config.ts b/agent-visibility-template/vite.config.ts index 962a4193b..10d475208 100644 --- a/agent-visibility-template/vite.config.ts +++ b/agent-visibility-template/vite.config.ts @@ -3,8 +3,14 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ + environments: { + worker: { build: { sourcemap: true } }, + }, optimizeDeps: { exclude: ["hono", "hono/cors"], }, - plugins: [react(), cloudflare({ remoteBindings: false })], + plugins: [ + react(), + cloudflare({ viteEnvironment: { name: "worker" }, remoteBindings: false }), + ], }); diff --git a/ai-brand-visibility-template/vite.config.ts b/ai-brand-visibility-template/vite.config.ts index 0ba8618c3..b67d532ce 100644 --- a/ai-brand-visibility-template/vite.config.ts +++ b/ai-brand-visibility-template/vite.config.ts @@ -5,6 +5,9 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + environments: { + ssr: { build: { sourcemap: true } }, + }, plugins: [ cloudflare({ viteEnvironment: { name: "ssr" }, remoteBindings: false }), tailwindcss(), diff --git a/astro-blog-starter-template/astro.config.mjs b/astro-blog-starter-template/astro.config.mjs index 668a56f25..8bca7b45d 100644 --- a/astro-blog-starter-template/astro.config.mjs +++ b/astro-blog-starter-template/astro.config.mjs @@ -8,7 +8,21 @@ import cloudflare from "@astrojs/cloudflare"; // https://astro.build/config export default defineConfig({ site: "https://example.com", - integrations: [mdx(), sitemap()], + integrations: [ + { + name: "worker-source-maps", + hooks: { + "astro:build:setup": ({ target, vite }) => { + if (target === "server") { + vite.build ??= {}; + vite.build.sourcemap = true; + } + }, + }, + }, + mdx(), + sitemap(), + ], adapter: cloudflare({ platformProxy: { enabled: true, diff --git a/cli/src/index.ts b/cli/src/index.ts index af8ac16d5..a7825d99f 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,6 +13,7 @@ import { depsInfo } from "./depsInfo"; import { depsUpdate } from "./depsUpdate"; import deployLiveDemos from "./deployLiveDemos"; import { preview } from "./preview"; +import { checkSourceMaps } from "./sourceMaps"; const program = new Command(); @@ -64,6 +65,20 @@ program ); }); +program + .command("check-source-maps") + .description("validate external Worker maps after framework builds") + .argument( + "[path-to-template(s)]", + "path to directory containing template(s)", + ".", + ) + .action((templateDirectory) => + actionWithSummary("Worker source maps", () => + checkSourceMaps(templateDirectory), + ), + ); + program .command("lint") .description("find and fix template style problems") diff --git a/cli/src/sourceMaps.ts b/cli/src/sourceMaps.ts new file mode 100644 index 000000000..f8d1fe308 --- /dev/null +++ b/cli/src/sourceMaps.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import path from "node:path"; +import MarkdownError from "./MarkdownError"; +import { getAllTemplates, readJsonC } from "./util"; + +type WorkerBuildConfig = { + main?: string; + upload_source_maps?: boolean; + assets?: { directory?: string }; +}; + +/** Check framework output after building. Wrangler generates maps for unbundled starters. */ +export function checkSourceMaps(templateDirectory: string): void { + const problems: string[] = []; + for (const template of getAllTemplates(templateDirectory)) { + const isFramework = fs + .readdirSync(template.path) + .some((file) => /^(vite|astro)\.config\.[cm]?[jt]s$/.test(file)); + if (!isFramework) continue; + try { + checkFrameworkSourceMaps(template.path); + } catch (error) { + problems.push(`${template.name}: ${(error as Error).message}`); + } + } + if (problems.length) { + throw new MarkdownError( + "Worker source map checks failed", + problems.map((problem) => `- ${problem}`).join("\n"), + ); + } +} + +export function checkFrameworkSourceMaps(templatePath: string): void { + const redirectPath = path.join(templatePath, ".wrangler/deploy/config.json"); + const rootConfigPath = ["wrangler.jsonc", "wrangler.json"] + .map((file) => path.join(templatePath, file)) + .find(fs.existsSync); + if (!rootConfigPath) throw new Error("Missing Wrangler configuration"); + const configPath = fs.existsSync(redirectPath) + ? path.resolve( + path.dirname(redirectPath), + (readJsonC(redirectPath) as { configPath: string }).configPath, + ) + : rootConfigPath; + if (!fs.existsSync(configPath)) + throw new Error( + "Missing generated Wrangler configuration; build the template first", + ); + const config = readJsonC(configPath) as WorkerBuildConfig; + if (config.upload_source_maps !== true) + throw new Error("Built Worker must enable upload_source_maps"); + const astroWorkerDir = path.join(templatePath, "dist/_worker.js"); + const workerDir = fs.existsSync(astroWorkerDir) + ? astroWorkerDir + : configPath !== rootConfigPath + ? path.dirname(configPath) + : path.join(templatePath, "build/server"); + if (!fs.existsSync(workerDir)) + throw new Error("Missing Worker build output; build the template first"); + const assetsDir = + config.assets?.directory && + path.resolve(path.dirname(configPath), config.assets.directory); + let mapCount = 0; + for (const file of fs.readdirSync(workerDir, { recursive: true })) { + if (!/\.[cm]?js$/.test(file.toString())) continue; + const jsPath = path.join(workerDir, file.toString()); + const code = fs.readFileSync(jsPath, "utf8"); + const reference = /\/\/# sourceMappingURL=(\S+)/.exec(code)?.[1]; + if (!reference) continue; + if (reference.startsWith("data:")) + throw new Error(`${file} uses inline maps; emit external maps`); + const mapPath = path.resolve(path.dirname(jsPath), reference); + if (!fs.existsSync(mapPath)) + throw new Error(`${file} references a missing source map`); + const map = JSON.parse(fs.readFileSync(mapPath, "utf8")); + if ( + map.version !== 3 || + !Array.isArray(map.sources) || + typeof map.mappings !== "string" + ) + throw new Error(`${file} has an invalid source map`); + if (assetsDir && isInside(assetsDir, mapPath)) { + const ignorePath = path.join(assetsDir, ".assetsignore"); + const ignored = + fs.existsSync(ignorePath) && + fs + .readFileSync(ignorePath, "utf8") + .split(/\r?\n/) + .some((pattern) => { + // Worker directories are excluded as a whole, including their maps. + const directory = pattern + .trim() + .replace(/^\//, "") + .replace(/\/\*\*$/, ""); + return ( + directory && + !directory.includes("*") && + isInside(path.join(assetsDir, directory), mapPath) + ); + }); + if (!ignored) + throw new Error( + `${file}'s server map is inside public assets; exclude its Worker directory in .assetsignore`, + ); + } + // Generated re-export entry files can have empty maps; require mapped source in the chunks. + if (map.sources.length && map.mappings.length) mapCount++; + } + if (!mapCount) + throw new Error( + "No external Worker source maps found; enable maps in the server build", + ); +} + +function isInside(directory: string, file: string): boolean { + const relative = path.relative(directory, file); + return ( + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} diff --git a/cli/test/sourceMaps.test.ts b/cli/test/sourceMaps.test.ts new file mode 100644 index 000000000..4f2f70789 --- /dev/null +++ b/cli/test/sourceMaps.test.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, expect, test } from "vitest"; +import { checkFrameworkSourceMaps } from "../src/sourceMaps"; + +let root: string; +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "template-source-maps-")); + fs.mkdirSync(path.join(root, ".wrangler/deploy"), { recursive: true }); + fs.mkdirSync(path.join(root, "dist/worker"), { recursive: true }); + fs.writeFileSync( + path.join(root, "wrangler.jsonc"), + '{"main":"src/index.ts","upload_source_maps":true}', + ); + fs.writeFileSync( + path.join(root, ".wrangler/deploy/config.json"), + '{"configPath":"../../dist/worker/wrangler.json"}', + ); + fs.writeFileSync( + path.join(root, "dist/worker/wrangler.json"), + '{"main":"index.js","upload_source_maps":true,"assets":{"directory":"../client"}}', + ); + fs.writeFileSync( + path.join(root, "dist/worker/index.js"), + 'throw new Error("original source");\n//# sourceMappingURL=index.js.map', + ); + fs.writeFileSync( + path.join(root, "dist/worker/index.js.map"), + JSON.stringify({ + version: 3, + sources: ["../../src/index.ts"], + mappings: "AAAA", + }), + ); +}); +afterEach(() => fs.rmSync(root, { recursive: true, force: true })); + +test("validates the built config and external Worker maps", () => { + expect(() => checkFrameworkSourceMaps(root)).not.toThrow(); +}); +test("rejects uploads disabled in the generated config", () => { + fs.writeFileSync( + path.join(root, "dist/worker/wrangler.json"), + '{"upload_source_maps":false}', + ); + expect(() => checkFrameworkSourceMaps(root)).toThrow( + "Built Worker must enable upload_source_maps", + ); +}); +test("rejects missing maps despite the upload flag", () => { + fs.rmSync(path.join(root, "dist/worker/index.js.map")); + expect(() => checkFrameworkSourceMaps(root)).toThrow( + "references a missing source map", + ); +}); +test("rejects builds with no map references", () => { + fs.writeFileSync( + path.join(root, "dist/worker/index.js"), + "export default {};", + ); + expect(() => checkFrameworkSourceMaps(root)).toThrow( + "No external Worker source maps found", + ); +}); +test("rejects builds containing only empty maps", () => { + fs.writeFileSync( + path.join(root, "dist/worker/index.js.map"), + '{"version":3,"sources":[],"mappings":""}', + ); + expect(() => checkFrameworkSourceMaps(root)).toThrow( + "No external Worker source maps found", + ); +}); +test("requires server maps to be excluded from public assets", () => { + fs.writeFileSync( + path.join(root, "dist/worker/wrangler.json"), + '{"upload_source_maps":true,"assets":{"directory":".."}}', + ); + expect(() => checkFrameworkSourceMaps(root)).toThrow( + "server map is inside public assets", + ); + fs.writeFileSync(path.join(root, "dist/.assetsignore"), "worker\n"); + expect(() => checkFrameworkSourceMaps(root)).not.toThrow(); +}); diff --git a/commerce-llms-txt-template/vite.config.ts b/commerce-llms-txt-template/vite.config.ts index b40175912..79d41f5cf 100644 --- a/commerce-llms-txt-template/vite.config.ts +++ b/commerce-llms-txt-template/vite.config.ts @@ -3,5 +3,11 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ - plugins: [react(), cloudflare({ remoteBindings: false })], + environments: { + worker: { build: { sourcemap: true } }, + }, + plugins: [ + react(), + cloudflare({ viteEnvironment: { name: "worker" }, remoteBindings: false }), + ], }); diff --git a/next-starter-template/README.md b/next-starter-template/README.md index 2b98066b5..cb52e789a 100644 --- a/next-starter-template/README.md +++ b/next-starter-template/README.md @@ -61,3 +61,7 @@ To learn more about Next.js, take a look at the following resources: - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Worker source maps + +This template enables `upload_source_maps` in Wrangler. OpenNext currently discards source maps when bundling its server, so production stacks can identify bundled server code but cannot reliably map back to the original Next.js source. Follow [OpenNext source map support](https://github.com/opennextjs/opennextjs-cloudflare/issues/1124) for adapter support. The repository's framework source map check covers Vite and Astro builds; OpenNext remains excluded until it preserves maps through its bundling and patching steps. diff --git a/package.json b/package.json index 64a0bdd41..3741fbcc9 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ ] }, "scripts": { - "check": "pnpm run check:deps && pnpm run check:templates && pnpm run check:lockfiles && pnpm run check:turbo && pnpm run check:prettier && git diff --exit-code", + "check": "pnpm run check:deps && pnpm run check:templates && pnpm run check:lockfiles && pnpm run check:turbo && pnpm run check:source-maps && pnpm run check:prettier && git diff --exit-code", "check:deps": "syncpack lint", "check:lockfiles": "templates lint-npm-lockfiles .", "check:prettier": "prettier . --check", @@ -51,6 +51,7 @@ "upload": "templates upload", "validate-d2c-buttons": "templates validate-d2c-buttons", "validate-live-demo-links": "templates validate-live-demo-links", - "validate-version-private-package-json": "templates validate-version-private-package-json" + "validate-version-private-package-json": "templates validate-version-private-package-json", + "check:source-maps": "templates check-source-maps ." } } diff --git a/react-postgres-fullstack-template/vite.config.js b/react-postgres-fullstack-template/vite.config.js index f74e318a1..8dc6aa65d 100644 --- a/react-postgres-fullstack-template/vite.config.js +++ b/react-postgres-fullstack-template/vite.config.js @@ -3,5 +3,8 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ - plugins: [react(), cloudflare()], + environments: { + worker: { build: { sourcemap: true } }, + }, + plugins: [react(), cloudflare({ viteEnvironment: { name: "worker" } })], }); diff --git a/react-router-hono-fullstack-template/vite.config.ts b/react-router-hono-fullstack-template/vite.config.ts index e96fbba4c..e29eb1f48 100644 --- a/react-router-hono-fullstack-template/vite.config.ts +++ b/react-router-hono-fullstack-template/vite.config.ts @@ -5,6 +5,9 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + environments: { + ssr: { build: { sourcemap: true } }, + }, plugins: [ cloudflare({ viteEnvironment: { name: "ssr" } }), tailwindcss(), diff --git a/react-router-postgres-ssr-template/vite.config.js b/react-router-postgres-ssr-template/vite.config.js index baa4c93c4..65c6d013e 100644 --- a/react-router-postgres-ssr-template/vite.config.js +++ b/react-router-postgres-ssr-template/vite.config.js @@ -3,5 +3,8 @@ import { cloudflare } from "@cloudflare/vite-plugin"; import { defineConfig } from "vite"; export default defineConfig({ + environments: { + ssr: { build: { sourcemap: true } }, + }, plugins: [cloudflare({ viteEnvironment: { name: "ssr" } }), reactRouter()], }); diff --git a/react-router-starter-template/vite.config.ts b/react-router-starter-template/vite.config.ts index e96fbba4c..e29eb1f48 100644 --- a/react-router-starter-template/vite.config.ts +++ b/react-router-starter-template/vite.config.ts @@ -5,6 +5,9 @@ import { defineConfig } from "vite"; import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ + environments: { + ssr: { build: { sourcemap: true } }, + }, plugins: [ cloudflare({ viteEnvironment: { name: "ssr" } }), tailwindcss(), diff --git a/react-starter-template/vite.config.ts b/react-starter-template/vite.config.ts index f74e318a1..8dc6aa65d 100644 --- a/react-starter-template/vite.config.ts +++ b/react-starter-template/vite.config.ts @@ -3,5 +3,8 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ - plugins: [react(), cloudflare()], + environments: { + worker: { build: { sourcemap: true } }, + }, + plugins: [react(), cloudflare({ viteEnvironment: { name: "worker" } })], }); diff --git a/remix-starter-template/vite.config.ts b/remix-starter-template/vite.config.ts index 5781cef72..e1cc7fecc 100644 --- a/remix-starter-template/vite.config.ts +++ b/remix-starter-template/vite.config.ts @@ -12,7 +12,7 @@ declare module "@remix-run/cloudflare" { } } -export default defineConfig({ +export default defineConfig(({ isSsrBuild }) => ({ plugins: [ cloudflareDevProxyVitePlugin({ getLoadContext, @@ -37,6 +37,7 @@ export default defineConfig({ mainFields: ["browser", "module", "main"], }, build: { + sourcemap: isSsrBuild, minify: true, }, -}); +})); diff --git a/saas-admin-template/astro.config.mjs b/saas-admin-template/astro.config.mjs index 81aad68e6..9864a1f73 100644 --- a/saas-admin-template/astro.config.mjs +++ b/saas-admin-template/astro.config.mjs @@ -19,7 +19,21 @@ export default defineConfig({ }, }, }), - integrations: [react(), tailwind({ applyBaseStyles: true })], + integrations: [ + { + name: "worker-source-maps", + hooks: { + "astro:build:setup": ({ target, vite }) => { + if (target === "server") { + vite.build ??= {}; + vite.build.sourcemap = true; + } + }, + }, + }, + react(), + tailwind({ applyBaseStyles: true }), + ], output: "server", vite: { resolve: { diff --git a/saas-admin-template/public/.assetsignore b/saas-admin-template/public/.assetsignore index e69de29bb..b8d40b555 100644 --- a/saas-admin-template/public/.assetsignore +++ b/saas-admin-template/public/.assetsignore @@ -0,0 +1,5 @@ +_worker.js +index.js +_routes.json +_headers +_redirects diff --git a/to-do-list-kv-template/vite.config.ts b/to-do-list-kv-template/vite.config.ts index 4c4e396af..9dd55ebe4 100644 --- a/to-do-list-kv-template/vite.config.ts +++ b/to-do-list-kv-template/vite.config.ts @@ -12,7 +12,7 @@ declare module "@remix-run/cloudflare" { } } -export default defineConfig({ +export default defineConfig(({ isSsrBuild }) => ({ plugins: [ cloudflareDevProxyVitePlugin({ getLoadContext, @@ -38,6 +38,7 @@ export default defineConfig({ mainFields: ["browser", "module", "main"], }, build: { + sourcemap: isSsrBuild, minify: true, }, -}); +})); diff --git a/vite-react-template/vite.config.ts b/vite-react-template/vite.config.ts index f74e318a1..8dc6aa65d 100644 --- a/vite-react-template/vite.config.ts +++ b/vite-react-template/vite.config.ts @@ -3,5 +3,8 @@ import react from "@vitejs/plugin-react"; import { cloudflare } from "@cloudflare/vite-plugin"; export default defineConfig({ - plugins: [react(), cloudflare()], + environments: { + worker: { build: { sourcemap: true } }, + }, + plugins: [react(), cloudflare({ viteEnvironment: { name: "worker" } })], }); diff --git a/workflows-starter-template/vite.config.ts b/workflows-starter-template/vite.config.ts index 92d836a64..cdde9e23c 100644 --- a/workflows-starter-template/vite.config.ts +++ b/workflows-starter-template/vite.config.ts @@ -6,5 +6,12 @@ import { cloudflare } from "@cloudflare/vite-plugin"; // https://vite.dev/config/ export default defineConfig({ - plugins: [react(), tailwindcss(), cloudflare()], + environments: { + worker: { build: { sourcemap: true } }, + }, + plugins: [ + react(), + tailwindcss(), + cloudflare({ viteEnvironment: { name: "worker" } }), + ], });