From 1be73ee8d2206a7f1520257d81c8a5a4f3cc195e Mon Sep 17 00:00:00 2001 From: Hayleigh Thompson Date: Tue, 13 Feb 2024 21:13:57 +0000 Subject: =?UTF-8?q?=F0=9F=9A=A7=20Begin=20working=20on=20CLI=20things.=20(?= =?UTF-8?q?#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :recycle: Rename http ffi to lustre_try_ffi. * :wrench: Add any files under lustre/cli as internal modules. * :recyle: Move lustre/try command into cli subdirectory. * :heavy_plus_sign: Add justin, simplifile, and tom as dependencies. * :sparkles: Write a 'lustre add' command for downloading esbuild. * :construction: Begin work on a 'lustre build' command for bundling apps and components. * :sparkles: Add 'main' function as CLI entrypoint to lustre. * :bug: Fix `no-styles` flag's name * :bug: Use consistent path for error reporting in lustre add * :construction: Project module * :truck: Move esbuild functions to their own module * :construction: Use a temporary file to bundle components * :construction: Build app and update to glint rc * :heavy_plus_sign: Add filepath dependency * :bug: Fix wrong paths in esbuild code --------- Co-authored-by: Giacomo Cavalieri --- src/http_ffi.erl | 117 ---------------------- src/lustre.gleam | 31 +++++- src/lustre/cli/add.gleam | 67 +++++++++++++ src/lustre/cli/build.gleam | 226 +++++++++++++++++++++++++++++++++++++++++++ src/lustre/cli/esbuild.gleam | 155 +++++++++++++++++++++++++++++ src/lustre/cli/project.gleam | 63 ++++++++++++ src/lustre/cli/try.gleam | 108 +++++++++++++++++++++ src/lustre/try.gleam | 100 ------------------- src/lustre_add_ffi.erl | 49 ++++++++++ src/lustre_build_ffi.erl | 9 ++ src/lustre_try_ffi.erl | 115 ++++++++++++++++++++++ 11 files changed, 822 insertions(+), 218 deletions(-) delete mode 100644 src/http_ffi.erl create mode 100644 src/lustre/cli/add.gleam create mode 100644 src/lustre/cli/build.gleam create mode 100644 src/lustre/cli/esbuild.gleam create mode 100644 src/lustre/cli/project.gleam create mode 100644 src/lustre/cli/try.gleam delete mode 100644 src/lustre/try.gleam create mode 100644 src/lustre_add_ffi.erl create mode 100644 src/lustre_build_ffi.erl create mode 100644 src/lustre_try_ffi.erl (limited to 'src') diff --git a/src/http_ffi.erl b/src/http_ffi.erl deleted file mode 100644 index 1261b13..0000000 --- a/src/http_ffi.erl +++ /dev/null @@ -1,117 +0,0 @@ --module(http_ffi). --export([exec/1, serve/3, response_default_headers/0]). - -exec(Command) -> - os:cmd(binary_to_list(Command)). - -serve({options, Host, Port, IncludeStyles}, OnStart, OnPortTaken) -> - {ok, Pattern} = re:compile("name *= *\"(?.+)\""), - {ok, Toml} = file:read_file("gleam.toml"), - {match, [Name]} = re:run(Toml, Pattern, [{capture, all_names, binary}]), - - Html = - << - "\n" - "\n" - "\n" - " \n" - " \n" - " Lustre preview server\n", - case IncludeStyles of - true -> - <<" \n">>; - false -> - <<"">> - end/binary, - " \n" - "\n" - "\n" - "
\n" - "\n" - "" - >>, - - file:write_file("build/dev/javascript/index.html", Html), - - AbsPath = - string:trim( - filename:absname("build/dev/javascript"), trailing, "/." - ), - - inets:start(), - Address = {127, 0, 0, 1}, - - ActualPort = - case port_available(Port) of - true -> - Port; - false -> - OnPortTaken(Port), - first_available_port(Port + 1) - end, - - {ok, Pid} = - httpd:start_service([ - {bind_address, Address}, - {document_root, AbsPath}, - {server_root, AbsPath}, - {directory_index, ["index.html"]}, - {server_name, binary_to_list(Host)}, - {port, ActualPort}, - {default_type, "text/html"}, - {mime_types, mime_types()}, - {customize, ?MODULE}, - {modules, [mod_alias, mod_dir, mod_get]} - ]), - - OnStart(ActualPort), - - receive - {From, shutdown} -> - ok = httpd:stop_service(Pid), - From ! done - end. - -port_available(Port) -> - case gen_tcp:listen(Port, []) of - {ok, Sock} -> - ok = gen_tcp:close(Sock), - true; - _ -> - false - end. - -first_available_port(Port) -> - case port_available(Port) of - true -> Port; - false -> first_available_port(Port + 1) - end. - -mime_types() -> - [ - {"html", "text/html"}, - {"htm", "text/html"}, - {"js", "text/javascript"}, - {"mjs", "text/javascript"}, - {"css", "text/css"}, - {"gif", "image/gif"}, - {"jpg", "image/jpeg"}, - {"jpeg", "image/jpeg"}, - {"png", "image/png"} - ]. - -response_default_headers() -> - [ - {"cache-control", "no-store, no-cache, must-revalidate, private"}, - {"pragma", "no-cache"} - ]. diff --git a/src/lustre.gleam b/src/lustre.gleam index 3151a1a..7aeb6b4 100644 --- a/src/lustre.gleam +++ b/src/lustre.gleam @@ -158,16 +158,45 @@ // IMPORTS --------------------------------------------------------------------- +import argv import gleam/bool import gleam/dict.{type Dict} -import gleam/dynamic.{type Decoder} +import gleam/dynamic.{type Decoder, type Dynamic} import gleam/erlang/process.{type Subject} import gleam/otp/actor.{type StartError} import gleam/result +import glint +import lustre/cli/add +import lustre/cli/build +import lustre/cli/try import lustre/effect.{type Effect} import lustre/element.{type Element, type Patch} import lustre/internals/runtime +// MAIN ------------------------------------------------------------------------ + +/// This function exists so you can run helpful Lustre utilities from the command +/// line using `gleam run -m lustre`. For a proper help message run: +/// +/// ```sh +/// gleam run -m lustre -- --help +/// ``` +/// +/// 🚨 If you're just using Lustre as a library, *you can ignore this function*. +/// +pub fn main() { + let args = argv.load().arguments + + glint.new() + |> glint.as_gleam_module + |> glint.with_name("lustre") + |> glint.add(at: ["add", "esbuild"], do: add.esbuild()) + |> glint.add(at: ["build", "app"], do: build.app()) + |> glint.add(at: ["build", "component"], do: build.component()) + |> glint.add(at: ["try"], do: try.run()) + |> glint.run(args) +} + // TYPES ----------------------------------------------------------------------- /// Represents a constructed Lustre application that is ready to be started. diff --git a/src/lustre/cli/add.gleam b/src/lustre/cli/add.gleam new file mode 100644 index 0000000..f1a426a --- /dev/null +++ b/src/lustre/cli/add.gleam @@ -0,0 +1,67 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/io +import gleam/option +import gleam/result +import gleam/string +import glint.{type Command, CommandInput} +import glint/flag +import lustre/cli/esbuild + +// MAIN ------------------------------------------------------------------------ + +pub fn esbuild() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(args: _, flags: flags, named_args: _) = input + let os = option.from_result(flag.get_string(flags, os_flag_name)) + let cpu = option.from_result(flag.get_string(flags, cpu_flag_name)) + + esbuild.download(os, cpu) + |> result.map_error(explain) + |> result.replace(Nil) + |> result.unwrap_both + }) + |> glint.flag(os_flag_name, os_flag()) + |> glint.flag(cpu_flag_name, cpu_flag()) +} + +// GLINT FLAGS ----------------------------------------------------------------- + +const os_flag_name = "os" + +fn os_flag() { + flag.string() + |> flag.description("The host to run the server on") +} + +const cpu_flag_name = "cpu" + +fn cpu_flag() { + flag.string() + |> flag.description("The port to run the server on") +} + +// UTILS ----------------------------------------------------------------------- + +fn explain(error: esbuild.Error) -> Nil { + case error { + esbuild.NetworkError(_) -> + "🚨 A network error occured. Check your connection and try again " + |> string.pad_right(78, ".") + |> string.append(" ❌") + esbuild.SimplifileError(_error, path) -> + "🚨 An unknown error occured while writing the executable to `{path}` " + |> string.replace(each: "{path}", with: path) + |> string.pad_right(78, ".") + |> string.append(" ❌") + esbuild.UnknownPlatform(os, cpu) -> + { "🚨 Could not locate download url for " <> os <> "/" <> cpu <> " " } + |> string.pad_right(78, ".") + |> string.append(" ❌") + esbuild.UnzipError(_error) -> + "🚨 An unknown error occured while extracting the archive " + |> string.pad_right(78, ".") + |> string.append(" ❌") + } + |> io.println +} diff --git a/src/lustre/cli/build.gleam b/src/lustre/cli/build.gleam new file mode 100644 index 0000000..e5a45c4 --- /dev/null +++ b/src/lustre/cli/build.gleam @@ -0,0 +1,226 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam/dict.{type Dict} +import gleam/io +import gleam/option.{type Option, None} +import gleam/result +import gleam/list +import gleam/string +import glint.{type Command, CommandInput} +import glint/flag.{type Flag} +import filepath +import simplifile +import lustre/cli/project +import lustre/cli/esbuild + +// TYPES ----------------------------------------------------------------------- + +type Error { + MissingComponentPath + MissingModule(module: String) + /// In case a module is provided that doesn't expose a register function. + MissingRegisterFunction(module: String) + /// In case a module exposes a register function, but with an unexpected type. + /// TODO: for now the type is just a string, later it's going to be a type + /// provided by the package interface decoder. + RegisterFunctionWithWrongType(type_: String) + CompileError + CannotDownloadEsbuild(error: esbuild.Error) + CannotBundleComponents(error: esbuild.Error) + CannotPerformCleanup(temp_file: String) + CannotWriteTempFile(reason: simplifile.FileError, temp_file: String) +} + +// MAIN ------------------------------------------------------------------------ + +pub fn component() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(args: args, flags: flags, named_args: _) = input + let assert Ok(minify) = flag.get_bool(flags, minify_flag_name) + + build_component(args, minify) + |> result.map_error(explain) + |> result.unwrap(Nil) + }) + |> glint.flag(minify_flag_name, minify_flag()) + |> glint.count_args(glint.MinArgs(1)) +} + +pub fn app() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(args: _, flags: flags, named_args: named_args) = input + let assert Ok(minify) = flag.get_bool(flags, minify_flag_name) + let module = option.from_result(dict.get(named_args, module_named_arg)) + + build_app(module, minify) + |> result.map_error(explain) + |> result.unwrap(Nil) + }) + |> glint.flag(minify_flag_name, minify_flag()) + |> glint.named_args([module_named_arg]) +} + +// GLINT FLAGS ----------------------------------------------------------------- + +const module_named_arg = "module" + +const minify_flag_name = "minify" + +fn minify_flag() -> flag.FlagBuilder(Bool) { + flag.bool() + |> flag.default(False) + |> flag.description(string.join( + [ + "A minified bundle renames variables to shorter names and obfuscates the code.", + "Minified bundles are always emitted with the `.min.mjs` extension.", + ], + " ", + )) +} + +// BUILD COMPONENT ------------------------------------------------------------- + +fn build_component(modules: List(String), minify: Bool) -> Result(Nil, Error) { + // Build the project to make sure it doesn't have any compile errors. + let compile = result.replace_error(project.build(), CompileError) + use compiled <- result.try(compile) + let configuration = project.read_configuration(compiled) + + // Ensure that all modules we're going to bundle actually expose the correct + // `register` function needed to register a component. + use _ <- result.try(list.try_each(modules, check_can_be_bundled)) + + // Figure out the outfile name based on the number of modules to bundle. + let output_file_name = case modules { + [module] -> { + let component_name = string.replace(module, each: "/", with: "_") + configuration.name <> "-" <> component_name + } + _ -> configuration.name <> "-components" + } + + let priv = filepath.join(project.root_folder(), "priv") + + components_script(configuration.name, modules) + |> bundle_script(minify, in: priv, named: output_file_name) +} + +fn check_can_be_bundled(_module_name: String) -> Result(Nil, Error) { + // let package_interface = todo + // let find_module = + // dict.get(package_interface.modules, module_name) + // |> result.replace_error(MissingModule(module_name)) + // use module <- result.try(find_module) + // let find_register_function = + // dict.get(module.functions, "register") + // |> result.replace_error(MissingRegisterFunction(module_name)) + // use register_function <- result.try(find_register_function) + // check_returns_component(register_function) + + // ^^^ We will be able to do all the above once the compiler exposes the + // package interface, for now we just assume everything's ok. + Ok(Nil) +} + +/// Generates the script that will be bundled, exposing the `register` function +/// of each one of the provided modules. +/// +fn components_script(project_name: String, modules: List(String)) -> String { + use script, module <- list.fold(over: modules, from: "") + let module_path = + "../build/dev/javascript/" <> project_name <> "/" <> module <> ".mjs" + + let alias = "register-" <> string.replace(each: "\\", with: "-", in: module) + let export = + "export { register as " <> alias <> " } from \"" <> module_path <> "\"" + let register = alias <> "();" + script <> "\n" <> export <> "\n" <> register +} + +// BUILD APP ------------------------------------------------------------------- + +fn build_app(module: Option(String), minify: Bool) -> Result(Nil, Error) { + // Build the project to make sure it doesn't have any compile errors. + let compile = result.replace_error(project.build(), CompileError) + use compiled <- result.try(compile) + let configuration = project.read_configuration(compiled) + + // If the module is missing we use the module with the same name as the + // project as a fallback. + let module = option.lazy_unwrap(module, fn() { configuration.name }) + use _ <- result.try(check_app_can_be_bundled(module)) + + let priv = filepath.join(project.root_folder(), "priv") + + app_script(configuration.name, module) + |> bundle_script(minify, in: priv, named: module <> "-app") +} + +fn check_app_can_be_bundled(_module: String) -> Result(Nil, Error) { + // Once we have the package interface we'll be able to check if the module + // actually exposes a function with the appropriate type etc. + // For now we just assume the user knows what they're doing. + Ok(Nil) +} + +fn app_script(project_name: String, module: String) -> String { + let module_path = + "../build/dev/javascript/" <> project_name <> "/" <> module <> ".mjs" + let import_main = "import { main } from \"" <> module_path <> "\"" + let invoke_main = "main();" + + import_main <> "\n" <> invoke_main +} + +// UTILS ----------------------------------------------------------------------- + +fn explain(error: Error) -> Nil { + case error { + _ -> { + io.debug(error) + todo as "explain the error" + } + } + |> string.pad_right(78, ".") + |> string.append(" ❌") + |> io.println +} + +fn bundle_script( + script: String, + minify: Bool, + in folder: String, + named output_file: String, +) -> Result(Nil, Error) { + // First, let's make sure there's the esbuild executable that can be used. + use esbuild <- result.try( + esbuild.download(None, None) + |> result.map_error(CannotDownloadEsbuild), + ) + + let output_file = filepath.join(folder, output_file) + + // Write the script to bundle to a temporary file that is going to be bundled + // by escript. + let temp_file = filepath.join(folder, "temp-bundle-input") + use _ <- result.try( + simplifile.write(script, to: temp_file) + |> result.map_error(CannotWriteTempFile(_, temp_file)), + ) + + let bundle_result = + esbuild.bundle(esbuild, temp_file, output_file, minify) + |> result.map_error(CannotBundleComponents) + + // Regardless of the result of the bundling process we delete the temporary + // input file. + case bundle_result { + Ok(Nil) -> + simplifile.delete(temp_file) + |> result.replace_error(CannotPerformCleanup(temp_file)) + Error(error) -> { + let _ = simplifile.delete(temp_file) + Error(error) + } + } +} diff --git a/src/lustre/cli/esbuild.gleam b/src/lustre/cli/esbuild.gleam new file mode 100644 index 0000000..b0e9fd2 --- /dev/null +++ b/src/lustre/cli/esbuild.gleam @@ -0,0 +1,155 @@ +import gleam/dynamic.{type Dynamic} +import gleam/option.{type Option} +import gleam/result +import gleam/set +import gleam/string +import filepath +import shellout +import simplifile.{type FilePermissions, Execute, FilePermissions, Read, Write} +import lustre/cli/project + +// CONSTANTS ------------------------------------------------------------------- + +pub const executable_name = "esbuild" + +// TYPES ----------------------------------------------------------------------- + +pub type Error { + NetworkError(Dynamic) + SimplifileError(reason: simplifile.FileError, path: String) + UnknownPlatform(os: String, cpu: String) + UnzipError(Dynamic) + BundleError(message: String) +} + +pub opaque type Executable { + Executable +} + +// DOWNLOAD ESBUILD ------------------------------------------------------------ + +/// Download the esbuild executable for the given os and cpu. If those options +/// are not provided it tries detecting the system's os and cpu. +/// +/// The executable will be at the project's root in the `priv/bin/esbuild` +/// folder. +/// +/// Returns a proof that esbuild was successfully downloaded that can be used +/// to run all sort of things. +/// +pub fn download( + os: Option(String), + cpu: Option(String), +) -> Result(Executable, Error) { + let os = option.unwrap(os, get_os()) + let cpu = option.unwrap(cpu, get_cpu()) + let base = "https://registry.npmjs.org/@esbuild/" + let path = case os, cpu { + "android", "arm" -> Ok("android-arm/-/android-arm-0.19.10.tgz") + "android", "arm64" -> Ok("android-arm64/-/android-arm64-0.19.10.tgz") + "android", "x64" -> Ok("android-x64/-/android-x64-0.19.10.tgz") + + "darwin", "aarch64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") + "darwin", "amd64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") + "darwin", "arm64" -> Ok("darwin-arm64/-/darwin-arm64-0.19.10.tgz") + "darwin", "x86_64" -> Ok("darwin-x64/-/darwin-x64-0.19.10.tgz") + + "freebsd", "arm64" -> Ok("freebsd-arm64/-/freebsd-arm64-0.19.10.tgz") + "freebsd", "x64" -> Ok("freebsd-x64/-/freebsd-x64-0.19.10.tgz") + + "linux", "arm" -> Ok("linux-arm/-/linux-arm-0.19.10.tgz") + "linux", "arm64" -> Ok("linux-arm64/-/linux-arm64-0.19.10.tgz") + "linux", "ia32" -> Ok("linux-ia32/-/linux-ia32-0.19.10.tgz") + "linux", "x64" -> Ok("linux-x64/-/linux-x64-0.19.10.tgz") + "linux", "x86_64" -> Ok("linux-x64/-/linux-x64-0.19.10.tgz") + + "win32", "arm64" -> Ok("win32-arm64/-/win32-arm64-0.19.10.tgz") + "win32", "ia32" -> Ok("win32-ia32/-/win32-ia32-0.19.10.tgz") + "win32", "x64" -> Ok("win32-x64/-/win32-x64-0.19.10.tgz") + "win32", "x86_64" -> Ok("win32-x64/-/win32-x64-0.19.10.tgz") + + "netbsd", "x64" -> Ok("netbsd-x64/-/netbsd-x64-0.19.10.tgz") + "openbsd", "x64" -> Ok("openbsd-x64/-/openbsd-x64-0.19.10.tgz") + "sunos", "x64" -> Ok("sunos-x64/-/sunos-x64-0.19.10.tgz") + + _, _ -> Error(UnknownPlatform(os, cpu)) + } + use url <- result.try(result.map(path, string.append(base, _))) + use tarball <- result.try(get_esbuild(url)) + + let destination_folder = + project.root_folder() + |> filepath.join("priv") + |> filepath.join("bin") + let esbuild_path = filepath.join(destination_folder, executable_name) + + let _ = simplifile.create_directory_all(destination_folder) + use esbuild <- result.try(unzip_esbuild(tarball)) + use _ <- result.try( + esbuild_path + |> simplifile.write_bits(esbuild) + |> result.map_error(SimplifileError(_, esbuild_path)), + ) + + let permissions = + FilePermissions( + user: set.from_list([Read, Write, Execute]), + group: set.from_list([Read, Execute]), + other: set.from_list([Read, Execute]), + ) + use _ <- result.try( + esbuild_path + |> simplifile.set_permissions(permissions) + |> result.map_error(SimplifileError(_, esbuild_path)), + ) + + Ok(Executable) +} + +// BUNDLE ---------------------------------------------------------------------- + +/// Bundles the given contents. The command will run in the project's root. +/// +pub fn bundle( + _esbuild: Executable, + input_file: String, + output_file: String, + minify: Bool, +) -> Result(Nil, Error) { + let flags = [ + "--bundle", + // The format is always "esm", we're not encouraging "cjs". + "--format=esm", + "--outfile=" <> output_file, + ] + + let options = case minify { + True -> [input_file, "--minify", ..flags] + False -> [input_file, ..flags] + } + + shellout.command( + run: filepath.join(".", "priv") + |> filepath.join("bin") + |> filepath.join(executable_name), + in: project.root_folder(), + with: options, + opt: [], + ) + |> result.replace(Nil) + |> result.map_error(fn(error) { BundleError(error.1) }) +} + +// EXTERNALS ------------------------------------------------------------------- + +@external(erlang, "lustre_add_ffi", "get_os") +fn get_os() -> String + +@external(erlang, "lustre_add_ffi", "get_cpu") +fn get_cpu() -> String + +@external(erlang, "lustre_add_ffi", "get_esbuild") +fn get_esbuild(url: String) -> Result(BitArray, Error) + +@external(erlang, "lustre_add_ffi", "unzip_esbuild") +fn unzip_esbuild(tarball: BitArray) -> Result(BitArray, Error) diff --git a/src/lustre/cli/project.gleam b/src/lustre/cli/project.gleam new file mode 100644 index 0000000..0b7287e --- /dev/null +++ b/src/lustre/cli/project.gleam @@ -0,0 +1,63 @@ +import gleam/dict.{type Dict} +import gleam/result +import filepath +import shellout +import simplifile +import tom.{type Toml} + +/// The configuration of a Gleam project +pub type Configuration { + Configuration(name: String, version: String, toml: Dict(String, Toml)) +} + +/// A proof that the project was compiled successfully. +/// +pub opaque type Compiled { + Compiled +} + +/// Compile the current project running the `gleam build` command. +/// +pub fn build() -> Result(Compiled, Nil) { + shellout.command( + run: "gleam", + in: ".", + with: ["build", "--target=js"], + opt: [], + ) + |> result.nil_error() + |> result.replace(Compiled) +} + +/// Read the project configuration in the `gleam.toml` file. +/// To call this function it's necessary to provide a proof that the project +/// was successfully built. To do so you can use the `build` function. +/// +pub fn read_configuration(_compiled: Compiled) -> Configuration { + // Since we have proof that the project could compile we're sure that there is + // bound to be a `gleam.toml` file somewhere in the current directory (or in + // its parent directories). So we can safely call `recursive_lookup` without + // it looping indefinitely. + let configuration_path = filepath.join(root_folder(), "gleam.toml") + // All these operations are safe to assert because the Gleam project wouldn't + // compile if any of this stuff was invalid. + let assert Ok(configuration) = simplifile.read(configuration_path) + let assert Ok(toml) = tom.parse(configuration) + let assert Ok(name) = tom.get_string(toml, ["name"]) + let assert Ok(version) = tom.get_string(toml, ["version"]) + Configuration(name: name, version: version, toml: toml) +} + +/// Finds the path leading to the project's root folder. +/// +pub fn root_folder() -> String { + do_root_folder(".") +} + +fn do_root_folder(path: String) -> String { + let toml = filepath.join(path, "gleam.toml") + case simplifile.verify_is_file(toml) { + Ok(False) | Error(_) -> do_root_folder(filepath.join("..", path)) + Ok(True) -> path + } +} diff --git a/src/lustre/cli/try.gleam b/src/lustre/cli/try.gleam new file mode 100644 index 0000000..42f3854 --- /dev/null +++ b/src/lustre/cli/try.gleam @@ -0,0 +1,108 @@ +// IMPORTS --------------------------------------------------------------------- + +import gleam_community/ansi +import gleam/int +import gleam/io +import gleam/result +import glint.{type Command, CommandInput} +import glint/flag +import lustre/cli/project + +// TYPES ----------------------------------------------------------------------- + +type Options { + /// It's important to remember that for Erlang, Gleam records have their field + /// names erased and they degenerate to tuples. This means that the order of + /// the fields is important! + Options(name: String, host: String, port: Int, no_styles: Bool) +} + +type Error { + CompileError +} + +// MAIN ------------------------------------------------------------------------ + +pub fn run() -> Command(Nil) { + glint.command(fn(input) { + let CommandInput(_, flags, _) = input + let assert Ok(port) = flag.get_int(flags, port_flag_name) + let assert Ok(host) = flag.get_string(flags, host_flag_name) + let assert Ok(no_styles) = flag.get_bool(flags, no_styles_flag_name) + + let result = { + let compile = result.replace_error(project.build(), CompileError) + use compiled <- result.try(compile) + let configuration = project.read_configuration(compiled) + let options = Options(configuration.name, host, port, no_styles) + serve(options, on_start(host, _), on_port_taken) + Ok(Nil) + } + + case result { + Ok(_) -> Nil + Error(error) -> explain(error) + } + }) + |> glint.flag(host_flag_name, host_flag()) + |> glint.flag(port_flag_name, port_flag()) + |> glint.flag(no_styles_flag_name, no_styles_flag()) +} + +// UTILS ----------------------------------------------------------------------- + +fn explain(error: Error) -> Nil { + case error { + CompileError -> Nil + } +} + +// GLINT FLAGS ----------------------------------------------------------------- + +const host_flag_name = "host" + +fn host_flag() { + flag.string() + |> flag.default("localhost") + |> flag.description("The host to run the server on.") +} + +const port_flag_name = "port" + +fn port_flag() { + flag.int() + |> flag.default(1234) + |> flag.description("The port to run the server on.") +} + +const no_styles_flag_name = "no-styles" + +fn no_styles_flag() { + flag.bool() + |> flag.default(False) + |> flag.description("When false, lustre/ui's styles will not be included.") +} + +// UTILS ----------------------------------------------------------------------- + +fn on_start(host: String, port: Int) -> Nil { + let address = "http://" <> host <> ":" <> int.to_string(port) + io.println("✨ Server has been started at " <> ansi.bold(address)) +} + +fn on_port_taken(port) -> Nil { + io.println( + "🚨 Port " + <> ansi.bold(int.to_string(port)) + <> " already in use, using next available port", + ) +} + +// EXTERNALS ------------------------------------------------------------------- + +@external(erlang, "lustre_try_ffi", "serve") +fn serve( + options: Options, + on_start: fn(Int) -> Nil, + on_port_taken: fn(Int) -> Nil, +) -> Nil diff --git a/src/lustre/try.gleam b/src/lustre/try.gleam deleted file mode 100644 index 177da97..0000000 --- a/src/lustre/try.gleam +++ /dev/null @@ -1,100 +0,0 @@ -// IMPORTS --------------------------------------------------------------------- - -import argv -import gleam_community/ansi -import gleam/int -import gleam/io -import glint.{CommandInput} -import glint/flag - -// TYPES ----------------------------------------------------------------------- - -type Options { - /// It's important to remember that for Erlang, Gleam records have their field - /// names erased and they degenerate to tuples. This means that the order of - /// the fields is important! - Options(host: String, port: Int, include_styles: Bool) -} - -// MAIN ------------------------------------------------------------------------ - -pub fn main() { - let args = argv.load().arguments - let program = - glint.new() - // There's an open issue on the glint repo to have the generated help text - // include the `gleam run -m ` prefix. If/until that's addressed, we can kind - // of hack it by telling glint the name of the program is the full command. - // - // See: https://github.com/TanklesXL/glint/issues/23 - // - |> glint.with_name("gleam run -m lustre/try") - |> glint.with_pretty_help(glint.default_pretty_help()) - |> glint.add( - at: [], - do: glint.command(fn(input) { - let CommandInput(_, flags) = input - let assert Ok(port) = flag.get_int(flags, "port") - let assert Ok(host) = flag.get_string(flags, "host") - let assert Ok(include_styles) = flag.get_bool(flags, "include-styles") - let options = Options(host, port, include_styles) - - exec("gleam build --target js") - serve(options, on_start(host, _), on_port_taken) - }) - |> glint.flag("host", host_flag()) - |> glint.flag("port", port_flag()) - |> glint.flag("include-styles", include_styles_flag()), - ) - - glint.run(program, args) -} - -// GLINT FLAGS ----------------------------------------------------------------- - -fn host_flag() { - flag.string() - |> flag.default("localhost") - |> flag.description("The host to run the server on") -} - -fn port_flag() { - flag.int() - |> flag.default(1234) - |> flag.description("The port to run the server on") -} - -fn include_styles_flag() { - flag.bool() - |> flag.default(False) - |> flag.description("Include lustre_ui's default stylesheet in your app.") -} - -// UTILS ----------------------------------------------------------------------- - -fn on_start(host: String, port: Int) -> Nil { - let address = "http://" <> host <> ":" <> int.to_string(port) - io.println("✨ Server has been started at " <> ansi.bold(address)) -} - -fn on_port_taken(port) -> Nil { - io.println( - "🚨 Port " - <> ansi.bold(int.to_string(port)) - <> " already in use, using next available port", - ) -} - -// EXTERNALS ------------------------------------------------------------------- - -@external(erlang, "http_ffi", "serve") -@external(javascript, "../http.ffi.mjs", "serve") -fn serve( - options: Options, - on_start: fn(Int) -> Nil, - on_port_taken: fn(Int) -> Nil, -) -> Nil - -@external(erlang, "http_ffi", "exec") -@external(javascript, "node:child_process", "execSync") -fn exec(command: String) -> String diff --git a/src/lustre_add_ffi.erl b/src/lustre_add_ffi.erl new file mode 100644 index 0000000..d7c5030 --- /dev/null +++ b/src/lustre_add_ffi.erl @@ -0,0 +1,49 @@ +-module(lustre_add_ffi). +-export([ + get_cpu/0, + get_esbuild/1, + get_os/0, + unzip_esbuild/1 +]). + +get_os() -> + case os:type() of + {win32, _} -> <<"win32">>; + {unix, darwin} -> <<"darwin">>; + {unix, linux} -> <<"linux">>; + {_, Unknown} -> atom_to_binary(Unknown, utf8) + end. + +get_cpu() -> + case erlang:system_info(os_type) of + {unix, _} -> + [Arch, _] = string:split(erlang:system_info(system_architecture), "-"), + list_to_binary(Arch); + {win32, _} -> + case erlang:system_info(wordsize) of + 4 -> {ok, <<"ia32">>}; + 8 -> {ok, <<"x64">>} + end + end. + +get_esbuild(Url) -> + inets:start(), + ssl:start(), + + case httpc:request(get, {Url, []}, [], [{body_format, binary}]) of + {ok, {{_, 200, _}, _, Zip}} -> {ok, Zip}; + {ok, Res} -> {error, {network_error, Res}}; + {error, Err} -> {error, {network_error, Err}} + end. + +unzip_esbuild(Zip) -> + Result = + erl_tar:extract({binary, Zip}, [ + memory, compressed, {files, ["package/bin/esbuild"]} + ]), + + case Result of + {ok, [{_, Esbuild}]} -> {ok, Esbuild}; + {ok, Res} -> {error, {unzip_error, Res}}; + {error, Err} -> {error, {unzip_error, Err}} + end. diff --git a/src/lustre_build_ffi.erl b/src/lustre_build_ffi.erl new file mode 100644 index 0000000..9a7e280 --- /dev/null +++ b/src/lustre_build_ffi.erl @@ -0,0 +1,9 @@ +-module(lustre_build_ffi). +-export([ + exec/1 +]). + +exec(Cmd) -> + Stdout = os:cmd(unicode:characters_to_list(Cmd)), + + unicode:characters_to_binary(Stdout). diff --git a/src/lustre_try_ffi.erl b/src/lustre_try_ffi.erl new file mode 100644 index 0000000..0209c14 --- /dev/null +++ b/src/lustre_try_ffi.erl @@ -0,0 +1,115 @@ +-module(lustre_try_ffi). +-export([serve/3, response_default_headers/0, exec/1]). + +serve({options, Name, Host, Port, NoStyles}, OnStart, OnPortTaken) -> + Html = + << + "\n" + "\n" + "\n" + " \n" + " \n" + " Lustre preview server\n", + case NoStyles of + true -> + <<"">>; + false -> + <<" \n">> + end/binary, + " \n" + "\n" + "\n" + "
\n" + "\n" + "" + >>, + + file:write_file("build/dev/javascript/index.html", Html), + + AbsPath = + string:trim( + filename:absname("build/dev/javascript"), trailing, "/." + ), + + inets:start(), + Address = {127, 0, 0, 1}, + + ActualPort = + case port_available(Port) of + true -> + Port; + false -> + OnPortTaken(Port), + first_available_port(Port + 1) + end, + + {ok, Pid} = + httpd:start_service([ + {bind_address, Address}, + {document_root, AbsPath}, + {server_root, AbsPath}, + {directory_index, ["index.html"]}, + {server_name, binary_to_list(Host)}, + {port, ActualPort}, + {default_type, "text/html"}, + {mime_types, mime_types()}, + {customize, ?MODULE}, + {modules, [mod_alias, mod_dir, mod_get]} + ]), + + OnStart(ActualPort), + + receive + {From, shutdown} -> + ok = httpd:stop_service(Pid), + From ! done + end. + +port_available(Port) -> + case gen_tcp:listen(Port, []) of + {ok, Sock} -> + ok = gen_tcp:close(Sock), + true; + _ -> + false + end. + +first_available_port(Port) -> + case port_available(Port) of + true -> Port; + false -> first_available_port(Port + 1) + end. + +mime_types() -> + [ + {"html", "text/html"}, + {"htm", "text/html"}, + {"js", "text/javascript"}, + {"mjs", "text/javascript"}, + {"css", "text/css"}, + {"gif", "image/gif"}, + {"jpg", "image/jpeg"}, + {"jpeg", "image/jpeg"}, + {"png", "image/png"} + ]. + +response_default_headers() -> + [ + {"cache-control", "no-store, no-cache, must-revalidate, private"}, + {"pragma", "no-cache"} + ]. + +exec(Cmd) -> + Stdout = os:cmd(unicode:characters_to_list(Cmd)), + + unicode:characters_to_binary(Stdout). -- cgit v1.2.3