Building an SPA in Phoenix: static HTML with an API

I’m writing an SPA in Phoenix with a GraphQL endpoint at /api. I’d like the following characteristics:

  • No Phoenix views or templating are being used. All HTML is served from priv/static.
  • I want to handle this entirely in Phoenix/Elixir to simplify deployment. No need for a linked web server for static files.
  • The router only handles the /api path, or the /emails path for previewing emails in development mode.
  • If a path isn’t matched from priv/static, priv/static/index.html should be served. This will render a JavaScript router which will either show the correct page, or render a 404 error.
  • For bonus points, it’d be nice if /login detected the presence of /login/index.html and served that.

I have this so far.

  plug Plug.Static,
    at: "/", from: :app, gzip: false

  plug AppWeb.Router

  plug :not_found
  def not_found(conn, _) do
    send_file(conn, 200, "priv/static/index.html")
  end

It works if I access http://localhost:4000/index.html, but if I access / the router kicks in and reports that there’s no route. Maybe this would work if I placed the router after not_found, but then I don’t know how I’d distinguish a router miss from a missing static file.

Additionally, if I access /api, the API returns correctly but I get:

    ** (Plug.Conn.AlreadySentError) the response was already sent

This goes away if I comment out the Plug.Static setup, but that of course undoes all of my work. :slight_smile:

How can I achieve this? Is there a better way? I don’t mind running everything through the router if I can tell it to serve up static files or modify the not_found behavior.

Maybe you can use match macro from Plug.Router?

# after all the other routes
match _ do
  send_file(conn, 200, "priv/static/index.html")
end

Also, is there any reason to use phoenix (without actually using any functionality it provides) and not just plug?

For an SPA I would still suggest using something like nginx for static assets because it at least caches them.

If you are not using the router functionality then don’t even bother with that, just put your handling plug at the bottom of the plug pipeline in your endpoint module and take out the router plug. :slight_smile:

Phoenix is just plugs, it still has useful ones for other purposes, plus if you want websockets or so later that is a huge boon of course, and the pubsub too, and and and… ^.^

Cool, thanks. So how would I modify this router configuration into just plugs?:

defmodule AppWeb.Router do
  use AppWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
    plug AppWeb.Plugs.GraphQLUserContext
  end

  scope "/api" do
    pipe_through :api
    forward "/", Absinthe.Plug.GraphiQL,
      schema: AppWeb.API
  end

  if Mix.env == :dev do
    forward "/emails", Bamboo.EmailPreviewPlug
  end

end

Looks like a series of match and forward calls should do it, but I don’t know what pipe_through translates to, or whether it should come before the match or be inside it. Also, and I don’t know if I’m doing something wrong in this router, but I don’t understand why I need scope "/api" and forward "/". Could I not somehow just say that everything at /api should hit the GraphQL plug?

As for why I’m using Phoenix, mainly for channels, which are used by GraphQL subscriptions. The generators are also nice in terms of giving me a non-magical starting place which I can slowly modify to meet my needs. I also have a nice migration path should I want to switch to a more traditional architecture later.

Maybe something like this

defmodule AppWeb.Router do
  use Plug.Router

  if Mix.env == :dev do
    forward "/emails", to: Bamboo.EmailPreviewPlug
  end

  # graphiql should also be used only for `dev`
  forward "/api", to: Your.GraphiQL.Plug

  match _ do
    # your 404
  end
end
defmodule Your.GraphiQL.Plug do
  use Plug.Router

  plug :accepts, ["json"]
  plug AppWeb.Plugs.GraphiQLUserContext
  
  forward "/", to: Absinthe.Plug.GraphiQL, schema: AppWeb.API
end

As for why I’m using Phoenix, mainly for channels, which are used by GraphQL subscriptions.

I think for graphql subscriptions in the browser all you need is websockets (cowboy) and a pubsub (phoenix_pubsub). And then you can use apollo-subscriptions in the client. I haven’t worked with absinthe-phoenix, but I guess it requires some extra work in order to use it with apollo GitHub - absinthe-graphql/absinthe_phoenix?

This probably doesn’t matter if you don’t plan to use apollo …

Thanks, I’ll give that a shot.

Not sure if this is intentional behavior or a bug, but I’ve discovered that the GraphiQL plug works both as a GraphQL endpoint and as a GraphiQL environment (I.e. GET /api from a browser returns GraphiQL, while Curl/GraphQL clients work as expected.) So this setup works in situations where you want your API to be open and explorable by other users. I actually like the fact that a single URL serves all use cases, from accessing the API via clients to exploring its docs via the browser. If I ever choose to lock down an API then I’d disable GraphiQL, but for most of my projects I don’t really care if someone builds their own interface or accesses my data via the mechanisms I allow.

1 Like

Look at the differences, you are using Absinthe.Plug.GraphiQL instead of Absinthe.Plug

scope "/api" do
  pipe_through :api

  forward "/", Absinthe.Plug,
    schema: AppWeb.Schema
end

scope "/graphiql" do
  pipe_through :api

  forward "/", Absinthe.Plug.GraphiQL,
    schema: AppWeb.Schema
end

Thanks for all the help. This is what I have so far, which has me most of the way where I want to be:

lib/app_web/endpoint.ex:

defmodule AppWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :app

  plug Plug.Logger

  socket "/socket", AppWeb.UserSocket

  # Serve at "/" the static files from "priv/static" directory.
  #
  # You should set gzip to true if you are running phoenix.digest
  # when deploying your static files in production.
  plug Plug.Static,
    at: "/", from: :app, gzip: false

  # Code reloading can be explicitly enabled under the
  # :code_reloader configuration of your endpoint.
  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
  end

  plug Plug.RequestId

  plug Plug.Parsers,
    parsers: [:urlencoded, :multipart, :json],
    pass: ["*/*"],
    json_decoder: Poison

  plug Plug.MethodOverride
  plug Plug.Head

  # The session will be stored in the cookie and signed,
  # this means its contents can be read but not tampered with.
  # Set :encryption_salt if you would also like to encrypt it.
  plug Plug.Session,
    store: :cookie,
    key: "_app_key",
    signing_salt: "ZXpvXeTS"

  plug AppWeb.Router
...

lib/app_web/router.ex:

defmodule AppWeb.Router do
  use AppWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug AppWeb.Plugs.GraphQLUserContext
  end

  scope "/api" do
    pipe_through :api

    forward "/", Absinthe.Plug.GraphiQL,
      schema: AppWeb.API
  end

  if Mix.env == :dev do
    forward "/emails", Bamboo.EmailPreviewPlug
  end

  scope "/", AppWeb do
    pipe_through :browser # Use the default browser stack

    forward "/", Plugs.StaticPlug
  end

end

lib/app_web/plugs/static_plug.ex:

defmodule AppWeb.Plugs.StaticPlug do
  @behaviour Plug
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts), do: send_file(conn, 200, "priv/static/index.html")

end

This serves up static content at /, index.html for anything that isn’t found, and my API/GraphiQL interface at /api. What it doesn’t do is serve up my socket at /socket. I thought putting that before the router/static plugs would match /socket first.

Am I misunderstanding something? I need /socket for channels.

Thanks.

The plot thickens. When I run:

mix phx.new --no-ecto noecto

then run the app, nothing I do to /socket gives me anything resembling a websocket. Curl gives me a “route not found” error. I’ve even tried the script at https://gist.githubusercontent.com/htp/fbce19069187ec1cc486b594104f01d0/raw/f37947a03600dd6b7b643f6372fb4153e96522dc/curl-websocket.sh which claims to let curl interact with websockets, no luck. Everything gives me the default “route not found” error page.

So, in other words, I’m seeing this issue with a default Phoenix app. I feel like I’m missing something obvious.

Thanks.

Can you post a minimal reproducable project on github?

You don’t have any reverse proxy in front of it?

In this instance, the reproduceable project was the mix phx.new --no-ecto ... output.

But I figured it out. I didn’t realize the actual socket endpoint was /socket/websocket, and presumably something else for long-polling. When I hit that, everything worked as expected. I’m now accomplishing most of what I need, except for some issues with live reloading not rebuilding assets, but those should be comparatively easier to fix.

Thanks for everyone’s help.

1 Like

Did you read this Building an SPA in Phoenix: static HTML with an API because you still use Absinthe.Plug.GraphiQL instead of Absinthe.Plug in your latest code.

Did you catch my reply where I mentioned that the GraphiQL plug also works as a GraphQL endpoint? Unless they’ve changed this since February, when I built another app on a slightly different stack but almost identical to this (I.e. I was using Phoenix’s view layer to render the HTML into which my Vue app rendered.) This isn’t my first time building something similar to this, it’s just my first time completely omitting Phoenix’s view layer to do it.

So you can send GraphQL queries to /api, but if you visit /api in a browser then you get the GraphiQL interface.

I think I misunderstood your first message, I thought you didn’t want this behaviour.

If you set it up like I did in the Phoenix router you will see that the GraphiQL interface on /graphiql url will also post the requests to /graphiql instead of /api. The app I’m building even has a if Mix.env == :dev before the graphiql endpoint because it’s a private api.

Ah, got it. Yeah, my intent is to support public, documented APIs by
default, then comment out the GraphiQL endpoint if I don’t need/want
them. This is a side project starter template, and I don’t generally
mind if folks were to build better interfaces atop whatever I make. :slight_smile: