Simplify static assets handling (#2866)
|
@ -12,5 +12,7 @@ npm-debug.log
|
|||
/assets/node_modules/
|
||||
/tmp/
|
||||
/livebook
|
||||
/priv/static
|
||||
/priv/iframe_static
|
||||
# Ignore app release files, including build artifacts
|
||||
/rel/app
|
||||
|
|
4
.gitignore
vendored
|
@ -33,3 +33,7 @@ npm-debug.log
|
|||
|
||||
# The built Escript
|
||||
/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
|
||||
|
||||
# Compile and build the release
|
||||
COPY priv/.gitkeep priv/.gitkeep
|
||||
COPY rel rel
|
||||
COPY static static
|
||||
COPY iframe/priv/static/iframe iframe/priv/static/iframe
|
||||
|
|
|
@ -226,7 +226,7 @@ export function setFavicon(name) {
|
|||
document.head.appendChild(link);
|
||||
}
|
||||
|
||||
link.href = `/${name}.svg`;
|
||||
link.href = `/favicons/${name}.svg`;
|
||||
}
|
||||
|
||||
export function findChildOrThrow(element, selector) {
|
||||
|
|
|
@ -8,6 +8,18 @@ defmodule Livebook.Config do
|
|||
| %{mode: :token, secret: String.t()}
|
||||
| %{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 """
|
||||
Returns docker images to be used when generating sample Dockerfiles.
|
||||
"""
|
||||
|
|
|
@ -13,6 +13,9 @@ defmodule LivebookCLI do
|
|||
|
||||
def main(args) do
|
||||
{:ok, _} = Application.ensure_all_started(:elixir)
|
||||
|
||||
extract_priv!()
|
||||
|
||||
:ok = Application.load(:livebook)
|
||||
|
||||
if unix?() do
|
||||
|
@ -76,4 +79,51 @@ defmodule LivebookCLI do
|
|||
version = Livebook.Config.app_version()
|
||||
IO.puts("\nLivebook #{version}")
|
||||
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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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
|
||||
quote do
|
||||
|
|
|
@ -5,8 +5,8 @@
|
|||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="csrf-token" content={get_csrf_token()} />
|
||||
<link rel="icon" type="image/svg+xml" href={~p"/favicon.svg"} />
|
||||
<link rel="alternate icon" type="image/png" href={~p"/favicon.png"} />
|
||||
<link rel="icon" type="image/svg+xml" href={~p"/favicons/favicon.svg"} />
|
||||
<link rel="alternate icon" type="image/png" href={~p"/favicons/favicon.png"} />
|
||||
<.live_title>
|
||||
<%= assigns[:page_title] || "Livebook" %>
|
||||
</.live_title>
|
||||
|
|
|
@ -41,8 +41,8 @@ defmodule LivebookWeb.ErrorHTML do
|
|||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href={~p"/favicon.svg"} />
|
||||
<link rel="alternate icon" type="image/png" href={~p"/favicon.png"} />
|
||||
<link rel="icon" type="image/svg+xml" href={~p"/favicons/favicon.svg"} />
|
||||
<link rel="alternate icon" type="image/png" href={~p"/favicons/favicon.png"} />
|
||||
<title><%= @status %> - Livebook</title>
|
||||
<link rel="stylesheet" href={~p"/assets/app.css"} />
|
||||
</head>
|
||||
|
|
|
@ -21,37 +21,49 @@ defmodule LivebookWeb.Endpoint do
|
|||
socket "/live", Phoenix.LiveView.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
|
||||
# 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
|
||||
# and can be served from memory.
|
||||
defmodule AssetsMemoryProvider do
|
||||
use LivebookWeb.MemoryProvider,
|
||||
from: Path.expand("../../static", __DIR__),
|
||||
gzip: true
|
||||
end
|
||||
|
||||
defmodule AssetsFileSystemProvider do
|
||||
use LivebookWeb.FileSystemProvider,
|
||||
from: "tmp/static_dev"
|
||||
end
|
||||
|
||||
# Serve static files at "/"
|
||||
# Serve static files at "/".
|
||||
#
|
||||
# In usual Phoenix applications, we serve static files from priv/static,
|
||||
# however Livebook can also be run as escript, in which case it is
|
||||
# packaged into a single file and priv/ is not accessible directly.
|
||||
# In that case, we include priv/ in the escript archive by setting
|
||||
# the :include_priv_for option. Then, on escript boot, we extract
|
||||
# the priv files into a temporary directory.
|
||||
#
|
||||
# To account for both cases, we configure Plug.Static :from as MFA
|
||||
# and return the accessible priv/ location in both scenarios.
|
||||
#
|
||||
# The priv/ static files are generated by the livebook.gen_priv task
|
||||
# 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
|
||||
# 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
|
||||
# In development we use assets from tmp/static_dev (rebuilt dynamically on every change).
|
||||
# Note that this directory doesn't contain predefined files (e.g. images), so we also
|
||||
# use `AssetsMemoryProvider` to serve those from static/.
|
||||
plug LivebookWeb.StaticPlug,
|
||||
# In development, we use assets from tmp/static_dev, which are
|
||||
# rebuilt on every change. We build to a different directory than
|
||||
# priv/static, to make sure it can be built concurrently.
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
file_provider: AssetsFileSystemProvider,
|
||||
gzip: false
|
||||
from: "tmp/static_dev",
|
||||
gzip: false,
|
||||
only: ["assets"]
|
||||
end
|
||||
|
||||
plug LivebookWeb.StaticPlug,
|
||||
plug Plug.Static,
|
||||
at: "/",
|
||||
file_provider: AssetsMemoryProvider,
|
||||
gzip: true
|
||||
from: {__MODULE__, :static_from, []},
|
||||
gzip: true,
|
||||
only: LivebookWeb.static_paths()
|
||||
|
||||
@doc false
|
||||
def static_from(), do: Path.join(Livebook.Config.priv_path(), "static")
|
||||
|
||||
plug :force_ssl
|
||||
|
||||
|
|
|
@ -1,25 +1,20 @@
|
|||
defmodule LivebookWeb.IframeEndpoint do
|
||||
use Plug.Builder
|
||||
|
||||
defmodule AssetsMemoryProvider do
|
||||
use LivebookWeb.MemoryProvider,
|
||||
from: Path.expand("../../iframe/priv/static/iframe", __DIR__),
|
||||
gzip: true
|
||||
end
|
||||
|
||||
plug LivebookWeb.StaticPlug,
|
||||
plug Plug.Static,
|
||||
at: "/iframe",
|
||||
file_provider: AssetsMemoryProvider,
|
||||
gzip: true,
|
||||
from: {__MODULE__, :static_from, []},
|
||||
# Iframes are versioned, so we cache them for long
|
||||
cache_control_for_etags: "public, max-age=31536000",
|
||||
headers: [
|
||||
# Enable CORS to allow Livebook fetch the content and verify its integrity
|
||||
{"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"}
|
||||
]
|
||||
|
||||
@doc false
|
||||
def static_from(), do: Path.join(Livebook.Config.priv_path(), "static_iframe")
|
||||
|
||||
plug :not_found
|
||||
|
||||
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"
|
||||
},
|
||||
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
|
||||
|
||||
|
@ -68,7 +68,10 @@ defmodule Livebook.MixProject do
|
|||
setup: ["deps.get", "cmd --cd assets npm install"],
|
||||
"assets.deploy": ["cmd npm run deploy --prefix assets"],
|
||||
"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
|
||||
|
||||
|
@ -76,7 +79,8 @@ defmodule Livebook.MixProject do
|
|||
[
|
||||
main_module: LivebookCLI,
|
||||
app: nil,
|
||||
emu_args: "-epmd_module Elixir.Livebook.EPMD"
|
||||
emu_args: "-epmd_module Elixir.Livebook.EPMD",
|
||||
include_priv_for: [:livebook]
|
||||
]
|
||||
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
|