Simplify static assets handling (#2866)
|
|
@ -12,5 +12,7 @@ npm-debug.log
|
||||||
/assets/node_modules/
|
/assets/node_modules/
|
||||||
/tmp/
|
/tmp/
|
||||||
/livebook
|
/livebook
|
||||||
|
/priv/static
|
||||||
|
/priv/iframe_static
|
||||||
# Ignore app release files, including build artifacts
|
# Ignore app release files, including build artifacts
|
||||||
/rel/app
|
/rel/app
|
||||||
|
|
|
||||||
4
.gitignore
vendored
|
|
@ -33,3 +33,7 @@ npm-debug.log
|
||||||
|
|
||||||
# The built Escript
|
# The built Escript
|
||||||
/livebook
|
/livebook
|
||||||
|
|
||||||
|
# We generate priv when building release or escript
|
||||||
|
/priv/static
|
||||||
|
/priv/iframe_static
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ COPY config config
|
||||||
RUN mix do deps.get, deps.compile
|
RUN mix do deps.get, deps.compile
|
||||||
|
|
||||||
# Compile and build the release
|
# Compile and build the release
|
||||||
|
COPY priv/.gitkeep priv/.gitkeep
|
||||||
COPY rel rel
|
COPY rel rel
|
||||||
COPY static static
|
COPY static static
|
||||||
COPY iframe/priv/static/iframe iframe/priv/static/iframe
|
COPY iframe/priv/static/iframe iframe/priv/static/iframe
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export function setFavicon(name) {
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
link.href = `/${name}.svg`;
|
link.href = `/favicons/${name}.svg`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findChildOrThrow(element, selector) {
|
export function findChildOrThrow(element, selector) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,18 @@ defmodule Livebook.Config do
|
||||||
| %{mode: :token, secret: String.t()}
|
| %{mode: :token, secret: String.t()}
|
||||||
| %{mode: :disabled}
|
| %{mode: :disabled}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns path to Livebook priv directory.
|
||||||
|
|
||||||
|
This returns the usual priv directory, however in case of Escript,
|
||||||
|
the priv files are extracted into a temporary directory and that is
|
||||||
|
the path returned.
|
||||||
|
"""
|
||||||
|
@spec priv_path() :: String.t()
|
||||||
|
def priv_path() do
|
||||||
|
Application.get_env(:livebook, :priv_dir) || Application.app_dir(:livebook, "priv")
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns docker images to be used when generating sample Dockerfiles.
|
Returns docker images to be used when generating sample Dockerfiles.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ defmodule LivebookCLI do
|
||||||
|
|
||||||
def main(args) do
|
def main(args) do
|
||||||
{:ok, _} = Application.ensure_all_started(:elixir)
|
{:ok, _} = Application.ensure_all_started(:elixir)
|
||||||
|
|
||||||
|
extract_priv!()
|
||||||
|
|
||||||
:ok = Application.load(:livebook)
|
:ok = Application.load(:livebook)
|
||||||
|
|
||||||
if unix?() do
|
if unix?() do
|
||||||
|
|
@ -76,4 +79,51 @@ defmodule LivebookCLI do
|
||||||
version = Livebook.Config.app_version()
|
version = Livebook.Config.app_version()
|
||||||
IO.puts("\nLivebook #{version}")
|
IO.puts("\nLivebook #{version}")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
import Record
|
||||||
|
defrecord(:zip_file, extract(:zip_file, from_lib: "stdlib/include/zip.hrl"))
|
||||||
|
|
||||||
|
defp extract_priv!() do
|
||||||
|
archive_dir = Path.join(Livebook.Config.tmp_path(), "escript")
|
||||||
|
extracted_path = Path.join(archive_dir, ".extracted")
|
||||||
|
in_archive_priv_path = ~c"livebook/priv"
|
||||||
|
|
||||||
|
# In dev we want to extract fresh directory on every boot
|
||||||
|
if Livebook.Config.app_version() =~ "-dev" do
|
||||||
|
File.rm_rf!(archive_dir)
|
||||||
|
end
|
||||||
|
|
||||||
|
# When temporary directory is cleaned by the OS, the directories
|
||||||
|
# may be left in place, so we use a regular file (.extracted) to
|
||||||
|
# check if the extracted archive is already available
|
||||||
|
if not File.exists?(extracted_path) do
|
||||||
|
{:ok, sections} = :escript.extract(:escript.script_name(), [])
|
||||||
|
archive = Keyword.fetch!(sections, :archive)
|
||||||
|
|
||||||
|
file_filter = fn zip_file(name: name) ->
|
||||||
|
List.starts_with?(name, in_archive_priv_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
case :zip.extract(archive, cwd: String.to_charlist(archive_dir), file_filter: file_filter) do
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
print_error_and_exit(
|
||||||
|
"Livebook failed to extract archive files, reason: #{inspect(error)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
File.touch!(extracted_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
priv_dir = Path.join(archive_dir, in_archive_priv_path)
|
||||||
|
Application.put_env(:livebook, :priv_dir, priv_dir, persistent: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec print_error_and_exit(String.t()) :: no_return()
|
||||||
|
defp print_error_and_exit(message) do
|
||||||
|
IO.ANSI.format([:red, message]) |> IO.puts()
|
||||||
|
System.halt(1)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
defmodule LivebookWeb do
|
defmodule LivebookWeb do
|
||||||
def static_paths, do: ~w(assets images favicon.svg favicon.png robots.txt)
|
def static_paths, do: ~w(assets images favicons robots.txt)
|
||||||
|
|
||||||
def controller do
|
def controller do
|
||||||
quote do
|
quote do
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="csrf-token" content={get_csrf_token()} />
|
<meta name="csrf-token" content={get_csrf_token()} />
|
||||||
<link rel="icon" type="image/svg+xml" href={~p"/favicon.svg"} />
|
<link rel="icon" type="image/svg+xml" href={~p"/favicons/favicon.svg"} />
|
||||||
<link rel="alternate icon" type="image/png" href={~p"/favicon.png"} />
|
<link rel="alternate icon" type="image/png" href={~p"/favicons/favicon.png"} />
|
||||||
<.live_title>
|
<.live_title>
|
||||||
<%= assigns[:page_title] || "Livebook" %>
|
<%= assigns[:page_title] || "Livebook" %>
|
||||||
</.live_title>
|
</.live_title>
|
||||||
|
|
|
||||||
|
|
@ -41,8 +41,8 @@ defmodule LivebookWeb.ErrorHTML do
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/svg+xml" href={~p"/favicon.svg"} />
|
<link rel="icon" type="image/svg+xml" href={~p"/favicons/favicon.svg"} />
|
||||||
<link rel="alternate icon" type="image/png" href={~p"/favicon.png"} />
|
<link rel="alternate icon" type="image/png" href={~p"/favicons/favicon.png"} />
|
||||||
<title><%= @status %> - Livebook</title>
|
<title><%= @status %> - Livebook</title>
|
||||||
<link rel="stylesheet" href={~p"/assets/app.css"} />
|
<link rel="stylesheet" href={~p"/assets/app.css"} />
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
|
|
@ -21,37 +21,49 @@ defmodule LivebookWeb.Endpoint do
|
||||||
socket "/live", Phoenix.LiveView.Socket, websocket: @websocket_options
|
socket "/live", Phoenix.LiveView.Socket, websocket: @websocket_options
|
||||||
socket "/socket", LivebookWeb.Socket, websocket: @websocket_options
|
socket "/socket", LivebookWeb.Socket, websocket: @websocket_options
|
||||||
|
|
||||||
# We use Escript for distributing Livebook, so we don't have access to the static
|
# Serve static files at "/".
|
||||||
# files at runtime in the prod environment. To overcome this we load contents of
|
#
|
||||||
# those files at compilation time, so that they become a part of the executable
|
# In usual Phoenix applications, we serve static files from priv/static,
|
||||||
# and can be served from memory.
|
# however Livebook can also be run as escript, in which case it is
|
||||||
defmodule AssetsMemoryProvider do
|
# packaged into a single file and priv/ is not accessible directly.
|
||||||
use LivebookWeb.MemoryProvider,
|
# In that case, we include priv/ in the escript archive by setting
|
||||||
from: Path.expand("../../static", __DIR__),
|
# the :include_priv_for option. Then, on escript boot, we extract
|
||||||
gzip: true
|
# the priv files into a temporary directory.
|
||||||
end
|
#
|
||||||
|
# To account for both cases, we configure Plug.Static :from as MFA
|
||||||
defmodule AssetsFileSystemProvider do
|
# and return the accessible priv/ location in both scenarios.
|
||||||
use LivebookWeb.FileSystemProvider,
|
#
|
||||||
from: "tmp/static_dev"
|
# The priv/ static files are generated by the livebook.gen_priv task
|
||||||
end
|
# before building the escript or the release. We gzip the static
|
||||||
|
# files in priv/, since we want to serve them gzipped, and we don't
|
||||||
# Serve static files at "/"
|
# include the non-gzipped ones to minimize app size. Note that we
|
||||||
|
# still have a separate static/ directory with the CI-precompiled
|
||||||
|
# assets, which we keep in Git so that people can install escript
|
||||||
|
# from GitHub or run MIX_ENV=prod phx.server, without Node and NPM.
|
||||||
|
# Storing minified assets is already not ideal, but we definitely
|
||||||
|
# want to avoid storing the gzipped variants in Git. That's why we
|
||||||
|
# store the assets uncompressed and then generate priv/static with
|
||||||
|
# their compressed variants as part of the build process.
|
||||||
|
|
||||||
if code_reloading? do
|
if code_reloading? do
|
||||||
# In development we use assets from tmp/static_dev (rebuilt dynamically on every change).
|
# In development, we use assets from tmp/static_dev, which are
|
||||||
# Note that this directory doesn't contain predefined files (e.g. images), so we also
|
# rebuilt on every change. We build to a different directory than
|
||||||
# use `AssetsMemoryProvider` to serve those from static/.
|
# priv/static, to make sure it can be built concurrently.
|
||||||
plug LivebookWeb.StaticPlug,
|
plug Plug.Static,
|
||||||
at: "/",
|
at: "/",
|
||||||
file_provider: AssetsFileSystemProvider,
|
from: "tmp/static_dev",
|
||||||
gzip: false
|
gzip: false,
|
||||||
|
only: ["assets"]
|
||||||
end
|
end
|
||||||
|
|
||||||
plug LivebookWeb.StaticPlug,
|
plug Plug.Static,
|
||||||
at: "/",
|
at: "/",
|
||||||
file_provider: AssetsMemoryProvider,
|
from: {__MODULE__, :static_from, []},
|
||||||
gzip: true
|
gzip: true,
|
||||||
|
only: LivebookWeb.static_paths()
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def static_from(), do: Path.join(Livebook.Config.priv_path(), "static")
|
||||||
|
|
||||||
plug :force_ssl
|
plug :force_ssl
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,20 @@
|
||||||
defmodule LivebookWeb.IframeEndpoint do
|
defmodule LivebookWeb.IframeEndpoint do
|
||||||
use Plug.Builder
|
use Plug.Builder
|
||||||
|
|
||||||
defmodule AssetsMemoryProvider do
|
plug Plug.Static,
|
||||||
use LivebookWeb.MemoryProvider,
|
|
||||||
from: Path.expand("../../iframe/priv/static/iframe", __DIR__),
|
|
||||||
gzip: true
|
|
||||||
end
|
|
||||||
|
|
||||||
plug LivebookWeb.StaticPlug,
|
|
||||||
at: "/iframe",
|
at: "/iframe",
|
||||||
file_provider: AssetsMemoryProvider,
|
from: {__MODULE__, :static_from, []},
|
||||||
gzip: true,
|
# Iframes are versioned, so we cache them for long
|
||||||
|
cache_control_for_etags: "public, max-age=31536000",
|
||||||
headers: [
|
headers: [
|
||||||
# Enable CORS to allow Livebook fetch the content and verify its integrity
|
# Enable CORS to allow Livebook fetch the content and verify its integrity
|
||||||
{"access-control-allow-origin", "*"},
|
{"access-control-allow-origin", "*"},
|
||||||
# Iframes are versioned, so we cache them for long
|
|
||||||
{"cache-control", "public, max-age=31536000"},
|
|
||||||
# Specify the charset
|
|
||||||
{"content-type", "text/html; charset=utf-8"}
|
{"content-type", "text/html; charset=utf-8"}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def static_from(), do: Path.join(Livebook.Config.priv_path(), "static_iframe")
|
||||||
|
|
||||||
plug :not_found
|
plug :not_found
|
||||||
|
|
||||||
defp not_found(conn, _) do
|
defp not_found(conn, _) do
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
defmodule LivebookWeb.FileSystemProvider do
|
|
||||||
# Configurable implementation of `LivebookWeb.StaticPlug.Provider` behaviour,
|
|
||||||
# that loads files directly from the file system.
|
|
||||||
#
|
|
||||||
# ## `use` options
|
|
||||||
#
|
|
||||||
# * `:from` (**required**) - where to read the static files from. See `Plug.Static` for more details.
|
|
||||||
|
|
||||||
defmacro __using__(opts) do
|
|
||||||
quote bind_quoted: [opts: opts] do
|
|
||||||
@behaviour LivebookWeb.StaticPlug.Provider
|
|
||||||
|
|
||||||
from = Keyword.fetch!(opts, :from)
|
|
||||||
static_path = LivebookWeb.StaticPlug.Provider.static_path(from)
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def get_file(segments, compression) do
|
|
||||||
LivebookWeb.FileSystemProvider.__get_file__(unquote(static_path), segments, compression)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def __get_file__(static_path, segments, nil) do
|
|
||||||
abs_path = Path.join([static_path | segments])
|
|
||||||
|
|
||||||
if File.regular?(abs_path) do
|
|
||||||
content = File.read!(abs_path)
|
|
||||||
digest = content |> :erlang.md5() |> Base.encode16(case: :lower)
|
|
||||||
%LivebookWeb.StaticPlug.File{content: content, digest: digest}
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def __get_file__(_static_path, _segments, _compression), do: nil
|
|
||||||
end
|
|
||||||
|
|
@ -1,71 +0,0 @@
|
||||||
defmodule LivebookWeb.MemoryProvider do
|
|
||||||
@gzippable_exts ~w(.js .css .txt .text .html .json .svg .eot .ttf)
|
|
||||||
|
|
||||||
# Configurable implementation of `LivebookWeb.StaticPlug.Provider` behaviour,
|
|
||||||
# that bundles the files into the module compiled source.
|
|
||||||
#
|
|
||||||
# ## `use` options
|
|
||||||
#
|
|
||||||
# * `:from` (**required**) - where to read the static files from. See `Plug.Static` for more details.
|
|
||||||
#
|
|
||||||
# * `:gzip` - whether to bundle gzipped version of the files,
|
|
||||||
# in which case the uncompressed files are not included. Defaults to `false`.
|
|
||||||
|
|
||||||
defmacro __using__(opts) do
|
|
||||||
quote bind_quoted: [opts: opts] do
|
|
||||||
@behaviour LivebookWeb.StaticPlug.Provider
|
|
||||||
|
|
||||||
from = Keyword.fetch!(opts, :from)
|
|
||||||
static_path = LivebookWeb.StaticPlug.Provider.static_path(from)
|
|
||||||
paths = LivebookWeb.MemoryProvider.__paths__(static_path)
|
|
||||||
files = LivebookWeb.MemoryProvider.__preload_files__!(static_path, paths, opts)
|
|
||||||
|
|
||||||
for path <- paths do
|
|
||||||
abs_path = Path.join(static_path, path)
|
|
||||||
@external_resource Path.relative_to_cwd(abs_path)
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def get_file(segments, compression)
|
|
||||||
|
|
||||||
for {segments, compression, file} <- files do
|
|
||||||
def get_file(unquote(segments), unquote(compression)), do: unquote(Macro.escape(file))
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_file(_, _), do: nil
|
|
||||||
|
|
||||||
# Force recompilation if the static files change.
|
|
||||||
def __mix_recompile__? do
|
|
||||||
current_paths = LivebookWeb.MemoryProvider.__paths__(unquote(static_path))
|
|
||||||
:erlang.md5(current_paths) != unquote(:erlang.md5(paths))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def __preload_files__!(static_path, paths, opts) do
|
|
||||||
gzip? = Keyword.get(opts, :gzip, false)
|
|
||||||
|
|
||||||
Enum.map(paths, fn path ->
|
|
||||||
segments = Path.split(path)
|
|
||||||
abs_path = Path.join(static_path, path)
|
|
||||||
content = File.read!(abs_path)
|
|
||||||
digest = content |> :erlang.md5() |> Base.encode16(case: :lower)
|
|
||||||
|
|
||||||
if gzip? and Path.extname(path) in @gzippable_exts do
|
|
||||||
gzipped_content = :zlib.gzip(content)
|
|
||||||
|
|
||||||
{segments, :gzip, %LivebookWeb.StaticPlug.File{content: gzipped_content, digest: digest}}
|
|
||||||
else
|
|
||||||
{segments, nil, %LivebookWeb.StaticPlug.File{content: content, digest: digest}}
|
|
||||||
end
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def __paths__(static_path) do
|
|
||||||
Path.join(static_path, "**")
|
|
||||||
|> Path.wildcard()
|
|
||||||
|> Enum.reject(&File.dir?/1)
|
|
||||||
|> Enum.map(&String.replace_leading(&1, static_path <> "/", ""))
|
|
||||||
|> Enum.sort()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,160 +0,0 @@
|
||||||
defmodule LivebookWeb.StaticPlug.File do
|
|
||||||
defstruct [:content, :digest]
|
|
||||||
|
|
||||||
@type t :: %__MODULE__{content: binary(), digest: String.t()}
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule LivebookWeb.StaticPlug.Provider do
|
|
||||||
@type segments :: list(String.t())
|
|
||||||
@type compression :: :gzip | nil
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Returns file data for the given path (given as list of segments) and compression type.
|
|
||||||
"""
|
|
||||||
@callback get_file(segments(), compression()) :: LivebookWeb.StaticPlug.File.t() | nil
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Parses static files location usually passed as the `:from` option
|
|
||||||
when configuring provider.
|
|
||||||
|
|
||||||
See `Plug.Static` for more details.
|
|
||||||
"""
|
|
||||||
@spec static_path({atom(), binary()} | atom() | binary()) :: binary()
|
|
||||||
def static_path(from)
|
|
||||||
|
|
||||||
def static_path({app, path}) when is_atom(app) and is_binary(path) do
|
|
||||||
Path.join(Application.app_dir(app), path)
|
|
||||||
end
|
|
||||||
|
|
||||||
def static_path(path) when is_binary(path), do: path
|
|
||||||
def static_path(app) when is_atom(app), do: static_path({app, "priv/static"})
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule LivebookWeb.StaticPlug do
|
|
||||||
# This is a simplified version of `Plug.Static` meant
|
|
||||||
# to serve static files using the given provider.
|
|
||||||
#
|
|
||||||
# ## Options
|
|
||||||
#
|
|
||||||
# * `:file_provider` (**required**) - a module implementing `LivebookWeb.StaticPlug.Provider`
|
|
||||||
# behaviour, responsible for resolving file requests
|
|
||||||
#
|
|
||||||
# * `:at`, `:gzip`, `:headers` - same as `Plug.Static`
|
|
||||||
|
|
||||||
@behaviour Plug
|
|
||||||
|
|
||||||
import Plug.Conn
|
|
||||||
|
|
||||||
@allowed_methods ~w(GET HEAD)
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def init(opts) do
|
|
||||||
file_provider = Keyword.fetch!(opts, :file_provider)
|
|
||||||
|
|
||||||
%{
|
|
||||||
file_provider: file_provider,
|
|
||||||
at: opts |> Keyword.fetch!(:at) |> Plug.Router.Utils.split(),
|
|
||||||
gzip?: Keyword.get(opts, :gzip, false),
|
|
||||||
headers: Keyword.get(opts, :headers, [])
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def call(
|
|
||||||
%Plug.Conn{method: method} = conn,
|
|
||||||
%{file_provider: file_provider, at: at, gzip?: gzip?} = options
|
|
||||||
)
|
|
||||||
when method in @allowed_methods do
|
|
||||||
segments = subset(at, conn.path_info)
|
|
||||||
|
|
||||||
case encoding_with_file(conn, file_provider, segments, gzip?) do
|
|
||||||
{encoding, file} ->
|
|
||||||
serve_static(conn, encoding, file, segments, options)
|
|
||||||
|
|
||||||
:error ->
|
|
||||||
conn
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def call(conn, _options) do
|
|
||||||
conn
|
|
||||||
end
|
|
||||||
|
|
||||||
defp serve_static(conn, content_encoding, file, segments, options) do
|
|
||||||
case put_cache_header(conn, file) do
|
|
||||||
{:stale, conn} ->
|
|
||||||
filename = List.last(segments)
|
|
||||||
content_type = MIME.from_path(filename)
|
|
||||||
|
|
||||||
conn
|
|
||||||
|> put_resp_header("content-type", content_type)
|
|
||||||
|> maybe_add_encoding(content_encoding)
|
|
||||||
|> maybe_add_vary(options)
|
|
||||||
|> merge_resp_headers(options.headers)
|
|
||||||
|> send_resp(200, file.content)
|
|
||||||
|> halt()
|
|
||||||
|
|
||||||
{:fresh, conn} ->
|
|
||||||
conn
|
|
||||||
|> maybe_add_vary(options)
|
|
||||||
|> send_resp(304, "")
|
|
||||||
|> halt()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_add_encoding(conn, nil), do: conn
|
|
||||||
defp maybe_add_encoding(conn, ce), do: put_resp_header(conn, "content-encoding", ce)
|
|
||||||
|
|
||||||
# If we serve gzip at any moment, we need to set the proper vary
|
|
||||||
# header regardless of whether we are serving gzip content right now.
|
|
||||||
# See: http://www.fastly.com/blog/best-practices-for-using-the-vary-header/
|
|
||||||
defp maybe_add_vary(conn, %{gzip?: true}) do
|
|
||||||
update_in(conn.resp_headers, &[{"vary", "Accept-Encoding"} | &1])
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_add_vary(conn, _options), do: conn
|
|
||||||
|
|
||||||
defp put_cache_header(conn, file) do
|
|
||||||
etag = etag_for_file(file)
|
|
||||||
|
|
||||||
conn =
|
|
||||||
conn
|
|
||||||
|> put_resp_header("cache-control", "public")
|
|
||||||
|> put_resp_header("etag", etag)
|
|
||||||
|
|
||||||
if etag in get_req_header(conn, "if-none-match") do
|
|
||||||
{:fresh, conn}
|
|
||||||
else
|
|
||||||
{:stale, conn}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp etag_for_file(file) do
|
|
||||||
<<?", file.digest::binary, ?">>
|
|
||||||
end
|
|
||||||
|
|
||||||
defp encoding_with_file(conn, file_provider, segments, gzip?) do
|
|
||||||
cond do
|
|
||||||
file = gzip? and accept_encoding?(conn, "gzip") && file_provider.get_file(segments, :gzip) ->
|
|
||||||
{"gzip", file}
|
|
||||||
|
|
||||||
file = file_provider.get_file(segments, nil) ->
|
|
||||||
{nil, file}
|
|
||||||
|
|
||||||
true ->
|
|
||||||
:error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp accept_encoding?(conn, encoding) do
|
|
||||||
encoding? = &String.contains?(&1, [encoding, "*"])
|
|
||||||
|
|
||||||
Enum.any?(get_req_header(conn, "accept-encoding"), fn accept ->
|
|
||||||
accept |> Plug.Conn.Utils.list() |> Enum.any?(encoding?)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp subset([h | expected], [h | actual]), do: subset(expected, actual)
|
|
||||||
defp subset([], actual), do: actual
|
|
||||||
defp subset(_, _), do: []
|
|
||||||
end
|
|
||||||
37
lib/mix/tasks/livebook.gen_priv.ex
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
defmodule Mix.Tasks.Livebook.GenPriv do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
# Note that we need to include priv/.gitkeep in Dockerfile and Hex
|
||||||
|
# package files, so that priv/ is symlinked within _build/, before
|
||||||
|
# we generate the actual files.
|
||||||
|
|
||||||
|
use Mix.Task
|
||||||
|
|
||||||
|
@gzippable_exts ~w(.js .css .txt .text .html .json .svg .eot .ttf)
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def run([]) do
|
||||||
|
compress_and_copy("static", "priv/static")
|
||||||
|
compress_and_copy("iframe/priv/static/iframe", "priv/iframe_static")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp compress_and_copy(source_dir, target_dir) do
|
||||||
|
File.rm_rf!(target_dir)
|
||||||
|
|
||||||
|
source_paths = Path.wildcard(Path.join(source_dir, "**/*"))
|
||||||
|
|
||||||
|
for source_path <- source_paths, File.regular?(source_path) do
|
||||||
|
target_path = Path.join(target_dir, Path.relative_to(source_path, source_dir))
|
||||||
|
File.mkdir_p!(Path.dirname(target_path))
|
||||||
|
|
||||||
|
if Path.extname(source_path) in @gzippable_exts do
|
||||||
|
content = source_path |> File.read!() |> :zlib.gzip()
|
||||||
|
File.write!(target_path <> ".gz", content)
|
||||||
|
else
|
||||||
|
File.cp!(source_path, target_path)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Mix.shell().info("Generated #{target_dir} with compressed files from #{source_dir}")
|
||||||
|
end
|
||||||
|
end
|
||||||
10
mix.exs
|
|
@ -59,7 +59,7 @@ defmodule Livebook.MixProject do
|
||||||
"GitHub" => "https://github.com/livebook-dev/livebook"
|
"GitHub" => "https://github.com/livebook-dev/livebook"
|
||||||
},
|
},
|
||||||
files:
|
files:
|
||||||
~w(lib static config mix.exs mix.lock README.md LICENSE CHANGELOG.md iframe/priv/static/iframe proto/lib)
|
~w(lib static priv/.gitkeep config mix.exs mix.lock README.md LICENSE CHANGELOG.md iframe/priv/static/iframe proto/lib)
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -68,7 +68,10 @@ defmodule Livebook.MixProject do
|
||||||
setup: ["deps.get", "cmd --cd assets npm install"],
|
setup: ["deps.get", "cmd --cd assets npm install"],
|
||||||
"assets.deploy": ["cmd npm run deploy --prefix assets"],
|
"assets.deploy": ["cmd npm run deploy --prefix assets"],
|
||||||
"format.all": ["format", "cmd --cd assets npm run format"],
|
"format.all": ["format", "cmd --cd assets npm run format"],
|
||||||
"protobuf.generate": ["cmd --cd proto mix protobuf.generate"]
|
"protobuf.generate": ["cmd --cd proto mix protobuf.generate"],
|
||||||
|
"phx.server": ["livebook.gen_priv", "phx.server"],
|
||||||
|
"escript.build": ["livebook.gen_priv", "escript.build"],
|
||||||
|
release: ["livebook.gen_priv", "release"]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -76,7 +79,8 @@ defmodule Livebook.MixProject do
|
||||||
[
|
[
|
||||||
main_module: LivebookCLI,
|
main_module: LivebookCLI,
|
||||||
app: nil,
|
app: nil,
|
||||||
emu_args: "-epmd_module Elixir.Livebook.EPMD"
|
emu_args: "-epmd_module Elixir.Livebook.EPMD",
|
||||||
|
include_priv_for: [:livebook]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
0
priv/.gitkeep
Normal file
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
|
|
@ -1,21 +0,0 @@
|
||||||
defmodule LivebookWeb.FileSystemProviderTest do
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
defmodule MyProvider do
|
|
||||||
use LivebookWeb.FileSystemProvider,
|
|
||||||
from: Path.expand("../../support/static", __DIR__)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes regular files" do
|
|
||||||
assert %{content: content} = MyProvider.get_file(["js", "app.js"], nil)
|
|
||||||
assert content =~ ~s{console.log("Hello");}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores directories" do
|
|
||||||
assert nil == MyProvider.get_file(["js"], nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores non-existent files" do
|
|
||||||
assert nil == MyProvider.get_file(["nonexistent.js"], nil)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
defmodule LivebookWeb.MemoryProviderTest do
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
|
|
||||||
defmodule MyProvider do
|
|
||||||
use LivebookWeb.MemoryProvider,
|
|
||||||
from: Path.expand("../../support/static", __DIR__),
|
|
||||||
gzip: true
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes uncompressed files that are not gzippable" do
|
|
||||||
assert %{content: ""} = MyProvider.get_file(["icon.ico"], nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes compressed files which are gzippable" do
|
|
||||||
assert %{content: content} = MyProvider.get_file(["js", "app.js"], :gzip)
|
|
||||||
assert :zlib.gunzip(content) =~ ~s{console.log("Hello");}
|
|
||||||
end
|
|
||||||
|
|
||||||
test "does not include uncompressed files that are gzippable" do
|
|
||||||
assert nil == MyProvider.get_file(["js", "app.js"], nil)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
defmodule LivebookWeb.StaticPlugTest do
|
|
||||||
use ExUnit.Case, async: true
|
|
||||||
use Plug.Test
|
|
||||||
|
|
||||||
defmodule MyProvider do
|
|
||||||
@behaviour LivebookWeb.StaticPlug.Provider
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def get_file(["app.js"], :gzip) do
|
|
||||||
%LivebookWeb.StaticPlug.File{content: "content", digest: "digest"}
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_file(["icon.ico"], nil) do
|
|
||||||
%LivebookWeb.StaticPlug.File{content: "content", digest: "digest"}
|
|
||||||
end
|
|
||||||
|
|
||||||
def get_file(_path, _compression), do: nil
|
|
||||||
end
|
|
||||||
|
|
||||||
defmodule MyPlug do
|
|
||||||
use Plug.Builder
|
|
||||||
|
|
||||||
plug LivebookWeb.StaticPlug,
|
|
||||||
at: "/",
|
|
||||||
file_provider: MyProvider,
|
|
||||||
gzip: true
|
|
||||||
|
|
||||||
plug :passthrough
|
|
||||||
|
|
||||||
defp passthrough(conn, _), do: Plug.Conn.send_resp(conn, 404, "Passthrough")
|
|
||||||
end
|
|
||||||
|
|
||||||
defp call(conn), do: MyPlug.call(conn, [])
|
|
||||||
|
|
||||||
test "serves uncompressed file if there is no compressed version" do
|
|
||||||
conn =
|
|
||||||
conn(:get, "/icon.ico")
|
|
||||||
|> put_req_header("accept-encoding", "gzip")
|
|
||||||
|> call()
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
assert conn.resp_body == "content"
|
|
||||||
assert get_resp_header(conn, "content-type") == ["image/vnd.microsoft.icon"]
|
|
||||||
assert get_resp_header(conn, "etag") == [~s{"digest"}]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "serves the compressed file if available" do
|
|
||||||
conn =
|
|
||||||
conn(:get, "/app.js")
|
|
||||||
|> put_req_header("accept-encoding", "gzip")
|
|
||||||
|> call()
|
|
||||||
|
|
||||||
assert conn.status == 200
|
|
||||||
assert get_resp_header(conn, "content-encoding") == ["gzip"]
|
|
||||||
assert get_resp_header(conn, "content-type") == ["text/javascript"]
|
|
||||||
assert get_resp_header(conn, "etag") == [~s{"digest"}]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "ignores unavailable paths" do
|
|
||||||
conn =
|
|
||||||
conn(:get, "/invalid.js")
|
|
||||||
|> call()
|
|
||||||
|
|
||||||
assert conn.status == 404
|
|
||||||
end
|
|
||||||
end
|
|
||||||