Skip to content

Commit 2c9d34a

Browse files
gsmlgGSMLG-BOT
andauthored
feat: implement complete Deno runtime support for Phoenix.React (#8)
Co-authored-by: Jonathan Gao <[email protected]>
1 parent aed4751 commit 2c9d34a

40 files changed

+5536
-182
lines changed

AGENTS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Agent Guidelines for Phoenix.React
2+
3+
## Commands
4+
- **Build**: `mix deps.get && npm install`
5+
- **Format**: `mix format` (uses .formatter.exs config)
6+
- **Test**: `mix test` (requires 3s delay for server startup in test_helper.exs)
7+
- **Single test**: `mix test path/to/test.exs:line_number`
8+
- **Bundle React components**: `mix phx.react.bun.bundle --component-base=assets/component --output=priv/react/server.js`
9+
10+
## Code Style
11+
- **Elixir**: Follow standard Elixir conventions, use `mix format`
12+
- **Module names**: PascalCase (e.g., `Phoenix.React.Server`)
13+
- **Function names**: snake_case
14+
- **Types**: Use `@type` and `@spec` for all public functions
15+
- **Error handling**: Return `{:ok, result}` or `{:error, reason}` tuples
16+
- **Imports**: Alias modules at top, use qualified calls when needed
17+
- **GenServer**: Use `@impl true` for callback implementations
18+
- **Documentation**: Include `@moduledoc` and `@doc` for public modules/functions
19+
- **React components**: Export `Component` function, use JSX syntax in .js files

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,44 @@ config :phoenix_react_server, Phoenix.React,
4747
Supported `runtime`
4848

4949
- [x] `Phoenix.React.Runtime.Bun`
50-
- [ ] `Phoenix.React.Runtime.Deno`
50+
- [x] `Phoenix.React.Runtime.Deno`
51+
52+
### Using Deno Runtime
53+
54+
To use Deno instead of Bun, configure the runtime and its specific settings:
55+
56+
```elixir
57+
config :phoenix_react_server, Phoenix.React,
58+
runtime: Phoenix.React.Runtime.Deno,
59+
component_base: Path.expand("../assets/component", __DIR__),
60+
cache_ttl: 60
61+
62+
# Deno-specific configuration
63+
config :phoenix_react_server, Phoenix.React.Runtime.Deno,
64+
cmd: System.find_executable("deno"),
65+
server_js: Path.expand("../priv/react/server.js", __DIR__),
66+
port: 5125,
67+
env: :dev # Use :prod for production
68+
```
69+
70+
**Deno Requirements:**
71+
- Deno 2.x (recommended)
72+
- Components must use `.jsx` file extension for proper JSX parsing
73+
- Deno automatically downloads npm packages via `--node-modules-dir` flag
74+
75+
**Environment Variable Switching:**
76+
You can also use environment variable to switch runtimes:
77+
78+
```elixir
79+
runtime =
80+
case System.get_env("REACT_RUNTIME", "bun") do
81+
"bun" -> Phoenix.React.Runtime.Bun
82+
"deno" -> Phoenix.React.Runtime.Deno
83+
_ -> Phoenix.React.Runtime.Bun
84+
end
85+
86+
config :phoenix_react_server, Phoenix.React, runtime: runtime
87+
```
5188

5289
Add Render Server in your application Supervisor tree.
5390

config/test.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Config
33
config :phoenix_react_server, Phoenix.React,
44
runtime: Phoenix.React.Runtime.Bun,
55
component_base: Path.expand("../test/fixtures", __DIR__),
6-
render_timeout: 5_000,
6+
render_timeout: 10_000,
77
cache_ttl: 60
88

99
config :phoenix_react_server, Phoenix.React.Runtime.Bun, port: 12457

deno.lock

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

devenv.nix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ in
2424
languages.javascript.bun.enable = true;
2525
languages.javascript.bun.package = pkgs-stable.bun;
2626

27+
languages.deno.enable = true;
28+
languages.deno.package = pkgs-stable.deno;
29+
2730
scripts.hello.exec = ''
2831
figlet -w 120 $GREET | lolcat
2932
'';

lib/phoenix/mix/build/bun.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ defmodule Mix.Tasks.Phx.React.Bun.Bundle do
4343
{basename, abs_path}
4444
end)
4545

46-
quoted = EEx.compile_file("#{__DIR__}/server.js.eex")
46+
quoted = EEx.compile_file("#{__DIR__}/server_bun.eex")
4747
{result, _bindings} = Code.eval_quoted(quoted, files: files, base_dir: base_dir)
4848
tmp_file = "#{cd}/server.js"
4949
File.write!(tmp_file, result)

lib/phoenix/mix/build/deno.ex

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
defmodule Mix.Tasks.Phx.React.Deno.Bundle do
2+
@moduledoc """
3+
Create server.js bundle for `deno` runtime,
4+
bundle all components and render server in one file for otp release.
5+
6+
## Usage
7+
8+
```shell
9+
mix phx.react.deno.bundle --component-base=assets/component --output=priv/react/server.js
10+
```
11+
12+
"""
13+
require Logger
14+
15+
use Mix.Task
16+
17+
@shortdoc "Bundle components into server.js"
18+
def run(args) do
19+
{opts, _argv} =
20+
OptionParser.parse!(args, strict: [component_base: :string, output: :string, cd: :string])
21+
22+
component_base = Keyword.get(opts, :component_base)
23+
base_dir = Path.absname(component_base, File.cwd!()) |> Path.expand()
24+
25+
components =
26+
if File.dir?(base_dir) do
27+
find_files(base_dir)
28+
else
29+
raise ArgumentError, "component_base dir does not exist: #{base_dir}"
30+
end
31+
32+
output = Keyword.get(opts, :output)
33+
Logger.info("Bundle component in directory [#{component_base}] into #{output}")
34+
35+
cd = Keyword.get(opts, :cd, File.cwd!())
36+
37+
# Create JSX files for Deno
38+
jsx_dir = Path.join(Path.dirname(output), "jsx_components")
39+
File.mkdir_p!(jsx_dir)
40+
# Clean up first
41+
File.rm_rf!(jsx_dir)
42+
File.mkdir_p!(jsx_dir)
43+
44+
files =
45+
components
46+
|> Enum.map(fn abs_path ->
47+
filename = Path.relative_to(abs_path, base_dir)
48+
ext = Path.extname(filename)
49+
basename = Path.basename(filename, ext)
50+
51+
# Create JSX version for Deno
52+
jsx_path = Path.join(jsx_dir, "#{basename}.jsx")
53+
File.cp!(abs_path, jsx_path)
54+
55+
{basename, jsx_path}
56+
end)
57+
58+
quoted = EEx.compile_file("#{__DIR__}/server_deno.js.eex")
59+
60+
{result, _bindings} =
61+
Code.eval_quoted(quoted, files: files, base_dir: base_dir, output: output)
62+
63+
_outdir = Path.dirname(output)
64+
65+
if File.exists?(output) do
66+
File.rm!(output)
67+
end
68+
69+
# Check if this is a source file (ends with _source.js) or binary output
70+
if String.ends_with?(output, "_source.js") do
71+
# For development, fix import paths to be relative and write the source file
72+
jsx_dir = Path.join(Path.dirname(output), "jsx_components")
73+
result = String.replace(result, "\"#{jsx_dir}/", "\"./jsx_components/")
74+
File.write!(output, result)
75+
Logger.info("Created Deno source file: #{output}")
76+
else
77+
# For production, create binary
78+
tmp_file = "#{cd}/server_deno.js"
79+
File.write!(tmp_file, result)
80+
81+
# Deno 2.x removed bundle, use compile instead
82+
{out, code} =
83+
System.cmd("deno", ["compile", "--output", output, tmp_file], cd: cd)
84+
85+
Logger.info(~s[cd #{cd}; deno compile --output #{output} #{tmp_file}])
86+
Logger.info("out #{code}: #{out}")
87+
88+
if code != 0 do
89+
throw("deno compile failed(#{code})")
90+
end
91+
92+
File.rm!(tmp_file)
93+
end
94+
95+
# Clean up JSX files only for production builds
96+
unless String.ends_with?(output, "_source.js") do
97+
File.rm_rf!(jsx_dir)
98+
end
99+
rescue
100+
error ->
101+
Logger.error("Build failed: #{Exception.format(:error, error, __STACKTRACE__)}")
102+
reraise error, __STACKTRACE__
103+
catch
104+
:throw, error ->
105+
Logger.error("Build failed: #{inspect(error)}")
106+
throw(error)
107+
108+
:exit, error ->
109+
Logger.error("Build failed: #{inspect(error)}")
110+
exit(error)
111+
end
112+
113+
def find_files(dir) do
114+
find_files(dir, [])
115+
end
116+
117+
defp find_files(dir, acc) do
118+
case File.ls(dir) do
119+
{:ok, entries} ->
120+
entries
121+
|> Enum.reduce(acc, fn entry, acc ->
122+
path = Path.join(dir, entry)
123+
124+
cond do
125+
# Recurse into subdirectories
126+
File.dir?(path) -> find_files(path, acc)
127+
# Collect files
128+
File.regular?(path) -> [path | acc]
129+
true -> acc
130+
end
131+
end)
132+
133+
# Ignore errors (e.g., permission issues)
134+
{:error, _} ->
135+
acc
136+
end
137+
end
138+
end
Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
import { serve, readableStreamToJSON, readableStreamToText, escapeHTML } from 'bun';
22
import { renderToReadableStream, renderToString, renderToStaticMarkup } from 'react-dom/server';
3+
import React from 'react';
34

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

10-
const { COMPONENT_BASE, BUN_ENV } = process.env;
11+
const { COMPONENT_BASE, BUN_ENV, BUN_PORT } = process.env;
1112

1213
const isDev = BUN_ENV === 'development';
1314

1415
const server = serve({
16+
port: parseInt(BUN_PORT || "5225"),
1517
development: isDev,
1618
async fetch(req) {
1719
try {
@@ -47,7 +49,7 @@ const server = serve({
4749
},
4850
});
4951
}
50-
const jsxNode = <Component {...props} />;
52+
const jsxNode = React.createElement(Component, props);
5153
const html = renderToStaticMarkup(jsxNode);
5254
return new Response(html, {
5355
headers: {
@@ -60,7 +62,7 @@ const server = serve({
6062
const props = await readableStreamToJSON(bodyStream);
6163
const fileName = pathname.replace(/^\/render_to_string\//, '');
6264
const Component = __comMap[fileName];
63-
const jsxNode = <Component {...props} />;
65+
const jsxNode = React.createElement(Component, props);
6466
const html = renderToString(jsxNode);
6567
return new Response(html, {
6668
headers: {
@@ -73,7 +75,7 @@ const server = serve({
7375
const props = await readableStreamToJSON(bodyStream);
7476
const fileName = pathname.replace(/^\/render_to_readable_stream\//, '');
7577
const Component = __comMap[fileName];
76-
const jsxNode = <Component {...props} />;
78+
const jsxNode = React.createElement(Component, props);
7779
const stream = await renderToReadableStream(jsxNode);
7880
return new Response(stream, {
7981
headers: {

0 commit comments

Comments
 (0)