diff --git a/go.mod b/go.mod index 7f90bdbd..0a3b3ef0 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/kr/text v0.2.0 // indirect + github.com/otiai10/copy v1.12.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/spf13/pflag v1.0.5 // indirect diff --git a/go.sum b/go.sum index 51ca8b7f..cd0ca68f 100644 --- a/go.sum +++ b/go.sum @@ -158,6 +158,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/moznion/go-optional v0.10.0 h1:YE42pzLDp6vc9zi/2hyaHYJesjahZEgFXEN1u5DMwMA= github.com/moznion/go-optional v0.10.0/go.mod h1:l3mLmsyp2bWTvWKjEm5MT7lo3g5MRlNIflxFB0XTASA= +github.com/otiai10/copy v1.12.0 h1:cLMgSQnXBs1eehF0Wy/FAGsgDTDmAqFR7rQylBb1nDY= +github.com/otiai10/copy v1.12.0/go.mod h1:rSaLseMUsZFFbsFGc7wCJnnkTAvdc5L6VWxPE4308Ww= github.com/pan93412/envexpander v1.1.0 h1:cf57P8BllM2CWfHO0eEKkpIoz0e44J94+H2Mh+hcUtw= github.com/pan93412/envexpander v1.1.0/go.mod h1:sBLOiYNNmCNyx+6z6L3Cegcs1MG0l7KXu8rhe41ZsM0= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= diff --git a/internal/nodejs/__snapshots__/template_test.snap b/internal/nodejs/__snapshots__/template_test.snap index 8b8688fc..92058965 100755 --- a/internal/nodejs/__snapshots__/template_test.snap +++ b/internal/nodejs/__snapshots__/template_test.snap @@ -13,11 +13,9 @@ RUN yarn install # Build if we can build it - EXPOSE 8080 CMD yarn start - --- [TestTemplate_NBuildCmd_OutputDir_NSPA - 1] @@ -34,21 +32,8 @@ RUN yarn install # Build if we can build it - -FROM nginx:alpine as runtime - -COPY --from=build /src/dist /usr/share/nginx/html/static -RUN echo "\ - server { \ - listen 8080; \ - root /usr/share/nginx/html/static; \ - absolute_redirect off; \ - location / { \ - try_files \$uri \$uri.html \$uri/index.html /404.html =404; \ - } \ - }"> /etc/nginx/conf.d/default.conf EXPOSE 8080 - +CMD yarn start --- @@ -66,21 +51,8 @@ RUN yarn install # Build if we can build it - -FROM nginx:alpine as runtime - -COPY --from=build /src/dist /usr/share/nginx/html/static -RUN echo "\ - server { \ - listen 8080; \ - root /usr/share/nginx/html/static; \ - absolute_redirect off; \ - location / { \ - try_files \$uri /index.html; \ - } \ - }"> /etc/nginx/conf.d/default.conf EXPOSE 8080 - +CMD yarn start --- @@ -98,11 +70,9 @@ RUN yarn install # Build if we can build it RUN yarn build - EXPOSE 8080 CMD yarn start - --- [TestTemplate_BuildCmd_OutputDir - 1] @@ -119,21 +89,8 @@ RUN yarn install # Build if we can build it RUN yarn build - -FROM nginx:alpine as runtime - -COPY --from=build /src/dist /usr/share/nginx/html/static -RUN echo "\ - server { \ - listen 8080; \ - root /usr/share/nginx/html/static; \ - absolute_redirect off; \ - location / { \ - try_files \$uri /index.html; \ - } \ - }"> /etc/nginx/conf.d/default.conf EXPOSE 8080 - +CMD yarn start --- @@ -157,9 +114,7 @@ RUN bun install # Build if we can build it - EXPOSE 8080 CMD bun start main.ts - --- diff --git a/internal/nodejs/nextjs/function.go b/internal/nodejs/nextjs/function.go new file mode 100644 index 00000000..7f7ecc15 --- /dev/null +++ b/internal/nodejs/nextjs/function.go @@ -0,0 +1,86 @@ +package nextjs + +import ( + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + cp "github.com/otiai10/copy" +) + +// constructNextFunction will construct the first function page, used as symlinks for other function pages +func constructNextFunction(zeaburOutputDir, firstFuncPage string) error { + p := path.Join(zeaburOutputDir, "functions", firstFuncPage+".func") + + err := os.MkdirAll(p, 0755) + if err != nil { + return fmt.Errorf("create function dir: %w", err) + } + + launcher, err := renderLauncher() + if err != nil { + return fmt.Errorf("render launcher: %w", err) + } + + err = os.WriteFile(path.Join(p, "index.js"), []byte(launcher), 0644) + if err != nil { + return fmt.Errorf("write launcher: %w", err) + } + + err = cp.Copy(".next", path.Join(p, ".next")) + if err != nil { + return fmt.Errorf("copy .next: %w", err) + } + + err = cp.Copy("package.json", path.Join(p, "package.json")) + if err != nil { + return fmt.Errorf("copy package.json: %w", err) + } + + outputNodeModulesDir := path.Join(p, "node_modules") + err = os.MkdirAll(outputNodeModulesDir, 0755) + if err != nil { + return fmt.Errorf("create node_modules dir: %w", err) + } + + var deps []string + err = filepath.Walk(".next", func(path string, info os.FileInfo, err error) error { + if strings.HasSuffix(path, ".nft.json") { + type nftJSON struct { + Files []string `json:"files"` + } + b, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read nft.json: %w", err) + } + var nft nftJSON + err = json.Unmarshal(b, &nft) + if err != nil { + return fmt.Errorf("unmarshal nft.json: %w", err) + } + for _, file := range nft.Files { + if !strings.Contains(file, "node_modules") { + continue + } + file = file[strings.Index(file, "node_modules"):] + deps = append(deps, file) + } + } + return nil + }) + if err != nil { + return fmt.Errorf("walk .next: %w", err) + } + + for _, dep := range deps { + err = cp.Copy(dep, path.Join(p, dep)) + if err != nil { + return fmt.Errorf("copy dep: %w", err) + } + } + + return nil +} diff --git a/internal/nodejs/nextjs/launcher.go b/internal/nodejs/nextjs/launcher.go new file mode 100644 index 00000000..5b39b8f4 --- /dev/null +++ b/internal/nodejs/nextjs/launcher.go @@ -0,0 +1,68 @@ +package nextjs + +import ( + _ "embed" + "encoding/json" + "fmt" + "os" + "strings" + "text/template" +) + +//go:embed launcher.js.tmpl +var launcherTemplate string + +// getNextConfig read .next/required-server-files.json and return the config string that will be injected into launcher +func getNextConfig() (string, error) { + rsf, err := os.ReadFile(".next/required-server-files.json") + if err != nil { + return "", fmt.Errorf("read required-server-files.json: %w", err) + } + + type requiredServerFiles struct { + Config json.RawMessage `json:"config"` + } + + var rs requiredServerFiles + err = json.Unmarshal(rsf, &rs) + if err != nil { + return "", fmt.Errorf("unmarshal required-server-files.json: %w", err) + } + + var data map[string]interface{} + if err := json.Unmarshal(rs.Config, &data); err != nil { + return "", fmt.Errorf("unmarshal config: %w", err) + } + + nextConfig, err := json.Marshal(data) + if err != nil { + return "", fmt.Errorf("marshal config: %w", err) + } + + return string(nextConfig), nil +} + +// renderLauncher will render the launcher.js template which used as the entrypoint of the serverless function +func renderLauncher() (string, error) { + nextConfig, err := getNextConfig() + if err != nil { + return "", fmt.Errorf("get next config: %w", err) + } + + tmpl, err := template.New("launcher").Parse(launcherTemplate) + if err != nil { + return "", fmt.Errorf("parse launcher template: %w", err) + } + + type renderLauncherTemplateContext struct { + NextConfig string + } + + var launcher strings.Builder + err = tmpl.Execute(&launcher, renderLauncherTemplateContext{NextConfig: nextConfig}) + if err != nil { + return "", fmt.Errorf("render launcher template: %w", err) + } + + return launcher.String(), nil +} diff --git a/internal/nodejs/nextjs/launcher.js.tmpl b/internal/nodejs/nextjs/launcher.js.tmpl new file mode 100644 index 00000000..ff710f4e --- /dev/null +++ b/internal/nodejs/nextjs/launcher.js.tmpl @@ -0,0 +1,24 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +process.chdir(__dirname); +process.env.NODE_ENV = 'production'; +process.env.__NEXT_PRIVATE_PREBUNDLED_REACT = "next" +const NextServer = require('next/dist/server/next-server.js').default; +const nextServer = new NextServer({ + conf: {{ .NextConfig }}, + dir: '.', + minimalMode: true, + customServer: false, +}); +const requestHandler = nextServer.getRequestHandler(); +module.exports = async (req, res) => { + const { NodeNextRequest, NodeNextResponse} = require('next/dist/server/base-http/node'); + req = new NodeNextRequest(req) + res = new NodeNextResponse(res) + try { + await requestHandler(req, res); + } catch (err) { + console.error(err); + process.exit(1); + } +}; diff --git a/internal/nodejs/nextjs/main.go b/internal/nodejs/nextjs/main.go new file mode 100644 index 00000000..9e7091b6 --- /dev/null +++ b/internal/nodejs/nextjs/main.go @@ -0,0 +1,143 @@ +// Package nextjs is used to transform build output of Next.js app to the serverless build output format of Zeabur +package nextjs + +import ( + "encoding/json" + "fmt" + "os" + "path" + "path/filepath" + "strings" + "text/template" + + cp "github.com/otiai10/copy" + "github.com/zeabur/zbpack/internal/utils" + "github.com/zeabur/zbpack/pkg/types" +) + +// TransformServerless will transform build output of Next.js app to the serverless build output format of Zeabur +// It is trying to implement the same logic as build function of https://github.com/vercel/vercel/tree/main/packages/next/src/index.ts +func TransformServerless(image, workdir string) error { + nextOutputDir := path.Join(workdir, ".next") + nextOutputServerPagesDir := path.Join(nextOutputDir, "server/pages") + zeaburOutputDir := path.Join(workdir, ".zeabur/output") + + _ = os.RemoveAll(nextOutputDir) + + err := utils.CopyFromImage(image, "/src/.next/.", nextOutputDir) + if err != nil { + return err + } + + _ = os.RemoveAll(path.Join(workdir, ".zeabur")) + + var serverlessFunctionPages []string + internalPages := []string{"_app.js", "_document.js", "_error.js"} + _ = filepath.Walk(nextOutputServerPagesDir, func(path string, info os.FileInfo, err error) error { + if strings.HasSuffix(path, ".js") { + for _, internalPage := range internalPages { + if strings.HasSuffix(path, internalPage) { + return nil + } + } + funcPath := strings.TrimPrefix(path, nextOutputServerPagesDir) + funcPath = strings.TrimSuffix(funcPath, ".js") + serverlessFunctionPages = append(serverlessFunctionPages, funcPath) + } + return nil + }) + + var staticPages []string + _ = filepath.Walk(nextOutputServerPagesDir, func(path string, info os.FileInfo, err error) error { + if strings.HasSuffix(path, ".html") { + filePath := strings.TrimPrefix(path, nextOutputServerPagesDir) + staticPages = append(staticPages, filePath) + } + return nil + }) + + err = os.MkdirAll(path.Join(zeaburOutputDir, "static"), 0755) + if err != nil { + return fmt.Errorf("create static dir: %w", err) + } + + err = cp.Copy(path.Join(nextOutputDir, "static"), path.Join(zeaburOutputDir, "static/_next/static")) + if err != nil { + return fmt.Errorf("copy static dir: %w", err) + } + + err = cp.Copy(path.Join(workdir, "public"), path.Join(zeaburOutputDir, "static")) + if err != nil { + return fmt.Errorf("copy public dir: %w", err) + } + + nextConfig, err := getNextConfig() + if err != nil { + return fmt.Errorf("get next config: %w", err) + } + + tmpl, err := template.New("launcher").Parse(launcherTemplate) + if err != nil { + return fmt.Errorf("parse launcher template: %w", err) + } + + type renderLauncherTemplateContext struct { + NextConfig string + } + + var launcher strings.Builder + err = tmpl.Execute(&launcher, renderLauncherTemplateContext{NextConfig: nextConfig}) + if err != nil { + return fmt.Errorf("render launcher template: %w", err) + } + + // if there is any serverless function page, create the first function page and symlinks for other function pages + if len(serverlessFunctionPages) > 0 { + + // create the first function page + err = constructNextFunction(zeaburOutputDir, serverlessFunctionPages[0]) + if err != nil { + return fmt.Errorf("construct next function: %w", err) + } + + // create symlinks for other function pages + for i, p := range serverlessFunctionPages { + if i == 0 { + continue + } + + funcPath := path.Join(zeaburOutputDir, "functions", p+".func") + + err = os.MkdirAll(path.Dir(funcPath), 0755) + if err != nil { + return fmt.Errorf("create function dir: %w", err) + } + + err = os.Symlink(path.Join(zeaburOutputDir, "functions", serverlessFunctionPages[0]+".func"), funcPath) + if err != nil { + return fmt.Errorf("create symlink: %w", err) + } + } + } + + // copy static pages which is rendered by Next.js at build time, so they will be served as static files + for _, p := range staticPages { + err = cp.Copy(path.Join(nextOutputDir, "server/pages", p), path.Join(zeaburOutputDir, "static", p)) + if err != nil { + return fmt.Errorf("copy static page: %w", err) + } + } + + cfg := types.ZeaburOutputConfig{Containerized: false, Routes: make([]types.ZeaburOutputConfigRoute, 0)} + cfgBytes, err := json.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + err = os.WriteFile(path.Join(zeaburOutputDir, "config.json"), cfgBytes, 0644) + if err != nil { + return fmt.Errorf("write config: %w", err) + } + + return nil +} diff --git a/internal/nodejs/node.go b/internal/nodejs/node.go index 3492bd4f..7ffd023f 100644 --- a/internal/nodejs/node.go +++ b/internal/nodejs/node.go @@ -4,7 +4,6 @@ package nodejs import ( "bytes" "embed" - "encoding/json" "text/template" "github.com/zeabur/zbpack/pkg/packer" @@ -19,12 +18,7 @@ type TemplateContext struct { BuildCmd string StartCmd string - OutputDir string - SPA bool - Bun bool - - // ZeaburConfig is the content of .zeabur/output/config.json - ZeaburConfig string + Bun bool } //go:embed templates @@ -43,72 +37,17 @@ func (c TemplateContext) Execute() (string, error) { return writer.String(), err } -func isMpaFramework(framework string) bool { - mpaFrameworks := []types.NodeProjectFramework{ - types.NodeProjectFrameworkHexo, - types.NodeProjectFrameworkVitepress, - types.NodeProjectFrameworkAstroStatic, - types.NodeProjectFrameworkSolidStartStatic, - } - - for _, f := range mpaFrameworks { - if framework == string(f) { - return true - } - } - - return false -} - -// isNotMpaFramework is `!isMpaFramework()`, but it's easier to read -func isNotMpaFramework(framework string) bool { - return !isMpaFramework(framework) -} - func getContextBasedOnMeta(meta types.PlanMeta) TemplateContext { context := TemplateContext{ NodeVersion: meta["nodeVersion"], InstallCmd: meta["installCmd"], BuildCmd: meta["buildCmd"], StartCmd: meta["startCmd"], - OutputDir: "", - SPA: true, // The flag specific to planner/bun. Bun: meta["bun"] == "true", } - if outputDir, ok := meta["outputDir"]; ok { - context.OutputDir = outputDir - context.SPA = isNotMpaFramework(meta["framework"]) - } - - type ZeaburConfigRoute struct { - Src string `json:"src"` - Dest string `json:"dest"` - } - - type ZeaburConfig struct { - Routes []ZeaburConfigRoute `json:"routes"` - Containerized bool `json:"containerized"` - } - - cfg := ZeaburConfig{ - Routes: []ZeaburConfigRoute{}, - Containerized: true, - } - - if context.OutputDir != "" { - cfg.Containerized = false - } - - if context.SPA { - cfg.Routes = []ZeaburConfigRoute{{Src: ".*", Dest: "/index.html"}} - } - - configStr, _ := json.Marshal(cfg) - context.ZeaburConfig = string(configStr) - return context } diff --git a/internal/nodejs/node_test.go b/internal/nodejs/node_test.go index 022b20be..350bc97b 100644 --- a/internal/nodejs/node_test.go +++ b/internal/nodejs/node_test.go @@ -7,30 +7,6 @@ import ( "github.com/zeabur/zbpack/pkg/types" ) -func TestIsMpaFramework_WithoutFramework(t *testing.T) { - if isMpaFramework("") { - t.Error("should return false") - } -} - -func TestIsNotMpaFramework_WithoutFramework(t *testing.T) { - if !isNotMpaFramework("") { - t.Error("should return true") - } -} - -func TestIsMpaFramework_DefaultFalse(t *testing.T) { - if isMpaFramework("aaaaaaaaaaaaaaaaaaaaaaaa") { - t.Error("should return false") - } -} - -func TestIsMpaFramework_CanTrue(t *testing.T) { - if isNotMpaFramework("hexo") { - t.Error("should return true") - } -} - // TODO)) type-safe builder func TestGetContextBasedOnMeta_MapShouldBeCorrect(t *testing.T) { meta := getContextBasedOnMeta(types.PlanMeta{ @@ -45,7 +21,6 @@ func TestGetContextBasedOnMeta_MapShouldBeCorrect(t *testing.T) { InstallCmd: "npm install", BuildCmd: "npm run build", StartCmd: "npm run start", - SPA: true, }) } @@ -64,8 +39,6 @@ func TestGetContextBasedOnMeta_WithOutputdirAndSPAFramework(t *testing.T) { InstallCmd: "npm install", BuildCmd: "npm run build", StartCmd: "npm run start", - OutputDir: "dist", - SPA: true, }) } @@ -84,7 +57,5 @@ func TestGetContextBasedOnMeta_WithOutputdirAndMPAFramework(t *testing.T) { InstallCmd: "npm install", BuildCmd: "npm run build", StartCmd: "npm run start", - OutputDir: "dist", - SPA: false, }) } diff --git a/internal/nodejs/plan.go b/internal/nodejs/plan.go index 0d7a178f..05cc311c 100644 --- a/internal/nodejs/plan.go +++ b/internal/nodejs/plan.go @@ -2,6 +2,7 @@ package nodejs import ( "log" + "os" "strconv" "strings" @@ -28,6 +29,7 @@ type nodePlanContext struct { BuildCmd optional.Option[string] StartCmd optional.Option[string] StaticOutputDir optional.Option[string] + Serverless optional.Option[bool] } // DeterminePackageManager determines the package manager of the Node.js project. @@ -535,6 +537,34 @@ func GetStaticOutputDir(ctx *nodePlanContext) string { return dir.Unwrap() } +func getServerless(ctx *nodePlanContext) bool { + expEnv := os.Getenv("EXPERIMENTAL_SERVERLESS") + if expEnv != "true" && expEnv != "1" { + return false + } + + sl := &ctx.Serverless + + if serverless, err := sl.Take(); err == nil { + return serverless + } + + framework := DetermineProjectFramework(ctx) + + defaultServerless := map[types.NodeProjectFramework]bool{ + types.NodeProjectFrameworkNextJs: true, + types.NodeProjectFrameworkNuxtJs: true, + } + + if serverless, ok := defaultServerless[framework]; ok { + *sl = optional.Some(serverless) + return sl.Unwrap() + } + + *sl = optional.Some(false) + return sl.Unwrap() +} + // GetMetaOptions is the options for GetMeta. type GetMetaOptions struct { Src afero.Fs @@ -602,5 +632,10 @@ func GetMeta(opt GetMetaOptions) types.PlanMeta { } meta["startCmd"] = startCmd + serverless := getServerless(ctx) + if serverless { + meta["serverless"] = strconv.FormatBool(serverless) + } + return meta } diff --git a/internal/nodejs/template_test.go b/internal/nodejs/template_test.go index 1d87b1ae..84d22278 100644 --- a/internal/nodejs/template_test.go +++ b/internal/nodejs/template_test.go @@ -15,9 +15,6 @@ func TestTemplate_NBuildCmd_NOutputDir(t *testing.T) { InstallCmd: "yarn install", BuildCmd: "", StartCmd: "yarn start", - - OutputDir: "", - SPA: false, } result, err := ctx.Execute() @@ -32,9 +29,6 @@ func TestTemplate_NBuildCmd_OutputDir_NSPA(t *testing.T) { InstallCmd: "yarn install", BuildCmd: "", StartCmd: "yarn start", - - OutputDir: "dist", - SPA: false, } result, err := ctx.Execute() @@ -49,9 +43,6 @@ func TestTemplate_NBuildCmd_OutputDir_SPA(t *testing.T) { InstallCmd: "yarn install", BuildCmd: "", StartCmd: "yarn start", - - OutputDir: "dist", - SPA: true, } result, err := ctx.Execute() @@ -66,9 +57,6 @@ func TestTemplate_BuildCmd_NOutputDir(t *testing.T) { InstallCmd: "yarn install", BuildCmd: "yarn build", StartCmd: "yarn start", - - OutputDir: "", - SPA: false, } result, err := ctx.Execute() @@ -83,9 +71,6 @@ func TestTemplate_BuildCmd_OutputDir(t *testing.T) { InstallCmd: "yarn install", BuildCmd: "yarn build", StartCmd: "yarn start", - - OutputDir: "dist", - SPA: true, } result, err := ctx.Execute() diff --git a/internal/nodejs/templates/nginx-runtime.Dockerfile b/internal/nodejs/templates/nginx-runtime.Dockerfile deleted file mode 100644 index 484997aa..00000000 --- a/internal/nodejs/templates/nginx-runtime.Dockerfile +++ /dev/null @@ -1,17 +0,0 @@ -{{define "nginx-runtime"}} -FROM nginx:alpine as runtime - -COPY --from=build /src/{{.OutputDir}} /src/.zeabur/output/static -RUN echo "\ - server { \ - listen 8080; \ - root /src/.zeabur/output/static; \ - absolute_redirect off; \ - location / { \ -{{ if .SPA }} try_files \$uri /index.html; \ -{{ else }} try_files \$uri \$uri.html \$uri/index.html /404.html =404; \ -{{ end }} } \ - }"> /etc/nginx/conf.d/default.conf -EXPOSE 8080 -{{ if .ZeaburConfig }}RUN echo '{{ .ZeaburConfig }}' > /src/.zeabur/output/config.json{{ end }} -{{end}} diff --git a/internal/nodejs/templates/template.Dockerfile b/internal/nodejs/templates/template.Dockerfile index 47b2c0e4..01631576 100644 --- a/internal/nodejs/templates/template.Dockerfile +++ b/internal/nodejs/templates/template.Dockerfile @@ -22,7 +22,5 @@ RUN {{ .InstallCmd }} # Build if we can build it {{ if .BuildCmd }}RUN {{ .BuildCmd }}{{ end }} -{{ if .OutputDir }}{{ template "nginx-runtime" . }}{{ else }} EXPOSE 8080 CMD {{ .StartCmd }} -{{ end }} diff --git a/internal/static/TransformServerless.go b/internal/static/TransformServerless.go new file mode 100644 index 00000000..f8c4cbb1 --- /dev/null +++ b/internal/static/TransformServerless.go @@ -0,0 +1,57 @@ +package static + +import ( + "encoding/json" + "os" + "path" + + "github.com/zeabur/zbpack/internal/utils" + "github.com/zeabur/zbpack/pkg/types" +) + +// TransformServerless copies the static files from output to .zeabur/output/static and creates a config.json file for SPA +func TransformServerless(image, workdir string, meta types.PlanMeta) error { + err := utils.CopyFromImage(image, path.Join("/src", meta["outputDir"])+"/.", path.Join(workdir, ".zeabur/output/static")) + if err != nil { + return err + } + + config := types.ZeaburOutputConfig{Containerized: false, Routes: make([]types.ZeaburOutputConfigRoute, 0)} + if isNotMpaFramework(meta["framework"]) { + config.Routes = []types.ZeaburOutputConfigRoute{{Src: ".*", Dest: "/index.html"}} + } + + configBytes, err := json.Marshal(config) + if err != nil { + return err + } + + err = os.WriteFile(path.Join(workdir, ".zeabur/output/config.json"), configBytes, 0644) + if err != nil { + return err + } + + return nil +} + +func isMpaFramework(framework string) bool { + mpaFrameworks := []types.NodeProjectFramework{ + types.NodeProjectFrameworkHexo, + types.NodeProjectFrameworkVitepress, + types.NodeProjectFrameworkAstroStatic, + types.NodeProjectFrameworkSolidStartStatic, + } + + for _, f := range mpaFrameworks { + if framework == string(f) { + return true + } + } + + return false +} + +// isNotMpaFramework is `!isMpaFramework()`, but it's easier to read +func isNotMpaFramework(framework string) bool { + return !isMpaFramework(framework) +} diff --git a/internal/utils/copy_from_image.go b/internal/utils/copy_from_image.go new file mode 100644 index 00000000..260843d2 --- /dev/null +++ b/internal/utils/copy_from_image.go @@ -0,0 +1,43 @@ +package utils + +import ( + "fmt" + "log" + "os" + "os/exec" + "strings" +) + +// CopyFromImage copies a directory from a docker image to the host +func CopyFromImage(image, srcInImage, destOnHost string) error { + createCmd := exec.Command("docker", "create", image) + createCmd.Stderr = os.Stderr + output, err := createCmd.Output() + if err != nil { + return fmt.Errorf("create docker container: %w", err) + } + + defer func() { + removeCmd := exec.Command("docker", "rm", "-f", strings.TrimSpace(string(output))) + removeCmd.Stderr = os.Stderr + if err := removeCmd.Run(); err != nil { + log.Println(err) + } + }() + + containerID := strings.TrimSpace(string(output)) + + if err := os.MkdirAll(destOnHost, 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + copyCmd := exec.Command("docker", "cp", containerID+":"+srcInImage, destOnHost) + var stderr strings.Builder + copyCmd.Stderr = &stderr + err = copyCmd.Run() + if err != nil { + return fmt.Errorf("copy from image: %s: %w", stderr.String(), err) + } + + return nil +} diff --git a/pkg/types/config.go b/pkg/types/config.go new file mode 100644 index 00000000..b68676aa --- /dev/null +++ b/pkg/types/config.go @@ -0,0 +1,23 @@ +package types + +// ZeaburOutputConfigRoute is a route in the output config to override the default route +// src is the path regex want to override, dest is the path you want to override it with +// for example, assume we already have an index.html in .zeabur/output/static, +// and our service is a Single Page App, we want to override all routes to serve index.html +// we would add the following to the output config: +// { "src": ".*", "dest": "/index.html" } +type ZeaburOutputConfigRoute struct { + Src string `json:"src"` + Dest string `json:"dest"` +} + +// ZeaburOutputConfig is the output config of Zeabur +type ZeaburOutputConfig struct { + // Routes is a list of routes to override the default route + Routes []ZeaburOutputConfigRoute `json:"routes"` + + // Containerized is a flag to indicate whether the output is containerized + // If it is containerized, Zeabur will deploy a container during deployment + // Otherwise, service is deployed as a serverless function plus static files + Containerized bool `json:"containerized"` +} diff --git a/pkg/zeaburpack/main.go b/pkg/zeaburpack/main.go index e8a52c6f..49b65bd1 100644 --- a/pkg/zeaburpack/main.go +++ b/pkg/zeaburpack/main.go @@ -2,10 +2,14 @@ package zeaburpack import ( "fmt" + "log" "os" "path" "strings" + "github.com/zeabur/zbpack/internal/nodejs/nextjs" + "github.com/zeabur/zbpack/internal/static" + "github.com/spf13/afero" "github.com/zeabur/zbpack/pkg/plan" @@ -173,16 +177,36 @@ func Build(opt *BuildOptions) error { return err } + _ = os.RemoveAll(".zeabur") + + if t == types.PlanTypeNodejs && m["framework"] == string(types.NodeProjectFrameworkNextJs) && m["serverless"] == "true" { + println("Transforming build output to serverless format ...") + err = nextjs.TransformServerless(*opt.ResultImage, *opt.Path) + if err != nil { + log.Println("Failed to transform serverless: " + err.Error()) + handleBuildFailed(err) + return err + } + } + + if t == types.PlanTypeNodejs && m["outputDir"] != "" { + println("Transforming build output to serverless format ...") + err = static.TransformServerless(*opt.ResultImage, *opt.Path, m) + if err != nil { + println("Failed to transform serverless: " + err.Error()) + handleBuildFailed(err) + return err + } + } + if opt.Interactive != nil && *opt.Interactive { handleLog("\n\033[32mBuild successful\033[0m\n") handleLog("\033[90m" + "To run the image, use the following command:" + "\033[0m") - handleLog("docker run -p 8080:8080 -it " + *opt.ResultImage) - } - - _, err = copyZeaburOutputToHost(*opt.ResultImage, *opt.Path) - if err != nil { - handleBuildFailed(fmt.Errorf("failed to copy zeabur output: %w", err)) - return err + if t == types.PlanTypeNodejs && m["outputDir"] != "" { + handleLog("npx serve .zeabur/output/static") + } else { + handleLog("docker run -p 8080:8080 -it " + *opt.ResultImage) + } } return nil