Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Agent Guidelines for Phoenix.React

## Commands
- **Build**: `mix deps.get && npm install`
- **Format**: `mix format` (uses .formatter.exs config)
- **Test**: `mix test` (requires 3s delay for server startup in test_helper.exs)
- **Single test**: `mix test path/to/test.exs:line_number`
- **Bundle React components**: `mix phx.react.bun.bundle --component-base=assets/component --output=priv/react/server.js`

## Code Style
- **Elixir**: Follow standard Elixir conventions, use `mix format`
- **Module names**: PascalCase (e.g., `Phoenix.React.Server`)
- **Function names**: snake_case
- **Types**: Use `@type` and `@spec` for all public functions
- **Error handling**: Return `{:ok, result}` or `{:error, reason}` tuples
- **Imports**: Alias modules at top, use qualified calls when needed
- **GenServer**: Use `@impl true` for callback implementations
- **Documentation**: Include `@moduledoc` and `@doc` for public modules/functions
- **React components**: Export `Component` function, use JSX syntax in .js files
39 changes: 38 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,44 @@ config :phoenix_react_server, Phoenix.React,
Supported `runtime`

- [x] `Phoenix.React.Runtime.Bun`
- [ ] `Phoenix.React.Runtime.Deno`
- [x] `Phoenix.React.Runtime.Deno`

### Using Deno Runtime

To use Deno instead of Bun, configure the runtime and its specific settings:

```elixir
config :phoenix_react_server, Phoenix.React,
runtime: Phoenix.React.Runtime.Deno,
component_base: Path.expand("../assets/component", __DIR__),
cache_ttl: 60

# Deno-specific configuration
config :phoenix_react_server, Phoenix.React.Runtime.Deno,
cmd: System.find_executable("deno"),
server_js: Path.expand("../priv/react/server.js", __DIR__),
port: 5125,
env: :dev # Use :prod for production
```

**Deno Requirements:**
- Deno 2.x (recommended)
- Components must use `.jsx` file extension for proper JSX parsing
- Deno automatically downloads npm packages via `--node-modules-dir` flag

**Environment Variable Switching:**
You can also use environment variable to switch runtimes:

```elixir
runtime =
case System.get_env("REACT_RUNTIME", "bun") do
"bun" -> Phoenix.React.Runtime.Bun
"deno" -> Phoenix.React.Runtime.Deno
_ -> Phoenix.React.Runtime.Bun
end

config :phoenix_react_server, Phoenix.React, runtime: runtime
```

Add Render Server in your application Supervisor tree.

Expand Down
2 changes: 1 addition & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Config
config :phoenix_react_server, Phoenix.React,
runtime: Phoenix.React.Runtime.Bun,
component_base: Path.expand("../test/fixtures", __DIR__),
render_timeout: 5_000,
render_timeout: 10_000,
cache_ttl: 60

config :phoenix_react_server, Phoenix.React.Runtime.Bun, port: 12457
36 changes: 36 additions & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions devenv.nix
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ in
languages.javascript.bun.enable = true;
languages.javascript.bun.package = pkgs-stable.bun;

languages.deno.enable = true;
languages.deno.package = pkgs-stable.deno;

scripts.hello.exec = ''
figlet -w 120 $GREET | lolcat
'';
Expand Down
2 changes: 1 addition & 1 deletion lib/phoenix/mix/build/bun.ex
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ defmodule Mix.Tasks.Phx.React.Bun.Bundle do
{basename, abs_path}
end)

quoted = EEx.compile_file("#{__DIR__}/server.js.eex")
quoted = EEx.compile_file("#{__DIR__}/server_bun.eex")
{result, _bindings} = Code.eval_quoted(quoted, files: files, base_dir: base_dir)
tmp_file = "#{cd}/server.js"
File.write!(tmp_file, result)
Expand Down
138 changes: 138 additions & 0 deletions lib/phoenix/mix/build/deno.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
defmodule Mix.Tasks.Phx.React.Deno.Bundle do
@moduledoc """
Create server.js bundle for `deno` runtime,
bundle all components and render server in one file for otp release.

## Usage

```shell
mix phx.react.deno.bundle --component-base=assets/component --output=priv/react/server.js
```

"""
require Logger

use Mix.Task

@shortdoc "Bundle components into server.js"
def run(args) do
{opts, _argv} =
OptionParser.parse!(args, strict: [component_base: :string, output: :string, cd: :string])

component_base = Keyword.get(opts, :component_base)
base_dir = Path.absname(component_base, File.cwd!()) |> Path.expand()

components =
if File.dir?(base_dir) do
find_files(base_dir)
else
raise ArgumentError, "component_base dir does not exist: #{base_dir}"
end

output = Keyword.get(opts, :output)
Logger.info("Bundle component in directory [#{component_base}] into #{output}")

cd = Keyword.get(opts, :cd, File.cwd!())

# Create JSX files for Deno
jsx_dir = Path.join(Path.dirname(output), "jsx_components")
File.mkdir_p!(jsx_dir)
# Clean up first
File.rm_rf!(jsx_dir)
File.mkdir_p!(jsx_dir)

files =
components
|> Enum.map(fn abs_path ->
filename = Path.relative_to(abs_path, base_dir)
ext = Path.extname(filename)
basename = Path.basename(filename, ext)

# Create JSX version for Deno
jsx_path = Path.join(jsx_dir, "#{basename}.jsx")
File.cp!(abs_path, jsx_path)

{basename, jsx_path}
end)

quoted = EEx.compile_file("#{__DIR__}/server_deno.js.eex")

{result, _bindings} =
Code.eval_quoted(quoted, files: files, base_dir: base_dir, output: output)

_outdir = Path.dirname(output)

if File.exists?(output) do
File.rm!(output)
end

# Check if this is a source file (ends with _source.js) or binary output
if String.ends_with?(output, "_source.js") do
# For development, fix import paths to be relative and write the source file
jsx_dir = Path.join(Path.dirname(output), "jsx_components")
result = String.replace(result, "\"#{jsx_dir}/", "\"./jsx_components/")
File.write!(output, result)
Logger.info("Created Deno source file: #{output}")
else
# For production, create binary
tmp_file = "#{cd}/server_deno.js"
File.write!(tmp_file, result)

# Deno 2.x removed bundle, use compile instead
{out, code} =
System.cmd("deno", ["compile", "--output", output, tmp_file], cd: cd)

Logger.info(~s[cd #{cd}; deno compile --output #{output} #{tmp_file}])
Logger.info("out #{code}: #{out}")

if code != 0 do
throw("deno compile failed(#{code})")
end

File.rm!(tmp_file)
end

# Clean up JSX files only for production builds
unless String.ends_with?(output, "_source.js") do
File.rm_rf!(jsx_dir)
end
rescue
error ->
Logger.error("Build failed: #{Exception.format(:error, error, __STACKTRACE__)}")
reraise error, __STACKTRACE__
catch
:throw, error ->
Logger.error("Build failed: #{inspect(error)}")
throw(error)

:exit, error ->
Logger.error("Build failed: #{inspect(error)}")
exit(error)
end

def find_files(dir) do
find_files(dir, [])
end

defp find_files(dir, acc) do
case File.ls(dir) do
{:ok, entries} ->
entries
|> Enum.reduce(acc, fn entry, acc ->
path = Path.join(dir, entry)

cond do
# Recurse into subdirectories
File.dir?(path) -> find_files(path, acc)
# Collect files
File.regular?(path) -> [path | acc]
true -> acc
end
end)

# Ignore errors (e.g., permission issues)
{:error, _} ->
acc
end
end
end
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { serve, readableStreamToJSON, readableStreamToText, escapeHTML } from 'bun';
import { renderToReadableStream, renderToString, renderToStaticMarkup } from 'react-dom/server';
import React from 'react';

const __comMap = {};
<%= for {{name, file}, idx} <- Enum.with_index(files) do %>
import { Component as __component_<%= idx %> } from "<%= file %>";
__comMap["<%= name %>"] = __component_<%= idx %>;
<% end %>

const { COMPONENT_BASE, BUN_ENV } = process.env;
const { COMPONENT_BASE, BUN_ENV, BUN_PORT } = process.env;

const isDev = BUN_ENV === 'development';

const server = serve({
port: parseInt(BUN_PORT || "5225"),
development: isDev,
async fetch(req) {
try {
Expand Down Expand Up @@ -47,7 +49,7 @@ const server = serve({
},
});
}
const jsxNode = <Component {...props} />;
const jsxNode = React.createElement(Component, props);
const html = renderToStaticMarkup(jsxNode);
return new Response(html, {
headers: {
Expand All @@ -60,7 +62,7 @@ const server = serve({
const props = await readableStreamToJSON(bodyStream);
const fileName = pathname.replace(/^\/render_to_string\//, '');
const Component = __comMap[fileName];
const jsxNode = <Component {...props} />;
const jsxNode = React.createElement(Component, props);
const html = renderToString(jsxNode);
return new Response(html, {
headers: {
Expand All @@ -73,7 +75,7 @@ const server = serve({
const props = await readableStreamToJSON(bodyStream);
const fileName = pathname.replace(/^\/render_to_readable_stream\//, '');
const Component = __comMap[fileName];
const jsxNode = <Component {...props} />;
const jsxNode = React.createElement(Component, props);
const stream = await renderToReadableStream(jsxNode);
return new Response(stream, {
headers: {
Expand Down
Loading