Skip to content

Commit 81232c9

Browse files
committed
feat: implement complete Deno runtime support for Phoenix.React
- Add Phoenix.React.Runtime.Deno module with full Bun feature parity - Implement Deno 2.x compatible bundling using deno compile - Add development mode with source files and hot reloading - Add production mode with standalone binary compilation - Support JSX file extensions for proper React parsing - Implement npm package support via --node-modules-dir flag - Add comprehensive test suite (17 test cases) - Update documentation and README with Deno configuration - Add environment variable switching between Bun and Deno runtimes - Fix import path resolution for relative module imports - Update react_demo to support both runtimes via REACT_RUNTIME env var
1 parent aed4751 commit 81232c9

File tree

22 files changed

+4310
-24
lines changed

22 files changed

+4310
-24
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

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/deno.ex

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
throw("component_base dir is not exists: #{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+
IO.inspect(error)
102+
catch
103+
error ->
104+
IO.inspect(error)
105+
end
106+
107+
def find_files(dir) do
108+
find_files(dir, [])
109+
end
110+
111+
defp find_files(dir, acc) do
112+
case File.ls(dir) do
113+
{:ok, entries} ->
114+
entries
115+
|> Enum.reduce(acc, fn entry, acc ->
116+
path = Path.join(dir, entry)
117+
118+
cond do
119+
# Recurse into subdirectories
120+
File.dir?(path) -> find_files(path, acc)
121+
# Collect files
122+
File.regular?(path) -> [path | acc]
123+
true -> acc
124+
end
125+
end)
126+
127+
# Ignore errors (e.g., permission issues)
128+
{:error, _} ->
129+
acc
130+
end
131+
end
132+
end
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { serve } from "https://deno.land/[email protected]/http/server.ts";
2+
import React from "npm:react";
3+
import { renderToReadableStream, renderToString, renderToStaticMarkup } from "npm:react-dom/server";
4+
5+
const __comMap = {};
6+
<%= for {{name, file}, idx} <- Enum.with_index(files) do %>
7+
import { Component as __component_<%= idx %> } from "<%= file %>";
8+
__comMap["<%= name %>"] = __component_<%= idx %>;
9+
<% end %>
10+
11+
const { COMPONENT_BASE, DENO_ENV } = Deno.env.toObject();
12+
13+
const isDev = DENO_ENV === 'development';
14+
15+
const port = parseInt(Deno.env.get("PORT") || "5226");
16+
17+
const handler = async (req) => {
18+
try {
19+
let bodyStream = req.body;
20+
if (isDev) {
21+
const bodyText = await req.text();
22+
console.log('Request: ', req.method, req.url, bodyText);
23+
bodyStream = new ReadableStream({
24+
start(controller) {
25+
controller.enqueue(new TextEncoder().encode(bodyText));
26+
controller.close();
27+
}
28+
});
29+
}
30+
const { url } = req;
31+
const uri = new URL(url);
32+
const { pathname } = uri;
33+
34+
if (pathname.startsWith('/stop')) {
35+
return new Response('{"message":"ok"}', {
36+
headers: {
37+
"Content-Type": "application/json",
38+
},
39+
});
40+
}
41+
42+
if (pathname.startsWith('/render_to_static_markup/')) {
43+
const props = await req.json();
44+
const fileName = pathname.replace(/^\/render_to_static_markup\//, '');
45+
const Component = __comMap[fileName];
46+
if (!Component) {
47+
return new Response(`Not Found, component not found.`, {
48+
status: 404,
49+
headers: {
50+
"Content-Type": "text/html",
51+
},
52+
});
53+
}
54+
const jsxNode = React.createElement(Component, props);
55+
const html = renderToStaticMarkup(jsxNode);
56+
return new Response(html, {
57+
headers: {
58+
"Content-Type": "text/html",
59+
},
60+
});
61+
}
62+
63+
if (pathname.startsWith('/render_to_string/')) {
64+
const props = await req.json();
65+
const fileName = pathname.replace(/^\/render_to_string\//, '');
66+
const Component = __comMap[fileName];
67+
const jsxNode = React.createElement(Component, props);
68+
const html = renderToString(jsxNode);
69+
return new Response(html, {
70+
headers: {
71+
"Content-Type": "text/html",
72+
},
73+
});
74+
}
75+
76+
if (pathname.startsWith('/render_to_readable_stream/')) {
77+
const props = await req.json();
78+
const fileName = pathname.replace(/^\/render_to_readable_stream\//, '');
79+
const Component = __comMap[fileName];
80+
const jsxNode = React.createElement(Component, props);
81+
const stream = await renderToReadableStream(jsxNode);
82+
return new Response(stream, {
83+
headers: {
84+
"Content-Type": "text/html",
85+
},
86+
});
87+
}
88+
89+
return new Response(`Not Found, not matched request.`, {
90+
status: 404,
91+
headers: {
92+
"Content-Type": "text/html",
93+
},
94+
});
95+
} catch(error) {
96+
const html = `
97+
<div role="alert" class="alert alert-error">
98+
<div>
99+
<div class="font-bold">${escapeHtml(error.toString())}</div>
100+
<pre style="white-space: pre-wrap;">${escapeHtml(error.stack || '')}</pre>
101+
</div>
102+
</div>
103+
`;
104+
return new Response(html, {
105+
status: 500,
106+
headers: {
107+
"Content-Type": "text/html",
108+
},
109+
});
110+
}
111+
};
112+
113+
function escapeHtml(unsafe) {
114+
return unsafe
115+
.replace(/&/g, "&amp;")
116+
.replace(/</g, "&lt;")
117+
.replace(/>/g, "&gt;")
118+
.replace(/"/g, "&quot;")
119+
.replace(/'/g, "&#039;");
120+
}
121+
122+
console.log(`Server started at http://localhost:${port}`);
123+
console.log(`COMPONENT_BASE`, COMPONENT_BASE);
124+
console.log(`DENO_ENV`, DENO_ENV);
125+
126+
const ppid = Deno.pid;
127+
const checkParentInterval = setInterval(() => {
128+
try {
129+
// Try to check if parent process still exists
130+
Deno.kill(ppid, "0");
131+
} catch (e) {
132+
console.log("Parent process exited. Shutting down server...");
133+
clearInterval(checkParentInterval);
134+
Deno.exit(0);
135+
}
136+
}, 1000);
137+
138+
const shutdown = async (signal) => {
139+
console.log(`\nReceived ${signal}. Cleaning up...`);
140+
clearInterval(checkParentInterval);
141+
console.log("Cleanup done. Exiting.");
142+
Deno.exit(0);
143+
};
144+
145+
Deno.addSignalListener("SIGINT", () => {
146+
shutdown("SIGINT");
147+
});
148+
149+
Deno.addSignalListener("SIGTERM", () => {
150+
shutdown("SIGTERM");
151+
});
152+
153+
await serve(handler, { port });

0 commit comments

Comments
 (0)