diff --git a/.claude/skills/verify-changes/SKILL.md b/.claude/skills/verify-changes/SKILL.md index 1c6d601..ab8fc16 100644 --- a/.claude/skills/verify-changes/SKILL.md +++ b/.claude/skills/verify-changes/SKILL.md @@ -29,13 +29,15 @@ Changes to declared input projection or standalone packaging also run: ```bash go test -run 'TestDeclared|TestBuildRecipe|TestRecipeBuild' -count=1 . -CODEFLY_TEST_NEXT_BUILD=1 go test -run '^TestNext(ConfiguredStandaloneOutput|ExportRejectsStaleOutput)$' -count=1 -timeout=10m -v . +CODEFLY_TEST_NEXT_BUILD=1 go test -run '^TestNext(ConfiguredStandaloneOutput|ExportRejectsStaleOutput|TurbopackStandaloneRelocation)$' -count=1 -timeout=10m -v . ``` The latter installs exact Next/React fixture dependencies in temporary directories and runs real Next production builds for default and custom/nested output, plus -default/custom export freshness refusal tests using the real config loader. It -requires Node/npm and package-registry access. The optional +default/custom export freshness refusal tests using the real config loader. The +Turbopack/Sentry fixture requests a production page after withholding its source +tree, exercising lazy route evaluation and traced package relocation. These +checks require Node/npm and package-registry access. The optional `CODEFLY_NEXT_CONSUMER_SOURCE` fixture checks an explicitly selected consumer snapshot without writing into it. Neither establishes image execution: the maintained CLI must build the actual recipes, and image health/page/static asset diff --git a/next_output_test.go b/next_output_test.go index 4f3c7c1..c60c419 100644 --- a/next_output_test.go +++ b/next_output_test.go @@ -1,17 +1,107 @@ package main import ( + "context" "encoding/json" + "io" "io/fs" + "net" + "net/http" "os" "os/exec" "path/filepath" "strings" "testing" + "time" "github.com/stretchr/testify/require" ) +// Turbopack emits hashed package aliases for Sentry's server dependencies. +// Execute the route over HTTP: importing its lazy page module is not sufficient. +func TestNextTurbopackStandaloneRelocation(t *testing.T) { + if os.Getenv("CODEFLY_TEST_NEXT_BUILD") != "1" { + t.Skip("set CODEFLY_TEST_NEXT_BUILD=1 for real Turbopack/Sentry production HTTP") + } + root := physicalPath(t.TempDir()) + app := filepath.Join(root, "source") + runtime := filepath.Join(root, "runtime") + buildInputWrite(t, app, "package.json", `{"name":"relocation-fixture","private":true,"scripts":{"build":"next build"},"dependencies":{"next":"16.4.1","react":"19.2.4","react-dom":"19.2.4","@sentry/nextjs":"10.75.2"}}`) + buildInputWrite(t, app, "next.config.mjs", `import {withSentryConfig} from '@sentry/nextjs'; export default withSentryConfig({output:'standalone',experimental:{cpus:1}}, {silent:true,telemetry:false,routeManifestInjection:false});`) + buildInputWrite(t, app, "app/layout.js", `export default function Layout({children}) { return
{children}; }`) + buildInputWrite(t, app, "app/auth/callback/page.js", `import * as Sentry from '@sentry/nextjs'; export const dynamic='force-dynamic'; export default function Page() { Sentry.getClient(); returnrelocated-sentry-page
; }`) + buildInputWrite(t, app, "instrumentation.js", `import * as Sentry from '@sentry/nextjs'; export async function register() {} export const onRequestError=Sentry.captureRequestError;`) + helper, err := fs.ReadFile(builderFS, "templates/builder/context/next-build.cjs.tmpl") + require.NoError(t, err) + buildInputWrite(t, root, "next-build.cjs", string(helper)) + run := func(args ...string) string { + cmd := exec.CommandContext(t.Context(), args[0], args[1:]...) + cmd.Dir = app + cmd.Env = append(os.Environ(), "NODE_ENV=production", "NEXT_TELEMETRY_DISABLED=1", "npm_config_cache="+filepath.Join(root, "npm-cache")) + out, err := cmd.CombinedOutput() + require.NoError(t, err, "%s", out) + return string(out) + } + t.Logf("build/runtime Node: %s", strings.TrimSpace(run("node", "--version"))) + run("npm", "install", "--include=dev", "--no-audit", "--no-fund") + t.Log(run("node", filepath.Join(root, "next-build.cjs"), app, runtime, "standalone")) + aliases, err := filepath.Glob(filepath.Join(app, ".next/standalone/.next/node_modules/require-in-the-middle-*")) + require.NoError(t, err) + require.NotEmpty(t, aliases, "real Turbopack build must exercise the traced package alias") + for _, alias := range aliases { + link, err := os.Readlink(alias) + require.NoError(t, err) + require.False(t, filepath.IsAbs(link), "framework alias should be relocatable: %s", link) + before, err := os.ReadFile(filepath.Join(alias, "index.js")) + require.NoError(t, err) + after, err := os.ReadFile(filepath.Join(runtime, ".next/node_modules", filepath.Base(alias), "index.js")) + require.NoError(t, err) + require.Equal(t, before, after, "relocation must preserve the package bytes") + t.Logf("generated alias %s -> %s", filepath.Base(alias), link) + } + // Remove the original path from resolution, without deleting its evidence. + require.NoError(t, os.Rename(app, filepath.Join(root, "source-withheld"))) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + address := listener.Addr().String() + _, port, err := net.SplitHostPort(address) + require.NoError(t, err) + require.NoError(t, listener.Close()) + ctx, cancel := context.WithCancel(t.Context()) + server := exec.CommandContext(ctx, "node", "server.js") + server.Dir = runtime + server.Env = append(os.Environ(), "NODE_ENV=production", "NEXT_TELEMETRY_DISABLED=1", "NODE_PATH=", "HOSTNAME=127.0.0.1", "PORT="+port) + logPath := filepath.Join(root, "production-server.log") + logFile, err := os.Create(logPath) + require.NoError(t, err) + server.Stdout, server.Stderr = logFile, logFile + require.NoError(t, server.Start()) + t.Cleanup(func() { + cancel() + _ = server.Wait() + _ = logFile.Close() + log, _ := os.ReadFile(logPath) + t.Logf("production server: %s", log) + }) + client := &http.Client{Timeout: 10 * time.Second} + var response *http.Response + require.Eventually(t, func() bool { + response, err = client.Get("http://" + address + "/auth/callback") + return err == nil + }, time.Minute, 100*time.Millisecond, "standalone server must answer without its source tree") + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode, "%s", body) + require.Contains(t, string(body), "relocated-sentry-page") + for _, alias := range aliases { + info, err := os.Lstat(filepath.Join(runtime, ".next/node_modules", filepath.Base(alias))) + require.NoError(t, err) + require.True(t, info.IsDir(), "traced package must be materialized, not linked to the build stage") + } + t.Log("production GET /auth/callback: 200 with source withheld") +} + // This opt-in test installs the real framework and runs its actual production // compiler. Recipe unit tests alone cannot establish the standalone layout. func TestNextConfiguredStandaloneOutput(t *testing.T) { diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index 64a2bc8..29c9803 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -81,6 +81,8 @@ root, or a custom directory inside the service). It must declare Next Custom production `distDir` and `outputFileTracingRoot` are resolved using the installed Next configuration and verified against fresh build metadata. Nested standalone servers retain their relative layout, public files and static assets. +Relocation materializes traced package aliases so the runtime does not depend on +symlinks back into the build stage's source tree. There is no hosted `npm start` fallback. Development and production output may remain separate. diff --git a/templates/builder/context/next-build.cjs.tmpl b/templates/builder/context/next-build.cjs.tmpl index eeb3e87..81069b9 100644 --- a/templates/builder/context/next-build.cjs.tmpl +++ b/templates/builder/context/next-build.cjs.tmpl @@ -39,7 +39,7 @@ async function build() { if (result.status !== 0) throw Error(`npm build failed: ${result.status}`); if (fs.existsSync(runtime)) throw Error('runtime destination already exists'); if (mode === 'export') { - fs.cpSync(exported, runtime, { recursive: true, dereference: true }); + await fs.promises.cp(exported, runtime, { recursive: true, dereference: true }); return; } const metadata = JSON.parse(fs.readFileSync(path.join(dist, 'required-server-files.json'), 'utf8')); @@ -50,11 +50,11 @@ async function build() { const standalone = path.join(dist, 'standalone'); if (!fs.statSync(path.join(standalone, relativeApp, 'server.js')).isFile()) throw Error('standalone server missing'); const target = path.join(runtime, path.relative(source, tracing)); - fs.cpSync(standalone, target, { recursive: true, dereference: true }); + await fs.promises.cp(standalone, target, { recursive: true, dereference: true }); const serverRoot = path.join(runtime, path.relative(source, app)); for (const name of ['public', path.join(config.distDir, 'static')]) { const from = path.join(app, name); - if (fs.existsSync(from)) fs.cpSync(from, path.join(serverRoot, name), { recursive: true, dereference: true }); + if (fs.existsSync(from)) await fs.promises.cp(from, path.join(serverRoot, name), { recursive: true, dereference: true }); } } build().catch(error => { console.error(error); process.exitCode = 1; });