diff --git a/Makefile b/Makefile index 64299adc..75298cba 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,13 @@ install: # Examples ## +example.zero: + @ # go run main.go -C example/zero generate2 --embed + @ go run main.go -C example/zero generate2 + +example.zero.watch: + @ watch --clear -- $(MAKE) example.zero + example.basic: @ (cd example/basic && npm link ../../livebud) @ go run main.go -C example/basic run diff --git a/example/zero/_bud/app b/example/zero/_bud/app new file mode 100755 index 00000000..25c0d37b Binary files /dev/null and b/example/zero/_bud/app differ diff --git a/example/zero/_bud/cmd/app/main.go b/example/zero/_bud/cmd/app/main.go new file mode 100644 index 00000000..a6e0f18d --- /dev/null +++ b/example/zero/_bud/cmd/app/main.go @@ -0,0 +1,56 @@ +package main + +import ( + command "github.com/livebud/bud/example/zero/bud/internal/command" + env "github.com/livebud/bud/example/zero/bud/internal/env" + transpiler "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + viewer "github.com/livebud/bud/example/zero/bud/pkg/viewer" + web1 "github.com/livebud/bud/example/zero/bud/pkg/web" + controller "github.com/livebud/bud/example/zero/bud/pkg/web/controller" + middleware "github.com/livebud/bud/example/zero/bud/pkg/web/middleware" + view "github.com/livebud/bud/example/zero/bud/pkg/web/view" + web2 "github.com/livebud/bud/example/zero/command/web" + posts "github.com/livebud/bud/example/zero/controller/posts" + sessions "github.com/livebud/bud/example/zero/controller/sessions" + users "github.com/livebud/bud/example/zero/controller/users" + app "github.com/livebud/bud/example/zero/generator/app" + csrf "github.com/livebud/bud/example/zero/middleware/csrf" + wraprw "github.com/livebud/bud/example/zero/middleware/wraprw" + mw "github.com/livebud/bud/example/zero/mw" + goldmark "github.com/livebud/bud/example/zero/transpiler/goldmark" + tailwind "github.com/livebud/bud/example/zero/transpiler/tailwind" + gohtml "github.com/livebud/bud/example/zero/viewer/gohtml" + web "github.com/livebud/bud/example/zero/web" + gomod "github.com/livebud/bud/package/gomod" + log "github.com/livebud/bud/package/log" +) + +func main() { + app.Main(loadCLI) +} + +func loadCLI(gomodModule *gomod.Module, logLog log.Log) (*command.CLI, error) { + envEnv, err := env.Load() + if err != nil { + return nil, err + } + tailwindTranspiler := &tailwind.Transpiler{Log: logLog} + goldmarkTranspiler := &goldmark.Transpiler{Log: logLog} + transpilerTranspiler := transpiler.Load(tailwindTranspiler, goldmarkTranspiler) + gohtmlViewer := gohtml.New(logLog, transpilerTranspiler) + viewerViewer := viewer.New(transpilerTranspiler, gohtmlViewer) + viewView := view.New(gomodModule, viewerViewer) + postsController := &posts.Controller{} + sessionsController := &sessions.Controller{} + usersController := &users.Controller{Env: envEnv} + controllerController := controller.New(viewView, postsController, sessionsController, usersController) + csrfMiddleware := &csrf.Middleware{Env: envEnv} + wraprwMiddleware := &wraprw.Middleware{} + middlewareMiddleware := middleware.New(csrfMiddleware, wraprwMiddleware) + mwMiddleware := &mw.Middleware{Env: envEnv} + webWeb := &web.Web{Env: envEnv, Controller: controllerController, Middleware: middlewareMiddleware, MW: mwMiddleware, View: viewView} + web1Server := web1.New(webWeb) + web2Command := web2.New(envEnv, logLog, web1Server) + commandCLI := command.New(logLog, web2Command) + return commandCLI, err +} diff --git a/example/zero/_bud/cmd/gen/main.go b/example/zero/_bud/cmd/gen/main.go new file mode 100644 index 00000000..42528736 --- /dev/null +++ b/example/zero/_bud/cmd/gen/main.go @@ -0,0 +1,47 @@ +package main + +import ( + generator "github.com/livebud/bud/example/zero/bud/internal/generator" + transpiler "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + viewer "github.com/livebud/bud/example/zero/bud/pkg/viewer" + app "github.com/livebud/bud/example/zero/generator/app" + command "github.com/livebud/bud/example/zero/generator/command" + controller "github.com/livebud/bud/example/zero/generator/controller" + env "github.com/livebud/bud/example/zero/generator/env" + middleware "github.com/livebud/bud/example/zero/generator/middleware" + session "github.com/livebud/bud/example/zero/generator/session" + view "github.com/livebud/bud/example/zero/generator/view" + web "github.com/livebud/bud/example/zero/generator/web" + goldmark "github.com/livebud/bud/example/zero/transpiler/goldmark" + tailwind "github.com/livebud/bud/example/zero/transpiler/tailwind" + gohtml "github.com/livebud/bud/example/zero/viewer/gohtml" + framework "github.com/livebud/bud/framework" + genfs "github.com/livebud/bud/package/genfs" + gomod "github.com/livebud/bud/package/gomod" + log "github.com/livebud/bud/package/log" + gen "github.com/livebud/bud/runtime/gen" +) + +func main() { + gen.Main(loadGenerator) +} + +func loadGenerator(frameworkFlag *framework.Flag, genfsFileSystem genfs.FileSystem, gomodModule *gomod.Module, logLog log.Log) (*generator.Generator, error) { + genParser := gen.ProvideParser(genfsFileSystem, gomodModule) + genInjector := gen.ProvideInjector(genfsFileSystem, logLog, gomodModule, genParser) + appGenerator := app.New(frameworkFlag, genInjector, gomodModule) + commandGenerator := command.NewGenerator(gomodModule) + webGenerator := web.New(gomodModule) + controllerGenerator := controller.New(genInjector, gomodModule) + tailwindTranspiler := &tailwind.Transpiler{Log: logLog} + goldmarkTranspiler := &goldmark.Transpiler{Log: logLog} + transpilerTranspiler := transpiler.Load(tailwindTranspiler, goldmarkTranspiler) + gohtmlViewer := gohtml.New(logLog, transpilerTranspiler) + viewerViewer := viewer.New(transpilerTranspiler, gohtmlViewer) + viewGenerator := view.New(frameworkFlag, gomodModule, viewerViewer) + envGenerator := env.New(gomodModule) + middlewareGenerator := middleware.New(gomodModule) + sessionGenerator := session.New(gomodModule) + generatorGenerator := generator.NewGenerator(genfsFileSystem, logLog, appGenerator, commandGenerator, webGenerator, controllerGenerator, viewGenerator, envGenerator, middlewareGenerator, sessionGenerator) + return generatorGenerator, nil +} diff --git a/example/zero/_bud/gen b/example/zero/_bud/gen new file mode 100755 index 00000000..21e7f517 Binary files /dev/null and b/example/zero/_bud/gen differ diff --git a/example/zero/_bud/internal/command/command.go b/example/zero/_bud/internal/command/command.go new file mode 100644 index 00000000..c9fc59fb --- /dev/null +++ b/example/zero/_bud/internal/command/command.go @@ -0,0 +1,36 @@ +package command + +import ( + context "context" + web "github.com/livebud/bud/example/zero/command/web" + command "github.com/livebud/bud/example/zero/generator/command" + log "github.com/livebud/bud/package/log" +) + +func New( + log log.Log, + webCmd *web.Command, +) *CLI { + cli := command.New("app") + webIn := new(web.Serve) + cli.Run(func(ctx context.Context) error { + return command.Go(ctx, log, + func(ctx context.Context) error { return webCmd.GoServe(ctx, webIn) }, + ) + }) + + { // web + + { // web:serve + cmd := cli.Command("web:serve", "serve web requests") + in := new(web.Serve) + cmd.Run(func(ctx context.Context) error { + return webCmd.GoServe(ctx, in) + }) + } + } + + return cli +} + +type CLI = command.CLI diff --git a/example/zero/_bud/internal/env/env.go b/example/zero/_bud/internal/env/env.go new file mode 100644 index 00000000..c3c61244 --- /dev/null +++ b/example/zero/_bud/internal/env/env.go @@ -0,0 +1,19 @@ +package env + +// Code generated by bud; DO NOT EDIT. + +import ( + env "github.com/livebud/bud/example/zero/env" + runenv "github.com/livebud/bud/example/zero/generator/env" +) + +func Load() (*Env, error) { + var e Env + // TODO: do this statically instead of using reflect + if err := runenv.Parse(&e); err != nil { + return nil, err + } + return &e, nil +} + +type Env = env.Env diff --git a/example/zero/_bud/internal/generator/generator.go b/example/zero/_bud/internal/generator/generator.go new file mode 100644 index 00000000..d2abf532 --- /dev/null +++ b/example/zero/_bud/internal/generator/generator.go @@ -0,0 +1,42 @@ +package generator + +import ( + app "github.com/livebud/bud/example/zero/generator/app" + command "github.com/livebud/bud/example/zero/generator/command" + controller "github.com/livebud/bud/example/zero/generator/controller" + env "github.com/livebud/bud/example/zero/generator/env" + middleware "github.com/livebud/bud/example/zero/generator/middleware" + session "github.com/livebud/bud/example/zero/generator/session" + view "github.com/livebud/bud/example/zero/generator/view" + web "github.com/livebud/bud/example/zero/generator/web" + log "github.com/livebud/bud/package/log" + generator "github.com/livebud/bud/runtime/generator" +) + +func NewGenerator( + genfs generator.FileSystem, + log log.Log, + app *app.Generator, + command *command.Generator, + web *web.Generator, + controller *controller.Generator, + view *view.Generator, + env *env.Generator, + middleware *middleware.Generator, + session *session.Generator, +) *Generator { + return generator.NewGenerator( + genfs, + log, + app, + command, + web, + controller, + view, + env, + middleware, + session, + ) +} + +type Generator = generator.Generator diff --git a/example/zero/_bud/pkg/sessions/sessions.go b/example/zero/_bud/pkg/sessions/sessions.go new file mode 100644 index 00000000..c694d458 --- /dev/null +++ b/example/zero/_bud/pkg/sessions/sessions.go @@ -0,0 +1,130 @@ +package sessions + +// Code generated by bud; DO NOT EDIT. + +import ( + bytes "bytes" + context "context" + json "encoding/json" + errors "errors" + fmt "fmt" + session "github.com/livebud/bud/example/zero/session" + secretbox "github.com/livebud/bud/package/cipher/secretbox" + cookies "github.com/livebud/bud/package/cookies" + budsession "github.com/livebud/bud/package/session" + http "net/http" + time "time" +) + +func New(secret string) *Middleware { + // cipher := secretbox.New([32]byte{ + // 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + // 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + // 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + // 0x13, 0x89, + // }) + // cs := cookies.Secure(cipher) + return &Middleware{ + Cookies: cookies.Default(), + Store: nil, + } +} + +type Middleware struct { + Cookies cookies.Store + Store budsession.Store // If nil, we use the cookie store +} + +const sessionID = "sid" + +type contextKey string + +var sessionKey = contextKey("session") + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + cipher := secretbox.New([32]byte{ + 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + 0x13, 0x89, + }) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + store := m.Store + if store == nil { + store = budsession.New(cookies.Secure(cipher), w, r) + } + + cookie, err := m.Cookies.Get(r, sessionID) + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + fmt.Println("cookie error, showing unauthorized", err) + http.Error(w, "cookie get error", http.StatusUnauthorized) + return + } + cookie = &http.Cookie{ + Name: sessionID, + Value: "a123", + Path: "/", + HttpOnly: true, + MaxAge: 86400, // 1 day + Expires: time.Now().Add(86400 * time.Second), + } + } + + var session Session + if sessionData, err := store.Get(cookie.Value); err != nil { + if !errors.Is(err, budsession.ErrNotFound) { + fmt.Println("session get error, showing unauthorized", err) + http.Error(w, "session get error", http.StatusUnauthorized) + return + } + } else if sessionData != nil { + fmt.Println("GOT SESSION DATA", string(sessionData)) + if err := json.NewDecoder(bytes.NewReader(sessionData)).Decode(&session); err != nil { + fmt.Println("session decode error, showing unauthorized", err) + http.Error(w, "session decode error", http.StatusUnauthorized) + return + } + } + + ctx := context.WithValue(r.Context(), sessionKey, &session) + r = r.WithContext(ctx) + + fmt.Println("mw: session before", session.UserID) + next.ServeHTTP(w, r) + + payload := new(bytes.Buffer) + if err := json.NewEncoder(payload).Encode(session); err != nil { + fmt.Println("session encode error, showing unauthorized", err) + http.Error(w, "session encode error", http.StatusUnauthorized) + return + } + fmt.Println(payload.String()) + + if err := store.Set(cookie.Value, payload.Bytes(), time.Now()); err != nil { + fmt.Println("session set error, showing unauthorized", err) + http.Error(w, "session set error", http.StatusUnauthorized) + return + } + + if err := m.Cookies.Set(w, cookie); err != nil { + fmt.Println("cookie set error, showing unauthorized", err) + http.Error(w, "cookie set error", http.StatusUnauthorized) + return + } + }) +} + +func isDirty(session *Session) bool { + return true +} + +func From(ctx context.Context) (*Session, error) { + session, ok := ctx.Value(sessionKey).(*Session) + if !ok { + return nil, fmt.Errorf("no session in context") + } + return session, nil +} + +type Session = session.Session diff --git a/example/zero/_bud/pkg/transpiler/transpiler.go b/example/zero/_bud/pkg/transpiler/transpiler.go new file mode 100644 index 00000000..1d15634a --- /dev/null +++ b/example/zero/_bud/pkg/transpiler/transpiler.go @@ -0,0 +1,36 @@ +package transpiler + +import ( + errors "errors" + goldmark "github.com/livebud/bud/example/zero/transpiler/goldmark" + tailwind "github.com/livebud/bud/example/zero/transpiler/tailwind" + transpiler "github.com/livebud/bud/runtime/transpiler2" +) + +// Load the transpiler +func Load( + tailwind *tailwind.Transpiler, + goldmark *goldmark.Transpiler, +) Transpiler { + tr := transpiler.New() + tr.Add(".gohtml", ".gohtml", tailwind.GohtmlToGohtml) + tr.Add(".md", ".gohtml", goldmark.MdToGohtml) + return &proxy{tr} +} + +type Transpiler = transpiler.Interface + +type proxy struct { + Transpiler +} + +func (p *proxy) Transpile(fromExt, toExt string, code []byte) ([]byte, error) { + transpiled, err := p.Transpiler.Transpile(fromExt, toExt, code) + if err != nil { + if !errors.Is(err, transpiler.ErrNoPath) { + return nil, err + } + return code, nil + } + return transpiled, nil +} diff --git a/example/zero/_bud/pkg/viewer/viewer.go b/example/zero/_bud/pkg/viewer/viewer.go new file mode 100644 index 00000000..f5739102 --- /dev/null +++ b/example/zero/_bud/pkg/viewer/viewer.go @@ -0,0 +1,112 @@ +package viewer + +import ( + context "context" + fmt "fmt" + transpiler "github.com/livebud/bud/example/zero/bud/pkg/transpiler" + gohtml "github.com/livebud/bud/example/zero/viewer/gohtml" + router "github.com/livebud/bud/package/router" + view "github.com/livebud/bud/runtime/view" + fs "io/fs" +) + +// Load the viewer +// TODO: turn most of this code into a runtime library +func New( + transpiler transpiler.Transpiler, + gohtml *gohtml.Viewer, +) Viewer { + return &viewer{ + transpiler: transpiler, + viewers: map[string]view.Viewer{ + ".gohtml": gohtml, + }, + accepts: []string{ + ".gohtml", + }, + } +} + +type Viewer = view.Viewer + +type viewer struct { + transpiler transpiler.Transpiler + viewers map[string]view.Viewer + accepts []string +} + +var _ Viewer = (*viewer)(nil) + +func (v *viewer) Register(r *router.Router, pages []*view.Page) { + for _, viewer := range v.viewers { + viewer.Register(r, pages) + } +} + +func (v *viewer) Render(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap) ([]byte, error) { + viewer, ok := v.viewers[page.Ext] + if ok { + return viewer.Render(ctx, fsys, page, propMap) + } + // TODO: don't choose best when embedded + ext, err := v.transpiler.Best(page.Ext, v.accepts) + if err != nil { + return nil, fmt.Errorf("viewer: unable to render %q. %w", page.Path, err) + } + viewer, ok = v.viewers[ext] + if ok { + return viewer.Render(ctx, fsys, page, propMap) + } + return nil, fmt.Errorf("viewer: unable to find acceptable viewer to render %q", page.Path) +} + +func (v *viewer) RenderError(ctx context.Context, fsys fs.FS, page *view.Page, propMap view.PropMap, err error) []byte { + viewer, ok := v.viewers[page.Error.Ext] + if ok { + return viewer.RenderError(ctx, fsys, page, propMap, err) + } + // TODO: don't choose best when embedded + ext, err := v.transpiler.Best(page.Error.Ext, v.accepts) + if err != nil { + msg := fmt.Sprintf("viewer: unable to find extension to render error page %q for error %s", page.Error.Path, err) + return []byte(msg) + } + viewer, ok = v.viewers[ext] + if ok { + return viewer.RenderError(ctx, fsys, page, propMap, err) + } + msg := fmt.Sprintf("viewer: unable to find acceptable viewer to render error page %q for error %s", page.Error.Path, err) + return []byte(msg) +} + +func (v *viewer) Bundle(ctx context.Context, fsys fs.FS, pages view.Pages, embeds view.Embeds) error { + exts := map[string]map[string]*view.Page{} + for _, page := range pages { + if _, ok := v.viewers[page.Ext]; ok { + if exts[page.Ext] == nil { + exts[page.Ext] = map[string]*view.Page{} + } + exts[page.Ext][page.Path] = page + continue + } + ext, err := v.transpiler.Best(page.Ext, v.accepts) + if err != nil { + return fmt.Errorf("viewer: unable find viewer to bundle %q. %w", page.Path, err) + } + if exts[ext] == nil { + exts[ext] = map[string]*view.Page{} + } + exts[ext][page.Path] = page + } + // TODO: consider parallelizing this + for ext, pages := range exts { + viewer, ok := v.viewers[ext] + if !ok { + return fmt.Errorf("viewer: unable find viewer for %q", ext) + } + if err := viewer.Bundle(ctx, fsys, pages, embeds); err != nil { + return fmt.Errorf("viewer: unable to bundle %q. %w", ext, err) + } + } + return nil +} diff --git a/example/zero/_bud/pkg/web/controller/controller.go b/example/zero/_bud/pkg/web/controller/controller.go new file mode 100644 index 00000000..ffa5cf48 --- /dev/null +++ b/example/zero/_bud/pkg/web/controller/controller.go @@ -0,0 +1,147 @@ +package controller + +import ( + context "context" + sessions1 "github.com/livebud/bud/example/zero/bud/pkg/sessions" + view "github.com/livebud/bud/example/zero/bud/pkg/web/view" + posts "github.com/livebud/bud/example/zero/controller/posts" + sessions "github.com/livebud/bud/example/zero/controller/sessions" + users "github.com/livebud/bud/example/zero/controller/users" + router "github.com/livebud/bud/package/router" + http "net/http" +) + +func New( + view *view.View, + posts *posts.Controller, + sessions *sessions.Controller, + users *users.Controller, +) *Controller { + return &Controller{ + &PostsController{ + &PostsIndexAction{posts, view}, + }, + &SessionsController{ + &SessionsNewAction{sessions, view}, + }, + &UsersController{ + &UsersIndexAction{users, view}, + &UsersNewAction{users, view}, + }, + } +} + +type Controller struct { + Posts *PostsController + Sessions *SessionsController + Users *UsersController +} + +// TODO: use a router.Router interface +func (c *Controller) Mount(r *router.Router) error { + c.Posts.Mount(r) + c.Sessions.Mount(r) + c.Users.Mount(r) + return nil +} + +type PostsController struct { + Index *PostsIndexAction +} + +func (c *PostsController) Mount(r *router.Router) error { + r.Get("/posts", c.Index) + return nil +} + +type PostsIndexAction struct { + controller *posts.Controller + view *view.View +} + +func (a *PostsIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + propMap := map[string]interface{}{} + indexContext, err := loadPostsIndexContext(r.Context()) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + res, err := a.controller.Index(indexContext) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + propMap["posts/index"] = res + html, err := a.view.Render(ctx, "posts/index", propMap) + if err != nil { + html = a.view.RenderError(ctx, "posts/index", propMap, err) + } + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) +} + +type SessionsController struct { + New *SessionsNewAction +} + +func (c *SessionsController) Mount(r *router.Router) error { + r.Get("/sessions/new", c.New) + return nil +} + +type SessionsNewAction struct { + controller *sessions.Controller + view *view.View +} + +func (a *SessionsNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +type UsersController struct { + Index *UsersIndexAction + New *UsersNewAction +} + +func (c *UsersController) Mount(r *router.Router) error { + r.Get("/users/new", c.New) + r.Get("/users", c.Index) + return nil +} + +type UsersIndexAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.Index() + w.Write([]byte(res)) +} + +type UsersNewAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +func loadPostsIndexContext(contextContext context.Context) (*posts.IndexContext, error) { + sessions1Session, err := sessions1.From(contextContext) + if err != nil { + return nil, err + } + postsIndexContext := &posts.IndexContext{Session: sessions1Session} + return postsIndexContext, err +} diff --git a/example/zero/_bud/pkg/web/middleware/middleware.go b/example/zero/_bud/pkg/web/middleware/middleware.go new file mode 100644 index 00000000..fc8463f6 --- /dev/null +++ b/example/zero/_bud/pkg/web/middleware/middleware.go @@ -0,0 +1,43 @@ +package middleware + +// Code generated by bud; DO NOT EDIT. + +import ( + csrf "github.com/livebud/bud/example/zero/middleware/csrf" + wraprw "github.com/livebud/bud/example/zero/middleware/wraprw" + middleware "github.com/livebud/bud/package/middleware" + http "net/http" +) + +type Interface = middleware.Middleware +type Stack = middleware.Stack + +func New( + csrf *csrf.Middleware, + // session *session.Middleware, + wraprw *wraprw.Middleware, +) *Middleware { + return &Middleware{ + csrf, + // session, + wraprw, + Stack{ + wraprw, + csrf, + // session, + }, + } +} + +type Middleware struct { + CSRF *csrf.Middleware + // Session *session.Middleware + WrapRW *wraprw.Middleware + stack Stack +} + +var _ Interface = (*Middleware)(nil) + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return m.stack.Middleware(next) +} diff --git a/example/zero/_bud/pkg/web/view/view.go b/example/zero/_bud/pkg/web/view/view.go new file mode 100644 index 00000000..41197e27 --- /dev/null +++ b/example/zero/_bud/pkg/web/view/view.go @@ -0,0 +1,204 @@ +package view + +import ( + context "context" + fmt "fmt" + viewer "github.com/livebud/bud/example/zero/bud/pkg/viewer" + gomod "github.com/livebud/bud/package/gomod" + router "github.com/livebud/bud/package/router" + virtual "github.com/livebud/bud/package/virtual" + view "github.com/livebud/bud/runtime/view" + fs "io/fs" + http "net/http" +) + +func New( + module *gomod.Module, + viewer viewer.Viewer, +) *View { + + fsys := module + + // TODO: generate with Pages instead + postsIndexPage := &PostsIndexPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/index", + Path: "posts/index.gohtml", + Ext: ".gohtml", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + postsIntroPage := &PostsIntroPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/intro", + Path: "posts/intro.md", + Ext: ".md", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + return &View{ + map[string]renderer{ + "posts/index": postsIndexPage, + "posts/intro": postsIntroPage, + }, + &PostsView{ + postsIndexPage, + postsIntroPage, + }, + } +} + +var embeddedFS = virtual.List{} + +type renderer interface { + Render(ctx context.Context, propMap view.PropMap) ([]byte, error) + RenderError(ctx context.Context, propMap view.PropMap, err error) []byte +} + +type View struct { + pages map[string]renderer + Posts *PostsView +} + +var _ view.Interface = (*View)(nil) + +// TODO: use a router.Router interface +func (v *View) Mount(r *router.Router) error { + return nil +} + +func (v *View) Render(ctx context.Context, key string, propMap view.PropMap) ([]byte, error) { + page, ok := v.pages[key] + if !ok { + return nil, fmt.Errorf("generator/view: no page for key %s", key) + } + return page.Render(ctx, propMap) +} + +func (v *View) RenderError(ctx context.Context, key string, propMap view.PropMap, err error) []byte { + page, ok := v.pages[key] + if !ok { + return []byte(fmt.Sprintf("no page %q to render error: %s", key, err)) + } + return page.RenderError(ctx, propMap, err) +} + +type PostsView struct { + Index *PostsIndexPage + Intro *PostsIntroPage +} + +func (p *PostsView) Mount(r *router.Router) error { + r.Mount(p.Index) + r.Mount(p.Intro) + return nil +} + +type PostsIndexPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIndexPage) Mount(r *router.Router) error { + r.Get("/posts", p) + return nil +} + +func (p *PostsIndexPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIndexPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIndexPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} + +type PostsIntroPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIntroPage) Mount(r *router.Router) error { + r.Get("/posts/intro", p) + return nil +} + +// TODO: consolidate +func (p *PostsIntroPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIntroPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIntroPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} diff --git a/example/zero/_bud/pkg/web/web.go b/example/zero/_bud/pkg/web/web.go new file mode 100644 index 00000000..40d78912 --- /dev/null +++ b/example/zero/_bud/pkg/web/web.go @@ -0,0 +1,54 @@ +package web + +// Code generated by bud; DO NOT EDIT. + +import ( + context "context" + errors "errors" + web "github.com/livebud/bud/example/zero/web" + middleware "github.com/livebud/bud/package/middleware" + router "github.com/livebud/bud/package/router" + socket "github.com/livebud/bud/package/socket" + errgroup "golang.org/x/sync/errgroup" + net "net" + http "net/http" +) + +func New(web *web.Web) *Server { + middleware := web.Stack(middleware.Stack{}) + router := web.Router(router.New()) + handler := web.Handler(middleware.Middleware(router)) + server := web.Server(&http.Server{ + Handler: handler, + }) + return &Server{server} +} + +type Server struct { + server *http.Server +} + +func (s *Server) Listen(ctx context.Context, addr string) error { + ln, err := socket.Listen(addr) + if err != nil { + return err + } + defer ln.Close() + return s.Serve(ctx, ln) +} + +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + eg := new(errgroup.Group) + eg.Go(func() error { + return s.server.Serve(ln) + }) + eg.Go(func() error { + <-ctx.Done() + // TODO: wait for another ctrl+c to force quit + return s.server.Shutdown(context.Background()) + }) + if err := eg.Wait(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/example/zero/command/web/command.go b/example/zero/command/web/command.go new file mode 100644 index 00000000..0280cb00 --- /dev/null +++ b/example/zero/command/web/command.go @@ -0,0 +1,32 @@ +package web + +import ( + "context" + + "github.com/livebud/bud/example/zero/bud/pkg/web" + "github.com/livebud/bud/example/zero/env" + "github.com/livebud/bud/package/log" +) + +func New(env *env.Env, log log.Log, server *web.Server) *Command { + return &Command{env, log, server} +} + +type Command struct { + env *env.Env + log log.Log + server *web.Server +} + +type Serve struct { + Listen string +} + +func (c *Command) GoServe(ctx context.Context, in *Serve) error { + if in.Listen == "" { + in.Listen = ":3000" + } + c.log.Infof("connecting to database: %s", c.env.Database.URL) + c.log.Infof("starting the web server on: http://localhost%s", in.Listen) + return c.server.Listen(ctx, in.Listen) +} diff --git a/example/zero/controller/posts/controller.go b/example/zero/controller/posts/controller.go new file mode 100644 index 00000000..5aeed575 --- /dev/null +++ b/example/zero/controller/posts/controller.go @@ -0,0 +1,23 @@ +package posts + +import ( + "fmt" + + "github.com/livebud/bud/example/zero/session" +) + +type Controller struct { +} + +type IndexContext struct { + Session *session.Session +} + +func (c *Controller) Index(ctx *IndexContext) (string, error) { + fmt.Println("got session user id", ctx.Session.UserID) + fmt.Println("is logged in?", !ctx.Session.Visitor()) + id := 10 + ctx.Session.UserID = &id + // fmt.Println("got session", ctx.Session) + return "Welcome to my blog!", nil +} diff --git a/example/zero/controller/sessions/controller.go b/example/zero/controller/sessions/controller.go new file mode 100644 index 00000000..33d89fce --- /dev/null +++ b/example/zero/controller/sessions/controller.go @@ -0,0 +1,8 @@ +package sessions + +type Controller struct { +} + +func (c *Controller) New() string { + return "new session" +} diff --git a/example/zero/controller/users/controller.go b/example/zero/controller/users/controller.go new file mode 100644 index 00000000..7c8a6411 --- /dev/null +++ b/example/zero/controller/users/controller.go @@ -0,0 +1,17 @@ +package users + +import ( + "github.com/livebud/bud/example/zero/env" +) + +type Controller struct { + Env *env.Env +} + +func (c *Controller) Index() string { + return "user index" +} + +func (c *Controller) New() string { + return "new user form" +} diff --git a/example/zero/env/env.go b/example/zero/env/env.go new file mode 100644 index 00000000..b3213f41 --- /dev/null +++ b/example/zero/env/env.go @@ -0,0 +1,19 @@ +package env + +type Env struct { + Database Database + CSRF CSRF + Session Session +} + +type Database struct { + URL string `env:"DATABASE_URL" envDefault:"postgres://localhost:5432/zero?sslmode=disable"` +} + +type CSRF struct { + Token string `env:"CSRF_TOKEN"` +} + +type Session struct { + Key string `env:"SESSION_KEY"` +} diff --git a/example/zero/generator/app/generator.go b/example/zero/generator/app/generator.go new file mode 100644 index 00000000..7572c7fa --- /dev/null +++ b/example/zero/generator/app/generator.go @@ -0,0 +1,127 @@ +package app + +import ( + "context" + "errors" + "os" + + "github.com/livebud/bud/example/zero/generator/command" + "github.com/livebud/bud/framework" + "github.com/livebud/bud/package/di" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/package/log/console" + "github.com/livebud/bud/runtime/generator" +) + +func New(flag *framework.Flag, injector *di.Injector, module *gomod.Module) *Generator { + return &Generator{flag, injector, module} +} + +type Generator struct { + flag *framework.Flag + injector *di.Injector + module *gomod.Module +} + +const appTemplate = `package main + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func main() { + app.Main({{ $.Provider.Name }}) +} + +{{ $.Provider.Function }} +` + +var appGen = gotemplate.MustParse("app.gotext", appTemplate) + +func (g *Generator) Extend(gen generator.FileSystem) { + // TODO: should bud/ be implied? I don't think we should sync non-bud/ + // directories, it's too risky. + gen.GenerateFile("bud/cmd/app/main.go", g.generateFile) +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + type State struct { + Imports []*imports.Import + Provider *di.Provider + } + imset := imports.New() + imset.Add(g.module.Import("generator/app")) + aliases := di.Aliases{ + di.ToType(g.module.Import("env"), "*Env"): di.ToType(g.module.Import("bud/internal/env"), "*Env"), + } + if !g.flag.Embed { + aliases[di.ToType(g.module.Import("bud/pkg/web/view"), "FS")] = di.ToType("github.com/livebud/bud/package/gomod", "*Module") + } + + provider, err := g.injector.Wire(&di.Function{ + Name: "loadCLI", + Imports: imset, + Params: []*di.Param{ + &di.Param{ + Import: "github.com/livebud/bud/package/log", + Type: "Log", + }, + &di.Param{ + Import: "github.com/livebud/bud/package/gomod", + Type: "*Module", + }, + }, + Aliases: aliases, + Results: []di.Dependency{ + di.ToType(g.module.Import("bud/internal/command"), "*CLI"), + &di.Error{}, + }, + }) + if err != nil { + return err + } + code, err := appGen.Generate(State{ + Imports: imset.List(), + Provider: provider, + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +///////////////////////////////////////// +// Runtime from the code generated above +///////////////////////////////////////// + +type loadCLI func(module *gomod.Module, log log.Log) (*command.CLI, error) + +func Main(loadCLI loadCLI) { + log := log.New(console.New(os.Stderr)) + if err := run(log, loadCLI); err != nil && !errors.Is(err, context.Canceled) { + log.Error(err) + os.Exit(1) + } +} + +func run(log log.Log, loadCLI loadCLI) error { + module, err := gomod.Find(".") + if err != nil { + return err + } + cli, err := loadCLI(module, log) + if err != nil { + return err + } + ctx := context.Background() + return cli.Parse(ctx, os.Args[1:]...) +} diff --git a/example/zero/generator/command/generator.go b/example/zero/generator/command/generator.go new file mode 100644 index 00000000..8f799b4a --- /dev/null +++ b/example/zero/generator/command/generator.go @@ -0,0 +1,117 @@ +package command + +import ( + "context" + + "github.com/livebud/bud/package/commander" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/package/log" + "github.com/livebud/bud/runtime/generator" + "golang.org/x/sync/errgroup" +) + +func NewGenerator(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +const template = `package command + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func New( + log log.Log, + webCmd *web.Command, +) *CLI { + cli := command.New("app") + webIn := new(web.Serve) + cli.Run(func(ctx context.Context) error { + return command.Go(ctx, log, + func(ctx context.Context) error { return webCmd.GoServe(ctx, webIn) }, + ) + }) + + { // web + + { // web:serve + cmd := cli.Command("web:serve", "serve web requests") + in := new(web.Serve) + cmd.Run(func(ctx context.Context) error { + return webCmd.GoServe(ctx, in) + }) + } + } + + return cli +} + +type CLI = command.CLI +` + +var gen = gotemplate.MustParse("command.gotext", template) + +func (g *Generator) Extend(gen generator.FileSystem) { + // TODO: should bud/ be implied? I don't think we should sync non-bud/ + // directories, it's too risky. + gen.GenerateFile("bud/internal/command/command.go", g.generateFile) +} + +type Routine struct{} + +type State struct { + Imports []*imports.Import + Name string +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("context") + imset.Add("github.com/livebud/bud/package/log") + // imset.Add("golang.org/x/sync/errgroup") + imset.Add(g.module.Import("generator/command")) + // TODO: move to State + imset.Add(g.module.Import("command/web")) + code, err := gen.Generate(&State{ + Imports: imset.List(), + Name: "app", + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +//////////////////////////////////////////////// +// Runtime from the code generated above +//////////////////////////////////////////////// + +func New(name string) *CLI { + return commander.New(name, "zero app") +} + +// Go starts a group of commands in goroutines and waits for them to finish +func Go(ctx context.Context, log log.Log, fns ...func(context.Context) error) error { + eg, ctx := errgroup.WithContext(ctx) + for _, fn := range fns { + fn := fn + eg.Go(func() error { + return fn(ctx) + }) + } + return eg.Wait() +} + +type CLI = commander.CLI diff --git a/example/zero/generator/controller/generator.go b/example/zero/generator/controller/generator.go new file mode 100644 index 00000000..53f93eda --- /dev/null +++ b/example/zero/generator/controller/generator.go @@ -0,0 +1,226 @@ +package controller + +import ( + "github.com/livebud/bud/package/di" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(injector *di.Injector, module *gomod.Module) *Generator { + return &Generator{injector, module} +} + +type Generator struct { + injector *di.Injector + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/controller/controller.go", g.generateFile) +} + +const template = `package controller + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{ $import.Name }} "{{ $import.Path }}" + {{- end }} +) +{{- end }} + +func New( + view *view.View, + posts *posts.Controller, + sessions *sessions.Controller, + users *users.Controller, +) *Controller { + return &Controller{ + &PostsController{ + &PostsIndexAction{posts, view}, + }, + &SessionsController{ + &SessionsNewAction{sessions, view}, + }, + &UsersController{ + &UsersIndexAction{users, view}, + &UsersNewAction{users, view}, + }, + } +} + +type Controller struct { + Posts *PostsController + Sessions *SessionsController + Users *UsersController +} + +// TODO: use a router.Router interface +func (c *Controller) Mount(r *router.Router) error { + c.Posts.Mount(r) + c.Sessions.Mount(r) + c.Users.Mount(r) + return nil +} + +type PostsController struct { + Index *PostsIndexAction +} + +func (c *PostsController) Mount(r *router.Router) error { + r.Get("/posts", c.Index) + return nil +} + +type PostsIndexAction struct { + controller *posts.Controller + view *view.View +} + +func (a *PostsIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + propMap := map[string]interface{}{} + indexContext, err := {{ $.PostIndexProvider.Name}}( + {{- if $.PostIndexProvider.Variable "context.Context" }}r.Context(),{{ end }} + {{- if $.PostIndexProvider.Variable "net/http.*Request" }}r,{{ end }} + {{- if $.PostIndexProvider.Variable "net/http.ResponseWriter" }}w,{{ end }} + ) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + res, err := a.controller.Index(indexContext) + if err != nil { + html := a.view.RenderError(ctx, "posts/index", propMap, err) + w.WriteHeader(500) + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) + return + } + propMap["posts/index"] = res + html, err := a.view.Render(ctx, "posts/index", propMap) + if err != nil { + html = a.view.RenderError(ctx, "posts/index", propMap, err) + } + w.Header().Add("Content-Type", "text/html") + w.Write([]byte(html)) +} + +type SessionsController struct { + New *SessionsNewAction +} + +func (c *SessionsController) Mount(r *router.Router) error { + r.Get("/sessions/new", c.New) + return nil +} + +type SessionsNewAction struct { + controller *sessions.Controller + view *view.View +} + +func (a *SessionsNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +type UsersController struct { + Index *UsersIndexAction + New *UsersNewAction +} + +func (c *UsersController) Mount(r *router.Router) error { + r.Get("/users/new", c.New) + r.Get("/users", c.Index) + return nil +} + +type UsersIndexAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersIndexAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.Index() + w.Write([]byte(res)) +} + +type UsersNewAction struct { + controller *users.Controller + view *view.View +} + +func (a *UsersNewAction) ServeHTTP(w http.ResponseWriter, r *http.Request) { + res := a.controller.New() + w.Write([]byte(res)) +} + +{{ $.PostIndexProvider.Function }} +` + +var gen = gotemplate.MustParse("controller.gotext", template) + +type State struct { + Imports []*imports.Import + PostIndexProvider *di.Provider +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("net/http") + imset.AddNamed("router", "github.com/livebud/bud/package/router") + imset.AddNamed("posts", g.module.Import("controller/posts")) + imset.AddNamed("users", g.module.Import("controller/users")) + imset.AddNamed("sessions", g.module.Import("controller/sessions")) + imset.AddNamed("view", g.module.Import("bud/pkg/web/view")) + + provider, err := g.injector.Wire(&di.Function{ + Name: "loadPostsIndexContext", + Hoist: true, + Imports: imset, + Target: g.module.Import("bud/pkg/web/controller"), + Params: []*di.Param{ + { + Import: "net/http", + Type: "*Request", + }, + { + Import: "net/http", + Type: "ResponseWriter", + }, + { + Import: "context", + Type: "Context", + }, + }, + Aliases: di.Aliases{ + di.ToType(g.module.Import("env"), "*Env"): di.ToType(g.module.Import("bud/internal/env"), "*Env"), + di.ToType(g.module.Import("session"), "*Session"): di.ToType(g.module.Import("bud/pkg/sessions"), "*Session"), + }, + Results: []di.Dependency{ + di.ToType(g.module.Import("controller/posts"), "*IndexContext"), + &di.Error{}, + }, + }) + if err != nil { + return err + } + // fmt.Println(provider.Function()) + + code, err := gen.Generate(&State{ + Imports: imset.List(), + PostIndexProvider: provider, + }) + if err != nil { + return err + } + file.Data = code + return nil +} diff --git a/example/zero/generator/env/generator.go b/example/zero/generator/env/generator.go new file mode 100644 index 00000000..b6ecc8e6 --- /dev/null +++ b/example/zero/generator/env/generator.go @@ -0,0 +1,74 @@ +package env + +import ( + "github.com/caarlos0/env/v7" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/internal/env/env.go", g.generateFile) +} + +const template = `package env + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func Load() (*Env, error) { + var e Env + // TODO: do this statically instead of using reflect + if err := runenv.Parse(&e); err != nil { + return nil, err + } + return &e, nil +} + +type Env = env.Env +` + +var gen = gotemplate.MustParse("env.gotext", template) + +type State struct { + Imports []*imports.Import +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddNamed("env", g.module.Import("env")) + imset.AddNamed("runenv", g.module.Import("generator/env")) + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +////////////////////////////////// +// RUNTIME +////////////////////////////////// + +func Parse(e interface{}) error { + return env.Parse(e) +} diff --git a/example/zero/generator/middleware/generator.go b/example/zero/generator/middleware/generator.go new file mode 100644 index 00000000..8fcffc20 --- /dev/null +++ b/example/zero/generator/middleware/generator.go @@ -0,0 +1,99 @@ +package middleware + +import ( + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/middleware/middleware.go", g.generateFile) +} + +const template = `package middleware + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +type Interface = middleware.Middleware +type Stack = middleware.Stack + +func New( + csrf *csrf.Middleware, + // session *session.Middleware, + wraprw *wraprw.Middleware, +) *Middleware { + return &Middleware{ + csrf, + // session, + wraprw, + Stack{ + wraprw, + csrf, + // session, + }, + } +} + +type Middleware struct { + CSRF *csrf.Middleware + // Session *session.Middleware + WrapRW *wraprw.Middleware + stack Stack +} + +var _ Interface = (*Middleware)(nil) + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return m.stack.Middleware(next) +} +` + +var gen = gotemplate.MustParse("middleware.gotext", template) + +type State struct { + Imports []*imports.Import + Middlewares []*Middleware +} + +type Middleware struct { +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("net/http") + imset.AddNamed("middleware", "github.com/livebud/bud/package/middleware") + // TODO: generate these + imset.AddNamed("wraprw", g.module.Import("middleware/wraprw")) + imset.AddNamed("csrf", g.module.Import("middleware/csrf")) + // imset.AddNamed("session", g.module.Import("middleware/session")) + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +////////////////////////////////// +// RUNTIME +////////////////////////////////// diff --git a/example/zero/generator/session/generator.go b/example/zero/generator/session/generator.go new file mode 100644 index 00000000..b2e768c6 --- /dev/null +++ b/example/zero/generator/session/generator.go @@ -0,0 +1,182 @@ +package env + +import ( + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/sessions/sessions.go", g.generateFile) +} + +const template = `package sessions + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func New(secret string) *Middleware { + // cipher := secretbox.New([32]byte{ + // 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + // 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + // 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + // 0x13, 0x89, + // }) + // cs := cookies.Secure(cipher) + return &Middleware{ + Cookies: cookies.Default(), + Store: nil, + } +} + +type Middleware struct { + Cookies cookies.Store + Store budsession.Store // If nil, we use the cookie store +} + +const sessionID = "sid" + +type contextKey string + +var sessionKey = contextKey("session") + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + cipher := secretbox.New([32]byte{ + 0xf5, 0xaf, 0xe2, 0xcb, 0x87, 0xfb, 0x59, 0x65, 0x3d, 0xff, + 0x43, 0x56, 0x19, 0x4a, 0x22, 0x64, 0x91, 0x4a, 0x28, 0xa0, + 0x4a, 0x06, 0xb8, 0x21, 0x29, 0x42, 0xb4, 0x44, 0x55, 0xd1, + 0x13, 0x89, + }) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + store := m.Store + if store == nil { + store = budsession.New(cookies.Secure(cipher), w, r) + } + + cookie, err := m.Cookies.Get(r, sessionID) + if err != nil { + if !errors.Is(err, http.ErrNoCookie) { + fmt.Println("cookie error, showing unauthorized", err) + http.Error(w, "cookie get error", http.StatusUnauthorized) + return + } + cookie = &http.Cookie{ + Name: sessionID, + Value: "a123", + Path: "/", + HttpOnly: true, + MaxAge: 86400, // 1 day + Expires: time.Now().Add(86400 * time.Second), + } + } + + var session Session + if sessionData, err := store.Get(cookie.Value); err != nil { + if !errors.Is(err, budsession.ErrNotFound) { + fmt.Println("session get error, showing unauthorized", err) + http.Error(w, "session get error", http.StatusUnauthorized) + return + } + } else if sessionData != nil { + fmt.Println("GOT SESSION DATA", string(sessionData)) + if err := json.NewDecoder(bytes.NewReader(sessionData)).Decode(&session); err != nil { + fmt.Println("session decode error, showing unauthorized", err) + http.Error(w, "session decode error", http.StatusUnauthorized) + return + } + } + + ctx := context.WithValue(r.Context(), sessionKey, &session) + r = r.WithContext(ctx) + + fmt.Println("mw: session before", session.UserID) + next.ServeHTTP(w, r) + + payload := new(bytes.Buffer) + if err := json.NewEncoder(payload).Encode(session); err != nil { + fmt.Println("session encode error, showing unauthorized", err) + http.Error(w, "session encode error", http.StatusUnauthorized) + return + } + fmt.Println(payload.String()) + + if err := store.Set(cookie.Value, payload.Bytes(), time.Now()); err != nil { + fmt.Println("session set error, showing unauthorized", err) + http.Error(w, "session set error", http.StatusUnauthorized) + return + } + + if err := m.Cookies.Set(w, cookie); err != nil { + fmt.Println("cookie set error, showing unauthorized", err) + http.Error(w, "cookie set error", http.StatusUnauthorized) + return + } + }) +} + +func isDirty(session *Session) bool { + return true +} + +func From(ctx context.Context) (*Session, error) { + session, ok := ctx.Value(sessionKey).(*Session) + if !ok { + return nil, fmt.Errorf("no session in context") + } + return session, nil +} + +type Session = session.Session +` + +var gen = gotemplate.MustParse("session.gotext", template) + +type State struct { + Imports []*imports.Import +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("net/http", "context", "time", "errors", "bytes", "encoding/json", "fmt") + // imset.AddStd("fmt") + imset.AddNamed("session", g.module.Import("session")) + imset.AddNamed("cookies", "github.com/livebud/bud/package/cookies") + imset.AddNamed("budsession", "github.com/livebud/bud/package/session") + imset.AddNamed("secretbox", "github.com/livebud/bud/package/cipher/secretbox") + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +////////////////////////////////// +// RUNTIME +////////////////////////////////// + +type Store interface { +} + +// func Parse(e interface{}) error { +// return env.Parse(e) +// } diff --git a/example/zero/generator/view/generator.go b/example/zero/generator/view/generator.go new file mode 100644 index 00000000..a6f23ea9 --- /dev/null +++ b/example/zero/generator/view/generator.go @@ -0,0 +1,287 @@ +package view + +import ( + "context" + "io/fs" + + "github.com/livebud/bud/example/zero/bud/pkg/viewer" + "github.com/livebud/bud/framework" + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" + "github.com/livebud/bud/runtime/view" +) + +func New(flag *framework.Flag, module *gomod.Module, viewer viewer.Viewer) *Generator { + return &Generator{flag, module, viewer} +} + +type Generator struct { + flag *framework.Flag + module *gomod.Module + viewer viewer.Viewer +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/view/view.go", g.generateFile) +} + +const template = `package view + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{ $import.Name }} "{{ $import.Path }}" + {{- end }} +) +{{- end }} + +func New( + module *gomod.Module, + viewer viewer.Viewer, +) *View { + {{ if $.Flag.Embed }} + fsys := embeddedFS + {{ else }} + fsys := module + {{ end }} + + // TODO: generate with Pages instead + postsIndexPage := &PostsIndexPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/index", + Path: "posts/index.gohtml", + Ext: ".gohtml", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + postsIntroPage := &PostsIntroPage{ + fsys, + viewer, + &view.Page{ + View: &view.View{ + Key: "posts/intro", + Path: "posts/intro.md", + Ext: ".md", + }, + Frames: []*view.View{ + { + Key: "posts/frame", + Path: "posts/frame.gohtml", + Ext: ".gohtml", + }, + }, + Layout: &view.View{ + Key: "layout", + Path: "layout.gohtml", + Ext: ".gohtml", + }, + Error: &view.View{ + Key: "error", + Path: "error.gohtml", + Ext: ".gohtml", + }, + }, + } + + return &View{ + map[string]renderer{ + "posts/index": postsIndexPage, + "posts/intro": postsIntroPage, + }, + &PostsView{ + postsIndexPage, + postsIntroPage, + }, + } +} + +var embeddedFS = virtual.List{ + {{- range $embed := $.Embeds }} + &virtual.File{ + Path: "{{ $embed.Path }}", + Data: []byte("{{ $embed.Embed }}"), + }, + {{- end }} +} + +type renderer interface { + Render(ctx context.Context, propMap view.PropMap) ([]byte, error) + RenderError(ctx context.Context, propMap view.PropMap, err error) []byte +} + +type View struct { + pages map[string]renderer + Posts *PostsView +} + +var _ view.Interface = (*View)(nil) + +// TODO: use a router.Router interface +func (v *View) Mount(r *router.Router) error { + return nil +} + +func (v *View) Render(ctx context.Context, key string, propMap view.PropMap) ([]byte, error) { + page, ok := v.pages[key] + if !ok { + return nil, fmt.Errorf("generator/view: no page for key %s", key) + } + return page.Render(ctx, propMap) +} + +func (v *View) RenderError(ctx context.Context, key string, propMap view.PropMap, err error) []byte { + page, ok := v.pages[key] + if !ok { + return []byte(fmt.Sprintf("no page %q to render error: %s", key, err)) + } + return page.RenderError(ctx, propMap, err) +} + +type PostsView struct { + Index *PostsIndexPage + Intro *PostsIntroPage +} + +func (p *PostsView) Mount(r *router.Router) error { + r.Mount(p.Index) + r.Mount(p.Intro) + return nil +} + +type PostsIndexPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIndexPage) Mount(r *router.Router) error { + r.Get("/posts", p) + return nil +} + +func (p *PostsIndexPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIndexPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIndexPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} + +type PostsIntroPage struct { + fsys fs.FS + viewer view.Viewer + page *view.Page +} + +func (p *PostsIntroPage) Mount(r *router.Router) error { + r.Get("/posts/intro", p) + return nil +} + +// TODO: consolidate +func (p *PostsIntroPage) ServeHTTP(w http.ResponseWriter, r *http.Request) { + propMap := map[string]interface{}{ + // TODO: static props? + p.page.Key: nil, + } + html, err := p.Render(r.Context(), propMap) + if err != nil { + html = p.RenderError(r.Context(), propMap, err) + } + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/html") + w.Write(html) +} + +func (p *PostsIntroPage) Render(ctx context.Context, propMap view.PropMap) ([]byte, error) { + return p.viewer.Render(ctx, p.fsys, p.page, propMap) +} + +func (p *PostsIntroPage) RenderError(ctx context.Context, propMap view.PropMap, err error) []byte { + return p.viewer.RenderError(ctx, p.fsys, p.page, propMap, err) +} +` + +var gen = gotemplate.MustParse("view.gotext", template) + +type State struct { + Flag *framework.Flag + Imports []*imports.Import + Pages map[string]*view.Page + Embeds view.Embeds +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + ctx := context.TODO() + imset := imports.New() + imset.AddStd("context", "fmt", "net/http", "io/fs") + imset.AddNamed("gomod", "github.com/livebud/bud/package/gomod") + imset.AddNamed("view", "github.com/livebud/bud/runtime/view") + imset.AddNamed("router", "github.com/livebud/bud/package/router") + imset.AddNamed("viewer", g.module.Import("bud/pkg/viewer")) + imset.AddNamed("virtual", "github.com/livebud/bud/package/virtual") + viewFS, err := fs.Sub(fsys, "view") + if err != nil { + return err + } + pages, err := view.Find(viewFS) + if err != nil { + return err + } + embeds := view.Embeds{} + if g.flag.Embed { + // TODO: decide if we want to scope to the view path or module path + if err := g.viewer.Bundle(ctx, fsys, pages, embeds); err != nil { + return err + } + } + code, err := gen.Generate(&State{ + Flag: g.flag, + Imports: imset.List(), + Pages: pages, + Embeds: embeds, + }) + if err != nil { + return err + } + file.Data = code + return nil +} diff --git a/example/zero/generator/web/generator.go b/example/zero/generator/web/generator.go new file mode 100644 index 00000000..d567c4aa --- /dev/null +++ b/example/zero/generator/web/generator.go @@ -0,0 +1,126 @@ +package web + +import ( + "github.com/livebud/bud/package/gomod" + "github.com/livebud/bud/package/gotemplate" + "github.com/livebud/bud/package/imports" + "github.com/livebud/bud/runtime/generator" +) + +func New(module *gomod.Module) *Generator { + return &Generator{module} +} + +type Generator struct { + module *gomod.Module +} + +func (g *Generator) Extend(gen generator.FileSystem) { + gen.GenerateFile("bud/pkg/web/web.go", g.generateFile) +} + +const template = `package web + +// Code generated by bud; DO NOT EDIT. + +{{- if $.Imports }} + +import ( + {{- range $import := $.Imports }} + {{$import.Name}} "{{$import.Path}}" + {{- end }} +) +{{- end }} + +func New(web *web.Web) *Server { + middleware := web.Stack(middleware.Stack{}) + router := web.Router(router.New()) + handler := web.Handler(middleware.Middleware(router)) + server := web.Server(&http.Server{ + Handler: handler, + }) + return &Server{server} +} + +type Server struct { + server *http.Server +} + +func (s *Server) Listen(ctx context.Context, addr string) error { + ln, err := socket.Listen(addr) + if err != nil { + return err + } + defer ln.Close() + return s.Serve(ctx, ln) +} + +func (s *Server) Serve(ctx context.Context, ln net.Listener) error { + eg := new(errgroup.Group) + eg.Go(func() error { + return s.server.Serve(ln) + }) + eg.Go(func() error { + <-ctx.Done() + // TODO: wait for another ctrl+c to force quit + return s.server.Shutdown(context.Background()) + }) + if err := eg.Wait(); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} +` + +var gen = gotemplate.MustParse("web.gotext", template) + +type State struct { + Imports []*imports.Import +} + +func (g *Generator) generateFile(fsys generator.FS, file *generator.File) error { + imset := imports.New() + imset.AddStd("context", "net/http", "net", "errors") + imset.AddNamed("errgroup", "golang.org/x/sync/errgroup") + imset.AddNamed("web", g.module.Import("web")) + imset.AddNamed("router", "github.com/livebud/bud/package/router") + imset.AddNamed("socket", "github.com/livebud/bud/package/socket") + imset.AddNamed("middleware", "github.com/livebud/bud/package/middleware") + + code, err := gen.Generate(&State{ + Imports: imset.List(), + }) + if err != nil { + return err + } + file.Data = code + return nil +} + +///////////////////////////// +// Runtime +///////////////////////////// + +// type Server interface { +// Listen(ctx context.Context, addr string) error +// Serve(ctx context.Context, ln net.Listener) error +// } + +// TODO: move this into *router.Router +// type Router interface { +// http.Handler +// middleware.Middleware +// Set(method string, route string, handler http.Handler) error +// Get(route string, handler http.Handler) error +// Post(route string, handler http.Handler) error +// Put(route string, handler http.Handler) error +// Patch(route string, handler http.Handler) error +// Delete(route string, handler http.Handler) error +// Mount(m Mount) error +// } + +// type Mount interface { +// Mount(router Router) error +// } + +// type Middlewares = middleware.Stack diff --git a/example/zero/go.mod b/example/zero/go.mod new file mode 100644 index 00000000..3f0b6aae --- /dev/null +++ b/example/zero/go.mod @@ -0,0 +1,33 @@ +module github.com/livebud/bud/example/zero + +go 1.19 + +replace github.com/livebud/bud => ../.. + +require ( + github.com/caarlos0/env/v7 v7.1.0 + github.com/felixge/httpsnoop v1.0.3 + github.com/livebud/bud v0.0.0-00010101000000-000000000000 + github.com/matryer/is v1.4.0 + github.com/yuin/goldmark v1.5.4 + golang.org/x/sync v0.1.0 +) + +require ( + github.com/RyanCarrier/dijkstra v1.1.0 // indirect + github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 // indirect + github.com/cespare/xxhash v1.1.0 // indirect + github.com/evanw/esbuild v0.14.11 // indirect + github.com/fatih/structtag v1.2.0 // indirect + github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813 // indirect + github.com/go-logfmt/logfmt v0.5.1 // indirect + github.com/gobwas/glob v0.2.3 // indirect + github.com/livebud/transpiler v0.0.3 // indirect + github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac // indirect + github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8 // indirect + github.com/mattn/go-sqlite3 v1.14.16 // indirect + github.com/xlab/treeprint v1.1.0 // indirect + golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect + golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect +) diff --git a/example/zero/go.sum b/example/zero/go.sum new file mode 100644 index 00000000..1e098f94 --- /dev/null +++ b/example/zero/go.sum @@ -0,0 +1,83 @@ +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/RyanCarrier/dijkstra v1.1.0 h1:/NDihjfJA3CxFaZz8EdzTwdFKFZDvvB881OVLdakRcI= +github.com/RyanCarrier/dijkstra v1.1.0/go.mod h1:5agGUBNEtUAGIANmbw09fuO3a2htPEkc1jNH01qxCWA= +github.com/RyanCarrier/dijkstra-1 v0.0.0-20170512020943-0e5801a26345 h1:fgSpoKViTSqRb4hjDNj10ig5wUvO0CayCzFdLf6fuRM= +github.com/RyanCarrier/dijkstra-1 v0.0.0-20170512020943-0e5801a26345/go.mod h1:OK4EvWJ441LQqGzed5NGB6vKBAE34n3z7iayPcEwr30= +github.com/albertorestifo/dijkstra v0.0.0-20160910063646-aba76f725f72 h1:uGeGZl8PxSq8VZGG4QK5njJTFA4/G/x5CYORvQVXtAE= +github.com/albertorestifo/dijkstra v0.0.0-20160910063646-aba76f725f72/go.mod h1:o+JdB7VetTHjLhU0N57x18B9voDBQe0paApdEAEoEfw= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59 h1:WWB576BN5zNSZc/M9d/10pqEx5VHNhaQ/yOVAkmj5Yo= +github.com/aybabtme/rgbterm v0.0.0-20170906152045-cc83f3b3ce59/go.mod h1:q/89r3U2H7sSsE2t6Kca0lfwTK8JdoNGS/yzM/4iH5I= +github.com/caarlos0/env/v7 v7.1.0 h1:9lzTF5amyQeWHZzuZeKlCb5FWSUxpG1js43mhbY8ozg= +github.com/caarlos0/env/v7 v7.1.0/go.mod h1:LPPWniDUq4JaO6Q41vtlyikhMknqymCLBw0eX4dcH1E= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/evanw/esbuild v0.14.11 h1:bw50N4v70Dqf/B6Wn+3BM6BVttz4A6tHn8m8Ydj9vxk= +github.com/evanw/esbuild v0.14.11/go.mod h1:GG+zjdi59yh3ehDn4ZWfPcATxjPDUH53iU4ZJbp7dkY= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= +github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813 h1:Uc+IZ7gYqAf/rSGFplbWBSHaGolEQlNLgMgSE3ccnIQ= +github.com/gedex/inflector v0.0.0-20170307190818-16278e9db813/go.mod h1:P+oSoE9yhSRvsmYyZsshflcR6ePWYLql6UU1amW13IM= +github.com/gitchander/permutation v0.0.0-20201214100618-1f3e7285f953 h1:+rJDfq6waeB1BncyEfuFL1N3U7t3aahrAjPqcKLpMys= +github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA= +github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/keegancsmith/rpc v1.3.0 h1:wGWOpjcNrZaY8GDYZJfvyxmlLljm3YQWF+p918DXtDk= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/livebud/transpiler v0.0.3 h1:OFKPsmfTOywBDoOE/ZFMVjAupk/xVXFlXoAgZNRhh5Q= +github.com/livebud/transpiler v0.0.3/go.mod h1:vQYMN//Y2cnM55tw0lOmLGbEETugP7alxTyhQHzNdTI= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matthewmueller/diff v0.0.0-20220104030700-cb2fe910d90c h1:yjGBNrCIE7IghJAwrFcyDzwzwJKf0oRPeOHx60wfkmA= +github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac h1:SjopLdUF96kdJU8ynYmGVHoJmngpwFHRvR5p2plBXG4= +github.com/matthewmueller/gotext v0.0.0-20210424201144-265ed61725ac/go.mod h1:0mnotoJNdO4NPxld/GcrLppxwGWuMqgroWyu413oOvw= +github.com/matthewmueller/text v0.0.0-20201215225457-a00346c71bb3/go.mod h1:vtPaEU72VzARd4tSSzHIX7DddCEamoO2X4ozlrdmtNY= +github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8 h1:XTmVlF7P9bpSNkLFlxpNlhig0kaVJ5mO4D3yK2CYjmM= +github.com/matthewmueller/text v0.0.0-20210424201111-ec1e4af8dfe8/go.mod h1:vtPaEU72VzARd4tSSzHIX7DddCEamoO2X4ozlrdmtNY= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattomatic/dijkstra v0.0.0-20130617153013-6f6d134eb237 h1:acuCHBjzG7MFTugvx3buC4m5rLDLaKC9J8C9jtlraRc= +github.com/mattomatic/dijkstra v0.0.0-20130617153013-6f6d134eb237/go.mod h1:UOnLAUmVG5paym8pD3C4B9BQylUDC2vXFJJpT7JrlEA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/tj/assert v0.0.3/go.mod h1:Ne6X72Q+TB1AteidzQncjw9PabbMp4PBMZ1k+vd1Pvk= +github.com/xlab/treeprint v1.1.0 h1:G/1DjNkPpfZCFt9CSh6b5/nY4VimlbHF3Rh4obvtzDk= +github.com/xlab/treeprint v1.1.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU= +github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 h1:7I4JAnoQBe7ZtJcBaYHi5UtiO8tQHbUSXxL+pnGRANg= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f h1:OKYpQQVE3DKSc3r3zHVzq46vq5YH7x8xpR3/k9ixmUg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= diff --git a/example/zero/middleware/_session/middleware.go b/example/zero/middleware/_session/middleware.go new file mode 100644 index 00000000..d72ee843 --- /dev/null +++ b/example/zero/middleware/_session/middleware.go @@ -0,0 +1,29 @@ +package session + +import ( + "fmt" + "net/http" + + "github.com/livebud/bud/example/zero/bud/pkg/sessions" + "github.com/livebud/bud/example/zero/env" +) + +type Middleware struct { + Env *env.Env + Sessions *sessions.Store + // Sessions *session.Store + // Store session.Storage +} + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, err := m.Sessions.Load(r, "some_id") + if err != nil { + fmt.Println("error loading session", err) + } + fmt.Println("loaded session", session) + next.ServeHTTP(w, r) + // Save the session + fmt.Println("saving session", m.Env.Session.Key) + }) +} diff --git a/example/zero/middleware/csrf/middleware.go b/example/zero/middleware/csrf/middleware.go new file mode 100644 index 00000000..d303d79f --- /dev/null +++ b/example/zero/middleware/csrf/middleware.go @@ -0,0 +1,19 @@ +package csrf + +import ( + "fmt" + "net/http" + + "github.com/livebud/bud/example/zero/env" +) + +type Middleware struct { + Env *env.Env +} + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Println("checking csrf", m.Env.CSRF.Token) + next.ServeHTTP(w, r) + }) +} diff --git a/example/zero/middleware/wraprw/middleware.go b/example/zero/middleware/wraprw/middleware.go new file mode 100644 index 00000000..75da605a --- /dev/null +++ b/example/zero/middleware/wraprw/middleware.go @@ -0,0 +1,62 @@ +package wraprw + +import ( + "bytes" + "net/http" + + "github.com/felixge/httpsnoop" +) + +type Middleware struct { +} + +func (m *Middleware) Middleware(next http.Handler) http.Handler { + rw := &responseWriter{ + code: 0, + body: new(bytes.Buffer), + } + return http.HandlerFunc(func(original http.ResponseWriter, r *http.Request) { + w := httpsnoop.Wrap(original, httpsnoop.Hooks{ + WriteHeader: func(_ httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc { + return rw.WriteHeader + }, + Write: func(_ httpsnoop.WriteFunc) httpsnoop.WriteFunc { + return rw.Write + }, + Flush: func(flush httpsnoop.FlushFunc) httpsnoop.FlushFunc { + rw.writeTo(original) + return flush + }, + }) + next.ServeHTTP(w, r) + rw.writeTo(original) + }) +} + +type responseWriter struct { + body *bytes.Buffer + code int + wrote bool +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.code = statusCode +} + +func (rw *responseWriter) Write(b []byte) (int, error) { + return rw.body.Write(b) +} + +func (rw *responseWriter) writeTo(w http.ResponseWriter) { + // Only write status code once to avoid: + // "http: superfluous response.WriteHeader" + // Not concurrency safe. + if !rw.wrote { + if rw.code == 0 { + rw.code = http.StatusOK + } + w.WriteHeader(rw.code) + rw.wrote = true + } + rw.body.WriteTo(w) +} diff --git a/example/zero/middleware/wraprw/middleware_test.go b/example/zero/middleware/wraprw/middleware_test.go new file mode 100644 index 00000000..2c840b15 --- /dev/null +++ b/example/zero/middleware/wraprw/middleware_test.go @@ -0,0 +1,170 @@ +package wraprw_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/livebud/bud/example/zero/middleware/wraprw" + "github.com/matryer/is" +) + +func TestHeadersNormal(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + }) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 200) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") +} + +func TestHeadersWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 200) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "B") +} + +func TestWriteStatusNormal(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.WriteHeader(201) + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + }) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!") +} + +func TestWriteStatusWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.WriteHeader(201) + w.Write([]byte("Hello, world!")) + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "B") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!") +} + +func TestFlushNormal(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("X-A", "A") + w.WriteHeader(201) + w.Write([]byte("Hello, world!")) + flush, ok := w.(http.Flusher) + if ok { + flush.Flush() + flush.Flush() + } + w.Header().Add("X-B", "B") + }) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!") +} + +func TestFlushWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, world!")) + w.Header().Add("X-A", "A") + flush, ok := w.(http.Flusher) + if ok { + flush.Flush() + w.Write([]byte("yoyo")) + flush.Flush() + } + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 200) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!yoyo") +} + +func TestFlushStatusWrapped(t *testing.T) { + is := is.New(t) + rec := httptest.NewRecorder() + req, err := http.NewRequest("GET", "/", nil) + is.NoErr(err) + wrap := &wraprw.Middleware{} + h := wrap.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("Hello, world!")) + w.WriteHeader(201) + w.Header().Add("X-A", "A") + flush, ok := w.(http.Flusher) + if ok { + flush.Flush() + w.Write([]byte("yoyo")) + flush.Flush() + } + w.Header().Add("X-B", "B") + })) + h.ServeHTTP(rec, req) + res := rec.Result() + is.Equal(res.StatusCode, 201) + is.Equal(res.Header.Get("X-A"), "A") + is.Equal(res.Header.Get("X-B"), "") + body, err := io.ReadAll(res.Body) + is.NoErr(err) + is.Equal(string(body), "Hello, world!yoyo") +} diff --git a/example/zero/mw/mw.go b/example/zero/mw/mw.go new file mode 100644 index 00000000..6aa2ce2d --- /dev/null +++ b/example/zero/mw/mw.go @@ -0,0 +1,30 @@ +package mw + +import ( + "net/http" + + "github.com/livebud/bud/example/zero/env" +) + +type Middleware struct { + *env.Env + // *sessions.Store +} + +// func (m *Middleware) Session(next http.Handler) http.Handler { +// fmt.Println(m.Store) +// // store := sessions.NewCookieStore([]byte(m.Env.Session.Key)) +// // _ = store +// return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +// }) +// } + +func (m *Middleware) CSRF(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) +} + +func (m *Middleware) WrapRW(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) +} diff --git a/example/zero/session/session.go b/example/zero/session/session.go new file mode 100644 index 00000000..17de74f3 --- /dev/null +++ b/example/zero/session/session.go @@ -0,0 +1,28 @@ +package session + +import "time" + +type Session struct { + UserID *int + ID string `session:"id"` + IP string `session:"ip_address"` + UA string `session:"user_agent"` + LastSeen time.Time `session:"last_seen"` + Expires time.Time `session:"expires"` +} + +func (s *Session) Visitor() bool { + return s.UserID == nil +} + +// func LoadUser(db *sql.Database, session *Session) (*User, error) { +// if session.ID == nil { +// return nil, nil +// } +// // hypothetical database call +// return db.FindUserByID(*session.ID) +// } + +// type User struct { +// *table.User +// } diff --git a/example/zero/session/store.go b/example/zero/session/store.go new file mode 100644 index 00000000..3bc8a6e3 --- /dev/null +++ b/example/zero/session/store.go @@ -0,0 +1,31 @@ +package session + +import ( + "net/http" + "time" +) + +// type Storage interface { +// Find(id string) ([]byte, error) +// } + +func New(w http.ResponseWriter, r *http.Request) *Store { + return &Store{w, r} +} + +type Store struct { + w http.ResponseWriter + r *http.Request +} + +func (s *Store) Create(id string, payload []byte, expires time.Time) error { + return nil +} + +func (s *Store) Find(id string) ([]byte, error) { + return nil, nil +} + +func (s *Store) Delete(id string) error { + return nil +} diff --git a/example/zero/transpiler/goldmark/transpiler.go b/example/zero/transpiler/goldmark/transpiler.go new file mode 100644 index 00000000..31332ca2 --- /dev/null +++ b/example/zero/transpiler/goldmark/transpiler.go @@ -0,0 +1,26 @@ +package goldmark + +import ( + "bytes" + "fmt" + + "github.com/livebud/bud/package/log" + transpiler "github.com/livebud/bud/runtime/transpiler2" + "github.com/yuin/goldmark" +) + +// TODO: finish markdown transpiler + +type Transpiler struct { + Log log.Log +} + +func (t *Transpiler) MdToGohtml(file *transpiler.File) error { + t.Log.Info("transpiling:", file.Path()) + var html bytes.Buffer + if err := goldmark.Convert(file.Data, &html); err != nil { + return fmt.Errorf("goldmark markdown error: %w", err) + } + file.Data = html.Bytes() + return nil +} diff --git a/example/zero/transpiler/tailwind/tailwind.go b/example/zero/transpiler/tailwind/tailwind.go new file mode 100644 index 00000000..98017351 --- /dev/null +++ b/example/zero/transpiler/tailwind/tailwind.go @@ -0,0 +1,20 @@ +package tailwind + +import ( + "bytes" + + "github.com/livebud/bud/package/log" + transpiler "github.com/livebud/bud/runtime/transpiler2" +) + +// TODO: switch to markdown. tailwind is more of a custom web generator + +type Transpiler struct { + Log log.Log +} + +func (t *Transpiler) GohtmlToGohtml(file *transpiler.File) error { + t.Log.Info("transpiling", file.Path()) + file.Data = bytes.ReplaceAll(file.Data, []byte("h1"), []byte("h2")) + return nil +} diff --git a/example/zero/view/error.gohtml b/example/zero/view/error.gohtml new file mode 100644 index 00000000..d04292c8 --- /dev/null +++ b/example/zero/view/error.gohtml @@ -0,0 +1 @@ +