Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(lib): Serverless output for static websites and Next.js #135

Merged
merged 6 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
85 changes: 85 additions & 0 deletions internal/nodejs/nextjs/function.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package nextjs

Check warning on line 1 in internal/nodejs/nextjs/function.go

View workflow job for this annotation

GitHub Actions / lint

package-comments: should have a package comment (revive)

import (
"encoding/json"
"fmt"
cp "github.com/otiai10/copy"

Check failure on line 6 in internal/nodejs/nextjs/function.go

View workflow job for this annotation

GitHub Actions / lint

File is not `goimports`-ed (goimports)
"os"
"path"
"path/filepath"
"strings"
)

// 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 {

Check warning on line 51 in internal/nodejs/nextjs/function.go

View workflow job for this annotation

GitHub Actions / lint

var-naming: type nftJson should be nftJSON (revive)
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
}
68 changes: 68 additions & 0 deletions internal/nodejs/nextjs/launcher.go
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 24 additions & 0 deletions internal/nodejs/nextjs/launcher.js.tmpl
Original file line number Diff line number Diff line change
@@ -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);
}
};
138 changes: 138 additions & 0 deletions internal/nodejs/nextjs/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package nextjs

import (
_ "embed"

Check warning on line 4 in internal/nodejs/nextjs/main.go

View workflow job for this annotation

GitHub Actions / lint

blank-imports: a blank import should be only in a main or test package, or have a comment justifying it (revive)
"encoding/json"
"fmt"
cp "github.com/otiai10/copy"

Check failure on line 7 in internal/nodejs/nextjs/main.go

View workflow job for this annotation

GitHub Actions / lint

File is not `goimports`-ed (goimports)
"github.com/zeabur/zbpack/internal/utils"
"github.com/zeabur/zbpack/pkg/types"
"os"
"path"
"path/filepath"
"strings"
"text/template"
)

// 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)

Check failure on line 136 in internal/nodejs/nextjs/main.go

View workflow job for this annotation

GitHub Actions / lint

ineffectual assignment to err (ineffassign)
return nil
}
Loading
Loading