//
// Copyright (c) 2020, Brian Frank and Andy Frank
// Licensed under the Academic Free License version 3.0
//
// History:
// 8 May 2020 Brian Frank Creation
//
using concurrent
**
** FilePack is an in-memory cache of multiple text files to service
** static resources via HTTP. It takes one or more text files and
** creates one compound file. The result is stored in RAM using GZIP
** compression. Or you can use the `pack` utility method to store
** the result to your own files/buffers.
**
** The `onGet` method is used to service GET requests for the bundle.
** The Content-Type header is set based on file extension of files bundled.
** It also implictly supports ETag/Last-Modified for 304 optimization.
**
** The core factory is the `makeFiles` constructor. A suite of utility
** methods is provided for standard bundling of Fantom JavaScrit and CSS
** files.
**
const class FilePack : Weblet
{
//////////////////////////////////////////////////////////////////////////
// Construction
//////////////////////////////////////////////////////////////////////////
** Construct a bundle for the given list of text files
static new makeFiles(File[] files, MimeType? mimeType := null)
{
// calculate buffer size to avoid resizes assuming 25% gzip compression
totalSize := 0
files.each |f| { totalSize += f.size ?: 0 }
buf := Buf(totalSize/4)
// derive mime type from file ext (assume they are all the same)
if (mimeType == null)
mimeType = files[0].mimeType ?: throw Err("Ext to mimeType: $files.first")
// write each file to the buffer
out := Zip.gzipOutStream(buf.out)
pack(files, out).close
return make(buf, mimeType)
}
** Private constructor
private new make(Buf buf, MimeType mimeType)
{
buf = buf.trim.toImmutable
this.buf = buf
this.etag = buf.toDigest("SHA-1").toBase64Uri
this.modified = DateTime.now
this.mimeType = mimeType
}
//////////////////////////////////////////////////////////////////////////
// Identity (NoDoc fields subject to change)
//////////////////////////////////////////////////////////////////////////
** The in-memory file contents in GZIP encoding
@NoDoc const Buf buf
** Entity tag provides a SHA-1 hash for the bundle contents
@NoDoc const Str etag
** Modified time is when bundle was generated
@NoDoc const DateTime modified
** Inferred mime type from file extensions
@NoDoc const MimeType mimeType
** Configurable URI for application specific path
@NoDoc Uri uri
{
get { uriRef.val ?: throw Err("No uri configured") }
set { uriRef.val = it }
}
private const AtomicRef uriRef := AtomicRef()
//////////////////////////////////////////////////////////////////////////
// Weblet
//////////////////////////////////////////////////////////////////////////
** Service an HTTP GET request for this bundle file
override Void onGet()
{
// only process GET requests
if (res.isDone) return
if (req.method != "GET") return res.sendErr(501)
// set identity headers
res.headers["ETag"] = etag
res.headers["Last-Modified"] = modified.toHttpStr
// check if we can return a 304 not modified
if (FileWeblet.doCheckNotModified(req, res, etag, modified)) return
// we only respond using gzip
res.statusCode = 200
res.headers["Content-Encoding"] = "gzip"
res.headers["Content-Type"] = mimeType.toStr
res.headers["Content-Length"] = buf.size.toStr
res.out.writeBuf(buf).close
}
//////////////////////////////////////////////////////////////////////////
// File Utils
//////////////////////////////////////////////////////////////////////////
** Pack multiple text files together and write to the given output
** stream. A trailing newline is automatically added if the file is
** missing one. Empty files are skipped. The stream is not closed.
** Return the given out stream.
static OutStream pack(File[] files, OutStream out)
{
files.each |f| { pipeToPack(f, out) }
return out
}
** Pack a file to the given outstream and ensure there is a trailing newline
private static Void pipeToPack(File f, OutStream out)
{
chunkSize := f.size.min(4096)
if (chunkSize == 0) return // skip empty files
buf := Buf(chunkSize)
in := f.in(chunkSize)
try
{
lastIsNewline := false
while (true)
{
n := in.readBuf(buf.clear, chunkSize)
if (n == null) break
if (n > 0) lastIsNewline = buf[-1] == '\n'
out.writeBuf(buf.flip, buf.remaining)
}
if (!lastIsNewline) out.writeChar('\n')
}
finally { in.close }
}
//////////////////////////////////////////////////////////////////////////
// JavaScript Utils
//////////////////////////////////////////////////////////////////////////
** Given a set of pods return a list of JavaScript files that
** form a complete Fantom application:
** - flatten the pods using `sys::Pod.flattenDepends`
** - order them by dependencies using `sys::Pod.orderByDepends`
** - insert `toEtcJsFiles` immediately after "sys.js"
static File[] toAppJsFiles(Pod[] pods)
{
pods = Pod.flattenDepends(pods)
pods = Pod.orderByDepends(pods)
files := toPodJsFiles(pods)
sysIndex := files.findIndex |f| { f.name == "sys.js" } ?: throw Err("Missing sys.js")
files.insertAll(sysIndex+1, toEtcJsFiles)
return files
}
** Get the standard pod JavaScript file or null if no JS code. The
** standard location used by the Fantom JS compiler is "/{pod-name}.js"
static File? toPodJsFile(Pod pod)
{
uri := (WebJsMode.cur.isEs ? `/js/` : `/`).plus(`${pod.name}.js`)
return pod.file(uri, false)
}
** Map a set of pods to "/{name}.js" JavaScript files.
** Ignore pods that are missing a JavaScript file.
** This method does *not* flatten/order the pods.
static File[] toPodJsFiles(Pod[] pods)
{
acc := File[,]
acc.capacity = pods.size
pods.each |pod|
{
js := toPodJsFile(pod)
if (js != null)
{
if (pod.name == "sys" && WebJsMode.cur.isEs) acc.add(pod.file(`/js/fan.js`))
acc.add(js)
}
}
return acc
}
** Return the required sys etc files:
** - add `toMimeJsFile`
** - add `toUnitsJsFile`
** - add `toIndexPropsJsFile`
static File[] toEtcJsFiles()
{
[toMimeJsFile, toUnitsJsFile, toIndexPropsJsFile]
}
@NoDoc static Obj moduleSystem()
{
Type.find("compilerEs::CommonJs").make([Env.cur.tempDir.plus(`file_pack/`)])
}
private static File compileJsFile(Str cname, Uri fname, Obj? arg := null)
{
buf := Buf(4096)
c := WebJsMode.cur.isEs
? Type.find("compilerEs::${cname}").make([moduleSystem])
: Type.find("compilerJs::${cname}").make
c->write(buf.out, arg)
return buf.toFile(fname)
}
** Compile the mime type database into a Javascript file "mime.js"
static File toMimeJsFile()
{
compileJsFile("JsExtToMime", `mime.js`)
}
** Compile the unit database into a JavaScript file "unit.js"
static File toUnitsJsFile()
{
compileJsFile("JsUnitDatabase", `units.js`)
}
** Compile the indexed props database into a JavaScript file "index-props.js"
static File toIndexPropsJsFile(Pod[] pods := Pod.list)
{
compileJsFile("JsIndexedProps", `index-props.js`, pods)
}
** Compile the timezone database into a JavaScript file "tz.js"
@Deprecated { msg="tz.js is now included by default in sys.js" }
static File toTimezonesJsFile()
{
// return empty file
Buf().toFile(`tz.js`)
}
** Compile the locale props into a JavaScript file "{locale}.js"
static File toLocaleJsFile(Locale locale, Pod[] pods := Pod.list)
{
buf := Buf(1024)
m := Slot.findMethod("compilerJs::JsProps.writeProps")
path := `locale/${locale.toStr}.props`
pods.each |pod| { m.call(buf.out, pod, path, 1sec) }
return buf.toFile(`${locale}.js`)
}
** Compile a list of pod JavaScript files into a single unified source
** map file. The list of files passed to this method should match
** exactly the list of files used to create the corresponding JavaScript
** FilePack. If the file is the standard pod JS file, then we will include
** an offset version of "{pod}.js.map" generated by the JavaScript compiler.
** Otherwise if the file is another JavaScript file (such as units.js) then
** we just add the appropiate offset.
**
** The 'sourceRoot' option may be passed in to replace "/dev/{podName}"
** as the root URI used to fetch source files from the server.
static File toPodJsMapFile(File[] files, [Str:Obj]? options := null)
{
buf := Buf(4 * 1024 * 1024)
m := WebJsMode.cur.isEs ?
Slot.findMethod("compilerEs::SourceMap.pack") :
Slot.findMethod("compilerJs::SourceMap.pack")
m.call(files, buf.out, options)
return buf.toFile(`js.map`)
}
//////////////////////////////////////////////////////////////////////////
// CSS Utils
//////////////////////////////////////////////////////////////////////////
** Given a set of pods return a list of CSS files that
** form a complete Fantom application:
** - flatten the pods using `sys::Pod.flattenDepends`
** - order them by dependencies using `sys::Pod.orderByDepends`
** - return `toPodCssFiles`
static File[] toAppCssFiles(Pod[] pods)
{
pods = Pod.flattenDepends(pods)
pods = Pod.orderByDepends(pods)
return toPodCssFiles(pods)
}
** Map a set of pods to "/res/css/{name}.css" CSS files.
** Ignore pods that are missing a CSS file.
** This method does *not* flatten/order the pods.
static File[] toPodCssFiles(Pod[] pods)
{
acc := File[,]
pods.each |pod|
{
css := pod.file(`/res/css/${pod.name}.css`, false)
if (css != null) acc.add(css)
}
return acc
}
//////////////////////////////////////////////////////////////////////////
// Main
//////////////////////////////////////////////////////////////////////////
** Test program
@NoDoc static Void main(Str[] args)
{
pods := args.map |n->Pod| { Pod.find(n) }
mainReport(toAppJsFiles(pods))
mainReport(toAppCssFiles(pods))
}
private static Void mainReport(File[] f)
{
b := makeFiles(f)
gzip := b.buf.size.toLocale("B")
echo("$f.first.ext: $f.size files, $gzip, $b.mimeType")
}
}