Pattern for smooth SPA development with SWI-Prolog

The gist of this pattern is to use SWI-Prolog to serve your single-page-application (SPA). Then, when your application requests JavaScript that it doesn’t have, it behaves like a proxy, passing the request onto the SPA development server and returning the response.

I use this with ClojureScript and Logtalk with SWI-Prolog on the backend, but there’s no reason it shouldn’t work with other SPAs (I’ve also used VueJS) and without Logtalk. If Web Development with SWI-Prolog is new to you, you might want to run through Annie’s fantastic tutorial first.

Step 1: Use SWI-Prolog to Serve the App Page

SPA applications work with an index.html page you’ll find somewhere in your SPA directory. Inside that index.html, there will be a <div/> or some such tag where the application will appear, as well as some scripts that make the magic happen. You can either copy this file across to your SWI-Prolog directory and have SWI-Prolog serve it, or recreate it with termerized HTML.

SPA applications work with an index.html page you’ll find somewhere in your SPA directory. In that index.html, there will be a <div/> or some such tag where the application will appear, as well as some scripts that make the magic happen.

You can either copy this file across to your SWI-Prolog directory and have SWI-Prolog serve it, or recreate it with termerized HTML (a handy Prolog trick for building pages dynamically). Usually, I opt for termerized HTML so I can keep things consistent across webpages—like having the same navbar—and get dynamic behaviour, such as only showing a “Logout” button if a user is logged in.

Here’s how I set up the app page for ClojureScript:

:- handler('/myapp/', app_page, [id(myapp)]).

app_page(_Request) :-
	reply_html_page(
		[title(myapp)],
		[
			div(id='app', [p('Loading ...')]),
			script([src='js/compiled/myapp.js', type='text/javascript'], []),
			script(type='text/javascript', 'myapp.core._main();')
		]).

Step 2: Getting that JavaScript

Now that the page is served, let’s tackle the JavaScript! For my ClojureScript application to run, I’m requesting some JavaScript at js/compiled/myapp.js. This in turn, triggers requests for a far-too-many-to-bother-counting number of JavaScript files. None of these files are visible to my SWI-Prolog application. Also, none of these files are used in production: by then they’re all compiled down to one “little” file.

Why Proxy?

So, what I want SWI-Prolog to do is play middleman: when it’s asked for these files, it should fetch them from whatever’s serving my SPA. This means I need my SPA’s usual development server running too. For ClojureScript, that’s lein figwheel (a nifty ClojureScript tool), serving on http://localhost:3449. You’ll need to tweak the code below for your own SPA.

I get SWI-Prolog to serves these files by combining the prefix we use for serving static files with SWI-Prolog’s HTTP Client libraries. In my app the requests to the scripts are relative to the URL being served, so it’s actually going to ask for /myapp/js/compiled/myapp.js, which is why the handler here is root(myapp). If yours is an absolute URL, adapt accordingly.

Here’s the proxy magic in action:

:- use_module(library(http_client), [ http_get/3 ]).
:- use_module(library(http_open), [ http_close_keep_alive/1 ]).
:- use_module(library(http_header), [ http_reply_header/3 ]).

:- handler(root(myapp), spa_proxy, [id(spa_proxy), prefix, priority(-1)]).

handle(Request) :-
	proxy_url(Request, Url),
	proxy(Url).

proxy_url(Request, Url) :-
	member(path_info(RelPath), Request) ; RelPath = '/'), !,
	% comment these writes and nl out when you've seen it working!
	write(user_output, 'Forwarding: '),
	write(user_output, RelPath),
	nl(user_output),
	% adapt the base url to your spa
	atom_concat('http://localhost:3449', RelPath, URL).

proxy(Url) :-
	http_get(Url, Data, [
		headers(Headers),
		to(string),
		connection('Keep-alive')]),
	http_close_keep_alive(URL),
	member(content_type(ContentType), Headers),
	write('Content-type: '), write(ContentType),
	nl,
	nl,
	write(Data).

Step 3: Enjoy!

That should be about all you need. Spin up both processes, navigate to your SPA app served by SWI-Prolog, and watch the magic happen.

When it comes to production deployment just tweak the myapp handler to return HTML that loads your compiled JavaScript instead of the development stuff, and skip loading the spa_proxy handler. I do this with Logtalk, so for me this is just commenting out one line in the loader.lgt file, but you can do the same with SWI-Prolog if you use some kind of loader file too.

This was a bit of a pain to figure out the first time, but it has saved me so much bother and made developing frontend applications on a Prolog backend quite practical. I hope it helps you out too!