diff --git a/build.go b/build.go index 740206f..2f2e7f7 100644 --- a/build.go +++ b/build.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "path" "sort" "time" @@ -31,13 +32,22 @@ type ( modTime time.Time } + linkHeader struct { + file string + as string + } + // TODO: add all headers including cache-control and // service worker so no regex matching is needed at runtime // PushHeaders are the link headers to send for a route - PushHeaders map[string][]string + PushHeaders map[string][]linkHeader ) +func (l linkHeader) String() string { + return fmt.Sprintf("<%s>; rel=preload; as=%s", l.file, l.as) +} + var files = make(map[string]*file) func loadBuilds(config *ProjectConfig, root http.Dir, routes Routes, version string, createTemplate createTemplateFn) builds { @@ -152,42 +162,61 @@ func newBuild(config *ProjectConfig, configOrder int, name string, requirements pushHeaders := PushHeaders{} prefix := version + name + "/" - for path, fragment := range routes { + for file, assets := range pushManifest { + headers := []linkHeader{} + + for p, asset := range assets { + link := linkHeader{ + file: path.Join(prefix, p), + as: asset.Type, + } + headers = append(headers, link) + } + + pushHeaders[path.Join(prefix, file)] = headers + } + + for route, fragment := range routes { set := map[string]struct{}{} - headers := []string{ - fmt.Sprintf("<%s%s>; rel=preload; as=%s", prefix, "bower_components/webcomponentsjs/webcomponents-loader.js", "script"), - fmt.Sprintf("<%s%s>; rel=preload; as=%s", prefix, config.Shell, "document"), + headers := []linkHeader{ + { + file: path.Join(prefix, "bower_components/webcomponentsjs/webcomponents-loader.js"), + as: "script", + }, + { + file: path.Join(prefix, config.Shell), + as: "document", + }, } - set[headers[0]] = struct{}{} - set[headers[1]] = struct{}{} - for path, asset := range pushManifest[config.Shell] { - link := fmt.Sprintf("<%s%s>; rel=preload; as=%s", prefix, path, asset.Type) - if _, found := set[link]; !found { - set[link] = struct{}{} + set[headers[0].String()] = struct{}{} + set[headers[1].String()] = struct{}{} + for p, asset := range pushManifest[config.Shell] { + link := linkHeader{ + file: path.Join(prefix, p), + as: asset.Type, + } + if _, found := set[link.String()]; !found { + set[link.String()] = struct{}{} headers = append(headers, link) } } - headers = append(headers, fmt.Sprintf("<%s%s>; rel=preload; as=%s", prefix, fragment, "document")) - for path, asset := range pushManifest[fragment] { - link := fmt.Sprintf("<%s%s>; rel=preload; as=%s", prefix, path, asset.Type) - if _, found := set[link]; !found { - set[link] = struct{}{} + headers = append(headers, linkHeader{ + file: path.Join(prefix, fragment), + as: "document", + }) + for p, asset := range pushManifest[fragment] { + link := linkHeader{ + file: path.Join(prefix, p), + as: asset.Type, + } + if _, found := set[link.String()]; !found { + set[link.String()] = struct{}{} headers = append(headers, link) } } - pushHeaders[path] = headers - } - - // update paths to account for the build folder name - manifest := Manifest{} - for path, assets := range pushManifest { - adjusted := make(map[string]AssetOpt, len(assets)) - for assetPath, asset := range assets { - adjusted[prefix+assetPath] = asset - } - manifest[prefix+path] = adjusted + pushHeaders[route] = headers } build := build{ diff --git a/capabilities.go b/capabilities.go index 4330407..dae0b77 100644 --- a/capabilities.go +++ b/capabilities.go @@ -120,7 +120,7 @@ func (c capability) String() string { return strings.Join(val, ", ") } -func (p *prpl) browserCapabilities(userAgentString string) capability { +func (p *PRPL) browserCapabilities(userAgentString string) capability { client := p.parser.Parse(userAgentString) predicate, ok := browserPredicates[client.UserAgent.Family] diff --git a/prpl.go b/prpl.go index 9dac13d..9d12ab5 100644 --- a/prpl.go +++ b/prpl.go @@ -8,7 +8,7 @@ import ( type ( // prpl is an instance of the prpl-server service - prpl struct { + PRPL struct { http.Handler parser *uaparser.Parser config *ProjectConfig @@ -18,15 +18,16 @@ type ( version string staticHandlers map[string]http.Handler createTemplate createTemplateFn + shouldPush func(r *http.Request) bool } // optionFn provides functional option configuration - optionFn func(*prpl) error + optionFn func(*PRPL) error ) // New creates a new prpl instance -func New(options ...optionFn) (*prpl, error) { - p := prpl{ +func New(options ...optionFn) (*PRPL, error) { + p := PRPL{ parser: uaparser.NewFromSaved(), root: http.Dir("."), version: "/static/", @@ -60,7 +61,7 @@ func New(options ...optionFn) (*prpl, error) { // WithVersion sets the version prefix func WithVersion(version string) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { p.version = "/" + version + "/" return nil } @@ -68,7 +69,7 @@ func WithVersion(version string) optionFn { // WithRoutes sets the route -> fragment mapping func WithRoutes(routes Routes) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { p.routes = routes return nil } @@ -76,7 +77,7 @@ func WithRoutes(routes Routes) optionFn { // WithRoot sets the root directory func WithRoot(root http.Dir) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { p.root = root return nil } @@ -84,7 +85,7 @@ func WithRoot(root http.Dir) optionFn { // WithConfig sets the project configuration func WithConfig(config *ProjectConfig) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { p.config = config return nil } @@ -92,7 +93,7 @@ func WithConfig(config *ProjectConfig) optionFn { // WithConfigFile loads the project configuration func WithConfigFile(filename string) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { config, err := ConfigFromFile(filename) if err != nil { return err @@ -105,7 +106,7 @@ func WithConfigFile(filename string) optionFn { // WithUAParserFile allows the uaparser configuration // to be overriden from the inbuilt settings func WithUAParserFile(regexFile string) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { parser, err := uaparser.New(regexFile) if err != nil { return err @@ -118,7 +119,7 @@ func WithUAParserFile(regexFile string) optionFn { // WithUAParserBytes allows the uaparser configuration // to be overriden from the inbuilt settings func WithUAParserBytes(data []byte) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { parser, err := uaparser.NewFromBytes(data) if err != nil { return err @@ -133,7 +134,7 @@ func WithUAParserBytes(data []byte) optionFn { // the manifest.json file per tenant or to serve specific // images based on host headers etc ... func WithStaticHandler(path string, handler http.Handler) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { p.staticHandlers[path] = handler return nil } @@ -143,8 +144,17 @@ func WithStaticHandler(path string, handler http.Handler) optionFn { // into a template so that the output can be transformed if // required func WithRouteTemplate(factory createTemplateFn) optionFn { - return func(p *prpl) error { + return func(p *PRPL) error { p.createTemplate = factory return nil } } + +// WithShouldPush specifies when the server should do a direct HTTP server push +// instead of just setting the server push Link header. +func WithShouldPush(shouldPush func(*http.Request) bool) optionFn { + return func(p *PRPL) error { + p.shouldPush = shouldPush + return nil + } +} diff --git a/server.go b/server.go index e40a41c..1b663a7 100644 --- a/server.go +++ b/server.go @@ -7,7 +7,13 @@ import ( "net/http" ) -func (p *prpl) createHandler() http.Handler { +var ( + CacheImmutable = "public, max-age=31536000, immutable" + CacheNever = "public, max-age=0" + CacheNeverPrivate = "private, max-age=0" +) + +func (p *PRPL) createHandler() http.Handler { m := http.NewServeMux() for _, build := range p.builds { @@ -24,7 +30,7 @@ func (p *prpl) createHandler() http.Handler { return m } -func (p *prpl) routeHandler(w http.ResponseWriter, r *http.Request) { +func (p *PRPL) routeHandler(w http.ResponseWriter, r *http.Request) { capabilities := p.browserCapabilities(r.UserAgent()) build := p.builds.findBuild(capabilities) if build == nil { @@ -33,32 +39,37 @@ func (p *prpl) routeHandler(w http.ResponseWriter, r *http.Request) { } h := w.Header() - h.Set("Cache-Control", "public, max-age=0") - build.addHeaders(w, h, r.URL.Path) + h.Set("Cache-Control", CacheNever) + build.addHeaders(p, w, r) build.template.Render(w, r) } -func (p *prpl) staticHandler(next http.Handler) http.Handler { +func (p *PRPL) staticHandler(next http.Handler) http.Handler { fn := func(w http.ResponseWriter, r *http.Request) { // TODO: Service worker location should be configurable. h := w.Header() if strings.HasSuffix(r.URL.Path, "service-worker.js") { h.Set("Service-Worker-Allowed", "/") - h.Set("Cache-Control", "private, max-age=0") + h.Set("Cache-Control", CacheNeverPrivate) } else { - h.Set("Cache-Control", "public, max-age=31536000, immutable") + h.Set("Cache-Control", CacheImmutable) } + capabilities := p.browserCapabilities(r.UserAgent()) + build := p.builds.findBuild(capabilities) + if build == nil { + http.Error(w, "This browser is not supported", http.StatusInternalServerError) + return + } + + build.addHeaders(p, w, r) + file, found := files[r.URL.Path] if !found { next.ServeHTTP(w, r) return } - // TODO: if using original prpl-server-node strategy - // add the push headers for *this* push-manifest entry - // build.addHeaders(w, h, r.URL.Path) - content := bytes.NewReader(file.data) http.ServeContent(w, r, r.URL.Path, file.modTime, content) } @@ -66,26 +77,30 @@ func (p *prpl) staticHandler(next http.Handler) http.Handler { return http.HandlerFunc(fn) } -func (b *build) addHeaders(w http.ResponseWriter, header http.Header, filename string) { - if links, ok := b.pushHeaders[filename]; ok { - // TODO: use actual push if server supports it - // need to add content type to push header info - // if pusher, ok := w.(http.Pusher); ok { - /* - for _, url := range headers { - pusher.Push(url, &http.PushOptions{ - Header: http.Header{ - "Cache-Control": []string{"public, max-age=31536000, immutable"}, - "Content-Type": []string{"TODO: file content type here"}, - }, - }) - } - */ - // } else { - // otherwise hope there is a proxy that will do it for us +func (b *build) addHeaders(p *PRPL, w http.ResponseWriter, r *http.Request) { + header := w.Header() + filename := r.URL.Path + + links, ok := b.pushHeaders[filename] + if !ok { + links, ok = b.pushHeaders[p.version+filename] + if !ok { + return + } + } + + if pusher, ok := w.(http.Pusher); ok && p.shouldPush != nil && p.shouldPush(r) { for _, link := range links { - header.Add("Link", link) + pusher.Push(link.file, &http.PushOptions{ + Header: http.Header{ + "Cache-Control": []string{CacheImmutable}, + //"Content-Type": []string{"TODO: file content type here"}, + }, + }) } - // } + } + + for _, link := range links { + header.Add("Link", link.String()) } }