| // Package concatjs provides a simple way of serving JavaScript sources in development. | |
| package concatjs | |
| import ( | |
| "bufio" | |
| "bytes" | |
| "compress/gzip" | |
| "fmt" | |
| "io" | |
| "io/ioutil" | |
| "log" | |
| "net/http" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "strings" | |
| "sync" | |
| "time" | |
| ) | |
| // ServeConcatenatedJS returns an http.Handler that serves the JavaScript files | |
| // listed in manifestPath in one concatenated, eval separated response body. | |
| // | |
| // This greatly speeds up development load times due to fewer HTTP requests, but | |
| // still for easy debugging by giving the eval'ed fragments URLs through | |
| // sourceURL comments. | |
| // | |
| // Example usage: | |
| // http.Handle("/app_combined.js", | |
| // concatjs.ServeConcatenatedJS("my/app/web_srcs.MF", ".", [], [], nil)) | |
| // | |
| // Relative paths in the manifest are resolved relative to the path given as root. | |
| func ServeConcatenatedJS(manifestPath string, root string, preScripts []string, postScripts []string, fs FileSystem) http.Handler { | |
| var lock sync.Mutex // Guards cache. | |
| cache := NewFileCache(root, fs) | |
| manifestPath = filepath.Join(root, manifestPath) | |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| w.Header().Set("Content-Type", "text/javascript; charset=utf-8") | |
| files, err := manifestFiles(manifestPath) | |
| if err != nil { | |
| w.WriteHeader(http.StatusInternalServerError) | |
| writeJSError(w, "Failed to read manifest: %v", err) | |
| return | |
| } | |
| var writer io.Writer = w | |
| if acceptGzip(r.Header) { | |
| // NB: gzip is not supported in App Engine, as the header is stripped: | |
| // https://cloud.google.com/appengine/docs/go/requests#Go_Request_headers | |
| // CompressionLevel = 3 is a reasonable compromise between speed and compression. | |
| gzw, err := gzip.NewWriterLevel(w, 3) | |
| if err != nil { | |
| log.Fatalf("Could not create gzip writer: %s", err) | |
| } | |
| defer gzw.Close() | |
| writer = gzw | |
| w.Header().Set("Content-Encoding", "gzip") | |
| } | |
| // Write out pre scripts | |
| for _, s := range preScripts { | |
| fmt.Fprint(writer, s) | |
| // Ensure scripts are separated by a newline | |
| fmt.Fprint(writer, "\n") | |
| } | |
| // Protect the cache with a lock because it's possible for multiple requests | |
| // to be handled in parallel. | |
| lock.Lock() | |
| cache.WriteFiles(writer, files) | |
| lock.Unlock() | |
| // Write out post scripts | |
| for _, s := range postScripts { | |
| fmt.Fprint(writer, s) | |
| // Ensure scripts are separated by a newline | |
| fmt.Fprint(writer, "\n") | |
| } | |
| }) | |
| } | |
| var acceptHeader = http.CanonicalHeaderKey("Accept-Encoding") | |
| func acceptGzip(h http.Header) bool { | |
| for _, hv := range h[acceptHeader] { | |
| for _, enc := range strings.Split(hv, ",") { | |
| if strings.TrimSpace(enc) == "gzip" { | |
| return true | |
| } | |
| } | |
| } | |
| return false | |
| } | |
| // FileSystem is the interface to reading files from disk. | |
| // It's abstracted into an interface to allow tests to replace it. | |
| type FileSystem interface { | |
| StatMtime(filename string) (time.Time, error) | |
| ReadFile(filename string) ([]byte, error) | |
| ResolvePath(root string, file string) (string, error) | |
| } | |
| // RealFileSystem implements FileSystem by actual disk access. | |
| type RealFileSystem struct{} | |
| // StatMtime gets the last modification time of the specified file. | |
| func (fs *RealFileSystem) StatMtime(filename string) (time.Time, error) { | |
| s, err := os.Stat(filename) | |
| if err != nil { | |
| return time.Time{}, err | |
| } | |
| return s.ModTime(), nil | |
| } | |
| // ReadFile reads the specified file using the real filesystem. | |
| func (fs *RealFileSystem) ReadFile(filename string) ([]byte, error) { | |
| return ioutil.ReadFile(filename) | |
| } | |
| // ResolvePath resolves the specified path within a given root by joining root and the filepath. | |
| // This is only works if the specified file is located within the given root in the | |
| // real filesystem. This does not work in Bazel where requested files aren't always | |
| // located within the specified root. Files would need to be resolved as runfiles. | |
| func (fs *RealFileSystem) ResolvePath(root string, file string) (string, error) { | |
| return filepath.Join(root, file), nil | |
| } | |
| // FileCache caches a set of files in memory and provides a single | |
| // method, WriteFiles(), that streams them out in the concatjs format. | |
| type FileCache struct { | |
| fs FileSystem | |
| root string | |
| entries map[string]*cacheEntry | |
| } | |
| // NewFileCache constructs a new FileCache. Relative paths in the cache | |
| // are resolved relative to root. fs injects file system access, and | |
| // will use the real file system if nil. | |
| func NewFileCache(root string, fs FileSystem) *FileCache { | |
| if fs == nil { | |
| fs = &RealFileSystem{} | |
| } | |
| return &FileCache{ | |
| root: root, | |
| fs: fs, | |
| entries: map[string]*cacheEntry{}, | |
| } | |
| } | |
| type cacheEntry struct { | |
| // err holds an error encountered while updating the entry; if | |
| // it's non-nil, then mtime, contents and the resolved path are invalid. | |
| err error | |
| mtime time.Time | |
| contents []byte | |
| resolvedPath string | |
| } | |
| // manifestFiles parses a manifest, returning a list of the files in the manifest. | |
| // It skips blank lines and javascript/closure/deps.js. | |
| func manifestFiles(manifest string) ([]string, error) { | |
| f, err := os.Open(manifest) | |
| if err != nil { | |
| return nil, fmt.Errorf("could not read manifest %s: %s", manifest, err) | |
| } | |
| defer f.Close() | |
| return manifestFilesFromReader(f) | |
| } | |
| // manifestFilesFromReader is a helper for manifestFiles, split out for testing. | |
| func manifestFilesFromReader(r io.Reader) ([]string, error) { | |
| var lines []string | |
| s := bufio.NewScanner(r) | |
| for s.Scan() { | |
| path := s.Text() | |
| if path == "" { | |
| continue | |
| } | |
| if path == "javascript/closure/deps.js" { | |
| // Ignore/skip deps.js, it is unused due to CLOSURE_NO_DEPS = true and superseded by the | |
| // dependency handling in this file. It's harmless, but a large download (>450 KB). | |
| continue | |
| } | |
| lines = append(lines, path) | |
| } | |
| if err := s.Err(); err != nil { | |
| return nil, err | |
| } | |
| return lines, nil | |
| } | |
| // writeJSError writes an error both to the log and into w as a JavaScript throw statement. | |
| func writeJSError(w io.Writer, format string, a ...interface{}) { | |
| log.Printf(format, a...) | |
| fmt.Fprint(w, "throw new Error('") | |
| fmt.Fprintf(w, format, a...) | |
| fmt.Fprint(w, "');\n") | |
| } | |
| // WriteFiles updates the cache for a list of files, then streams them into an io.Writer. | |
| func (cache *FileCache) WriteFiles(w io.Writer, files []string) error { | |
| // Ensure the cache is up to date with respect to the on-disk state. | |
| // Note that refreshFiles cannot fail; any errors encountering while refreshing | |
| // are stored in the cache entry and streamed into the response. | |
| cache.refreshFiles(files) | |
| for _, path := range files { | |
| if _, err := fmt.Fprintf(w, "// %s\n", path); err != nil { | |
| return err | |
| } | |
| ce := cache.entries[path] | |
| if ce.err != nil { | |
| writeJSError(w, "loading %s failed: %s", path, ce.err) | |
| continue | |
| } | |
| if _, err := w.Write(ce.contents); err != nil { | |
| return err | |
| } | |
| } | |
| return nil | |
| } | |
| // refresh ensures a single cacheEntry is up to date. It stat()s and | |
| // potentially reads the contents of the file it is caching. | |
| func (e *cacheEntry) refresh(fs FileSystem) error { | |
| mt, err := fs.StatMtime(e.resolvedPath) | |
| if err != nil { | |
| return err | |
| } | |
| if e.mtime == mt && e.contents != nil { | |
| return nil // up to date | |
| } | |
| contents, err := fileContents(e.resolvedPath, fs) | |
| if err != nil { | |
| return err | |
| } | |
| e.mtime = mt | |
| e.contents = contents | |
| return nil | |
| } | |
| // Convert Windows paths separators. We can use this to create canonical paths that | |
| // can be also used as browser source urls. | |
| var pathReplacer = strings.NewReplacer("\\", "/") | |
| // refreshFiles stats the given files and updates the cache for them. | |
| func (cache *FileCache) refreshFiles(files []string) { | |
| // Stating many files asynchronously is faster on network file systems. | |
| // Push all files that need to be stat'd into a channel and have | |
| // a set of workers stat/read them to update the cache entry. | |
| type workItem struct { | |
| path string | |
| entry *cacheEntry | |
| } | |
| work := make(chan workItem) | |
| var wg sync.WaitGroup | |
| wg.Add(len(files)) | |
| for i := 0; i < len(files); i++ { | |
| // TODO(evanm): benchmark limiting this to fewer goroutines. | |
| go func() { | |
| w := <-work | |
| w.entry.err = w.entry.refresh(cache.fs) | |
| wg.Done() | |
| }() | |
| } | |
| for _, path := range files { | |
| entry := cache.entries[path] | |
| if entry == nil { | |
| // Resolve path only once for a cache entry. The resolved path will be part of the | |
| // cache item. | |
| resolvedPath, err := cache.fs.ResolvePath(cache.root, path) | |
| if err != nil { | |
| fmt.Fprintf(os.Stderr, "could not resolve path %s. %v\n", path, err) | |
| os.Exit(1) | |
| } | |
| // Create a new cache entry with the corresponding resolved path. Also normalize the path | |
| // before storing it persistently in the cache. The normalizing is good to do here because | |
| // the path might be used in browser source URLs and should be kept in posix format. | |
| entry = &cacheEntry{ | |
| resolvedPath: pathReplacer.Replace(resolvedPath), | |
| } | |
| cache.entries[path] = entry | |
| } | |
| work <- workItem{path, entry} | |
| } | |
| close(work) | |
| wg.Wait() | |
| } | |
| // The maximum number of bytes of a source file to be searched for the "goog.module" declaration. | |
| // Limited to 50,000 bytes to avoid degenerated performance on large compiled JS (e.g. a | |
| // pre-compiled AngularJS binary). | |
| const googModuleSearchLimit = 50 * 1000 | |
| // Matches files containing "goog.module", which have to be served slightly differently. | |
| var googModuleRegExp = regexp.MustCompile(`(?m)^\s*goog\.module\s*\(\s*['"]`) | |
| // fileContents returns escaped JS file contents for the given path. | |
| func fileContents(path string, fs FileSystem) ([]byte, error) { | |
| contents, err := fs.ReadFile(path) | |
| if err != nil { | |
| return nil, err | |
| } | |
| var f bytes.Buffer | |
| // goog.module files must be wrapped in a goog.loadModule call. Check the first X bytes of the file for it. | |
| limit := googModuleSearchLimit | |
| if len(contents) < limit { | |
| limit = len(contents) | |
| } | |
| if googModuleRegExp.Match(contents[:limit]) { | |
| fmt.Fprint(&f, "goog.loadModule('") | |
| } else { | |
| fmt.Fprint(&f, "eval('") | |
| } | |
| if err := writeJSEscaped(&f, contents); err != nil { | |
| log.Printf("Failed to write file contents of %s: %s", path, err) | |
| return nil, err | |
| } | |
| fmt.Fprintf(&f, "\\n\\n//# sourceURL=http://concatjs/%s\\n');\n", path) | |
| return f.Bytes(), nil | |
| } | |
| // writeJSEscaped writes contents into the given writer, escaping for content in | |
| // a single quoted JavaScript string. | |
| func writeJSEscaped(out io.Writer, contents []byte) error { | |
| // template.JSEscape escapes whitespace and line breaks to bulky six-character | |
| // escapes, substantially blowing up response size, and is also a bit slower. | |
| // As this also doesn't need safe escaping, this code just rather escapes itself. | |
| for _, b := range contents { | |
| switch b { | |
| case '\n': | |
| if _, err := out.Write([]byte("\\n")); err != nil { | |
| return err | |
| } | |
| case '\r': | |
| if _, err := out.Write([]byte("\\r")); err != nil { | |
| return err | |
| } | |
| case '\\', '\'': | |
| if _, err := out.Write([]byte{'\\'}); err != nil { | |
| return err | |
| } | |
| fallthrough | |
| default: | |
| if _, err := out.Write([]byte{b}); err != nil { | |
| return err | |
| } | |
| } | |
| } | |
| return nil | |
| } |