-
Notifications
You must be signed in to change notification settings - Fork 1k
feat: nextrouter pkg to handle nextjs routing rules #167
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
Changes from 31 commits
dd6d315
33897e5
b8d092c
9949261
fadc0b6
771f39d
78406b1
50c72ba
7a98a48
1eb2402
b0fd9a8
a506148
b59e2a8
ff75695
a410396
15c38b9
c813b5e
317c040
133e452
6d8f412
a3ecd7f
700c9ef
05a369e
03a20c1
199d468
ca515e4
6072bad
826eaf5
dac34f5
92d4eb0
5262b7f
33342e5
750bec9
c15b046
c17fc15
34e4f46
0743503
4585c13
8f2b47d
19fcfac
8343923
4a5c353
e4d031f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,225 @@ | ||
package nextrouter | ||
|
||
import ( | ||
"bytes" | ||
"context" | ||
"html/template" | ||
"io/fs" | ||
"net/http" | ||
"path/filepath" | ||
"strings" | ||
"time" | ||
|
||
"github.com/go-chi/chi/v5" | ||
|
||
"cdr.dev/slog" | ||
) | ||
|
||
// Options for configuring a nextrouter | ||
type Options struct { | ||
Logger slog.Logger | ||
TemplateDataFunc TemplateDataFunc | ||
} | ||
|
||
// TemplateDataFunc is a function that lets the consumer of `nextrouter` | ||
// inject arbitrary template parameters, based on the request. This is useful | ||
// if the Request object is carrying CSRF tokens, session tokens, etc - | ||
// they can be emitted in the page. | ||
type TemplateDataFunc func(*http.Request) interface{} | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Handler returns an HTTP handler for serving a next-based static site | ||
// This handler respects NextJS-based routing rules: | ||
// https://nextjs.org/docs/routing/dynamic-routes | ||
// | ||
// 1) If a file is of the form `[org]`, it's a dynamic route for a single-parameter | ||
// 2) If a file is of the form `[[...any]]`, it's a dynamic route for any parameters | ||
func Handler(fileSystem fs.FS, options *Options) http.Handler { | ||
if options == nil { | ||
options = &Options{ | ||
Logger: slog.Logger{}, | ||
TemplateDataFunc: nil, | ||
} | ||
} | ||
router := chi.NewRouter() | ||
|
||
// Build up a router that matches NextJS routing rules, for HTML files | ||
buildRouter(router, fileSystem, *options) | ||
|
||
// Fallback to static file server for non-HTML files | ||
// Non-HTML files don't have special routing rules, so we can just leverage | ||
// the built-in http.FileServer for it. | ||
fileHandler := http.FileServer(http.FS(fileSystem)) | ||
router.NotFound(fileHandler.ServeHTTP) | ||
|
||
// Finally, if there is a 404.html available, serve that | ||
serve404IfAvailable(fileSystem, router, *options) | ||
|
||
return router | ||
} | ||
|
||
// buildRouter recursively traverses the file-system, building routes | ||
// as appropriate for respecting NextJS dynamic rules. | ||
func buildRouter(rtr chi.Router, fileSystem fs.FS, options Options) { | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
files, err := fs.ReadDir(fileSystem, ".") | ||
if err != nil { | ||
options.Logger.Warn(context.Background(), "Provided filesystem is empty; unable to build routes") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a warn? I'm not sure empty directories would be of much concern. Maybe change to debug here. |
||
return | ||
} | ||
|
||
// Loop through everything in the current directory... | ||
for _, file := range files { | ||
name := file.Name() | ||
|
||
// ...if it's a directory, create a sub-route by | ||
// recursively calling `buildRouter` | ||
if file.IsDir() { | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
sub, err := fs.Sub(fileSystem, name) | ||
if err != nil { | ||
options.Logger.Error(context.Background(), "Unable to call fs.Sub on directory", slog.F("directory_name", name)) | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
continue | ||
} | ||
|
||
// In the special case where the folder is dynamic, | ||
// like `[org]`, we can convert to a chi-style dynamic route | ||
// (which uses `{` instead of `[`) | ||
routeName := name | ||
if isDynamicRoute(name) { | ||
routeName = "{dynamic}" | ||
} | ||
|
||
options.Logger.Info(context.Background(), "Adding route", slog.F("name", name), slog.F("routeName", routeName)) | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
rtr.Route("/"+routeName, func(r chi.Router) { | ||
buildRouter(r, sub, options) | ||
}) | ||
} else { | ||
// ...otherwise, if it's a file - serve it up! | ||
serveFile(rtr, fileSystem, name, options) | ||
} | ||
} | ||
} | ||
|
||
// serveFile is responsible for serving up HTML files in our next router | ||
// It handles various special cases, like trailing-slashes or handling routes w/o the .html suffix. | ||
func serveFile(router chi.Router, fileSystem fs.FS, fileName string, options Options) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe rename this to It might be helpful to move the |
||
// We only handle .html files for now | ||
ext := filepath.Ext(fileName) | ||
if ext != ".html" { | ||
return | ||
} | ||
|
||
options.Logger.Debug(context.Background(), "Reading file", slog.F("fileName", fileName)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should create a linting rule for this, but all system errors we've used are generally lowercase. |
||
|
||
data, err := fs.ReadFile(fileSystem, fileName) | ||
if err != nil { | ||
options.Logger.Error(context.Background(), "Unable to read file", slog.F("fileName", fileName)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably a fatal error too! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AKA maybe this function should return errors too. |
||
return | ||
} | ||
|
||
// Create a template from the data - we can inject custom parameters like CSRF here | ||
tpls, err := template.New(fileName).Parse(string(data)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like that this is precompiled in memory 👍 |
||
if err != nil { | ||
options.Logger.Error(context.Background(), "Unable to create template for file", slog.F("fileName", fileName)) | ||
return | ||
} | ||
|
||
handler := func(writer http.ResponseWriter, request *http.Request) { | ||
var buf bytes.Buffer | ||
|
||
// See if there are any template parameters we need to inject! | ||
// Things like CSRF tokens, etc... | ||
//templateData := struct{}{} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we structure this data for the user? I'm not sure we need to allow arbitrary injection right now. Having a supported set of exposed injectable values seems reasonable to me! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It can still be dynamic per-request, but let's have something structured instead of |
||
var templateData interface{} | ||
templateData = nil | ||
if options.TemplateDataFunc != nil { | ||
templateData = options.TemplateDataFunc(request) | ||
} | ||
|
||
options.Logger.Debug(context.Background(), "Applying template parameters", slog.F("fileName", fileName), slog.F("templateData", templateData)) | ||
err := tpls.ExecuteTemplate(&buf, fileName, templateData) | ||
|
||
if err != nil { | ||
options.Logger.Error(request.Context(), "Error executing template", slog.F("template_parameters", templateData)) | ||
http.Error(writer, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) | ||
return | ||
} | ||
|
||
http.ServeContent(writer, request, fileName, time.Time{}, bytes.NewReader(buf.Bytes())) | ||
} | ||
|
||
fileNameWithoutExtension := removeFileExtension(fileName) | ||
|
||
// Handle the `[[...any]]` catch-all case | ||
if isCatchAllRoute(fileNameWithoutExtension) { | ||
options.Logger.Info(context.Background(), "Registering catch-all route", slog.F("fileName", fileName)) | ||
router.NotFound(handler) | ||
return | ||
} | ||
|
||
// Handle the `[org]` dynamic route case | ||
if isDynamicRoute(fileNameWithoutExtension) { | ||
options.Logger.Info(context.Background(), "Registering dynamic route", slog.F("fileName", fileName)) | ||
router.Get("/{dynamic}", handler) | ||
return | ||
} | ||
|
||
// Handle the basic file cases | ||
// Directly accessing a file, ie `/providers.html` | ||
router.Get("/"+fileName, handler) | ||
// Accessing a file without an extension, ie `/providers` | ||
router.Get("/"+fileNameWithoutExtension, handler) | ||
|
||
// Special case: '/' should serve index.html | ||
if fileName == "index.html" { | ||
router.Get("/", handler) | ||
} else { | ||
// Otherwise, handling the trailing slash case - | ||
// for examples, `providers.html` should serve `/providers/` | ||
router.Get("/"+fileNameWithoutExtension+"/", handler) | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
} | ||
|
||
func serve404IfAvailable(fileSystem fs.FS, router chi.Router, options Options) { | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
// Get the file contents | ||
fileBytes, err := fs.ReadFile(fileSystem, "404.html") | ||
if err != nil { | ||
// An error is expected if the file doesn't exist | ||
return | ||
} | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
router.NotFound(func(writer http.ResponseWriter, request *http.Request) { | ||
writer.WriteHeader(http.StatusNotFound) | ||
_, err = writer.Write(fileBytes) | ||
if err != nil { | ||
options.Logger.Error(request.Context(), "Unable to write bytes for 404") | ||
return | ||
} | ||
}) | ||
} | ||
|
||
// isDynamicRoute returns true if the file is a NextJS dynamic route, like `[orgs]` | ||
// Returns false if the file is not a dynamic route, or if it is a catch-all route (`[[...any]]`) | ||
func isDynamicRoute(fileName string) bool { | ||
fileWithoutExtension := removeFileExtension(fileName) | ||
|
||
// Assuming ASCII encoding - `len` in go works on bytes | ||
byteLen := len(fileWithoutExtension) | ||
if byteLen < 2 { | ||
return false | ||
} | ||
|
||
return fileWithoutExtension[0] == '[' && fileWithoutExtension[1] != '[' && fileWithoutExtension[byteLen-1] == ']' | ||
} | ||
|
||
// isCatchAllRoute returns true if the file is a catch-all route, like `[[...any]]` | ||
// Return false otherwise | ||
func isCatchAllRoute(fileName string) bool { | ||
fileWithoutExtension := removeFileExtension(fileName) | ||
bryphe-coder marked this conversation as resolved.
Show resolved
Hide resolved
|
||
ret := strings.HasPrefix(fileWithoutExtension, "[[.") | ||
return ret | ||
} | ||
|
||
// removeFileExtension removes the extension from a file | ||
// For example, removeFileExtension("index.html") would return "index" | ||
func removeFileExtension(fileName string) string { | ||
return strings.TrimSuffix(fileName, filepath.Ext(fileName)) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't want this to be confused with our primary app routing logic.
For that reason, what do you think about this being in
site
like it was before? It could even be at the root if you feel that's clean enough.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I'd prefer to keep it in its own package so it's clear that it is stand-alone - how about I move it to
site/nextrouter
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved in 19fcfac
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense to me!